Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -268,3 +268,7 @@ See [THEORY.md](THEORY.md), [SPEC.md](SPEC.md), and
## License

Apache-2.0. See [LICENSE](LICENSE).

The independently versioned [Opsle Tasks capability package](capabilities/tasks/README.md)
provides the external adapter, explicit operator grant configuration, and a pinned
isolated compatibility regression in the normal test catalog.
5 changes: 5 additions & 0 deletions adapters/README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
# Adapters

Adapters translate host-specific data into the generic protocol. The core must remain usable by Codex workflows, Claude Code, OpenAI agent systems, CI, GitHub Actions, Temporal, custom orchestrators, and future systems without importing a particular application.

The independently installable Opsle Tasks compatibility package lives in
[`capabilities/tasks`](../capabilities/tasks/README.md). Its pinned generic-loader
regression is part of the normal `npm test` catalog; it does not add Tasks
integration dependencies to the core reducer.
3 changes: 2 additions & 1 deletion bin/context-firewall.js
Original file line number Diff line number Diff line change
Expand Up @@ -72,12 +72,13 @@ async function main() {
mechanismRevision: options.mechanismRevision,
});
if (options.valueReceiptPath) {
await writeFile(options.valueReceiptPath, `${canonicalJson(valueReceipt)}\n`, 'utf8');
await writeFile(options.valueReceiptPath, `${canonicalJson(valueReceipt)}\n`, { encoding: 'utf8', mode: 0o600 });
}
if (options.modelEvidencePath) {
await writeFile(
options.modelEvidencePath,
serializeModelEvidence(modelEvidenceForPacket(packet)),
{ mode: 0o600 },
);
}
process.stdout.write(serializePacket(packet));
Expand Down
21 changes: 21 additions & 0 deletions capabilities/tasks/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2026 Opsle

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
69 changes: 69 additions & 0 deletions capabilities/tasks/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# Context Firewall capability for Opsle Tasks

`@opsle/context-firewall-tasks-capability` 0.1.0 is independently versioned
from the standalone Context Firewall CLI (compatible baseline: 0.5.0,
revision `6dd6e5fdf21f28dc5ebfa07954aaa9bed2dbcc32`). It owns its adapter
and helpers; it invokes the configured CLI through its public JSON interface.
It has no npm dependencies or Tasks-internal imports.

Compatibility is pinned to the generic manifest/result contract in Opsle Tasks
`50f2666b2ddf3b95fc77c7e7bd8d64b6807e91f8`. Other Tasks releases require
compatibility verification before use. The required `command.evidence` hook is
a deterministic authority, never a model-backed hook.

## Trusted installation

An operator can build an archive with `npm pack --ignore-scripts` in this
directory, then install that archive with
`npm install --ignore-scripts --prefix /trusted/capability-install /path/to/archive.tgz`.
Install the standalone Context Firewall repository separately. Set
`OPSLE_CONTEXT_FIREWALL_REPO` to its absolute path and optionally set
`OPSLE_CONTEXT_FIREWALL_MAX_BYTES` (default 12000). The repository path has no
default and cannot be supplied through project-controlled selection.

Include the installed directory
`/trusted/capability-install/node_modules/@opsle/context-firewall-tasks-capability`
in the operator-owned `OPSLE_CAPABILITY_PATH` discovery roots (preserving other
required roots). Scope discovery so it contains exactly one installation of
`opsle.context-firewall`.

The manifest uses `default_enabled: false`. The operator must add
`opsle.context-firewall` to the project's `opsle.capability-grants.v1` `allow`
array. That grant activates the authority directly. Repository selection must
neither enable nor disable this authority. Keep grant changes outside active
executions, as required by Tasks policy. No live configuration is changed by
this package or its tests.

Revoke the operator grant to disable the authority; a subsequent required
invocation fails without an authority. Remove the installed package after
revocation. Reinstallation requires the same trusted discovery/configuration
and grant. For a compatible upgrade, replace the installed package and restart
Tasks so its ESM module cache is refreshed.

**Tasks-owned prerequisite:** the pinned Tasks release still bundles this same
capability identity. Removing that bundled package and any identity-specific
Tasks integration is a separate project-20 task. This repair does not perform
that removal or claim the original cross-repository feature is shipped.

## Evidence boundary and verification

Only `value.decisionEvidence` supplies the semantic model projection, with
exactly `protocol_version`, `operation_id`, and `decision_evidence`. The full
canonical packet remains in `auditEvidence`, and Visible Value measurements
travel in the separate private `receipts` channel. The complete result envelope
and raw `run` are not initial model context. Raw-evidence requests remain
explicit; an impossible ceiling remains a required failure. Delivery is marked
as constructed/stored, never as verified provider submission.

The repository's `npm test` runs `tests/tasks-capability.test.js`: it hashes
vendored pinned inputs, packs/installs offline in a temporary root, and invokes
the unmodified generic loader in fresh Node processes. It tests grant ownership,
private receipts, exact model projection, native Node reporter classification,
raw escalation, ceilings, removal/reinstall and a synthetic compatible patch
upgrade. The standalone CLI is copied into the isolated root. The test neither
uses the host Tasks checkout nor edits Tasks. It also checks all packaged JS
imports and verifies that loader/reference bytes remain unchanged.

The test-only harness denies unused host execution/model/provider imports;
it does not establish end-to-end provider delivery. Run the normal catalog
`npm test`, `npm run check`, and `npm run conformance` before release.
64 changes: 64 additions & 0 deletions capabilities/tasks/adapter.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { existsSync } from 'node:fs';
import { resolve } from 'node:path';
import { reduceWithContextFirewall } from './reduce.js';
import { capabilityResult } from './utils.js';

export function createCapability({ manifest, configuration, services }) {
return {
health() {
return {
available: existsSync(resolve(configuration.repository, 'bin', 'context-firewall.js')),
detail: `CLI not found under ${configuration.repository}`,
};
},
invoke(hook, payload) {
const evidence = reduceWithContextFirewall({
contextFirewallRepo: configuration.repository,
logsDir: services.logsDir,
}, {
...payload,
valueRunId: services.executionId,
maxBytes: configuration.maxBytes,
});
const measurements = evidence.packet?.receipt?.measurements;
if (!Number.isSafeInteger(measurements?.original_bytes)
|| !Number.isSafeInteger(measurements?.reduced_bytes)) {
throw new Error(`${manifest.name} returned invalid evidence measurements.`);
}
const normalized = {
schema: 'opsle.execution.command-evidence.v1',
operationId: evidence.packet.operation_id,
executionId: services.executionId,
summary: evidence.summary,
run: evidence.run,
decisionEvidence: {
value: evidence.modelEvidence,
text: evidence.modelEvidenceText,
path: evidence.modelEvidencePath,
},
auditEvidence: {
value: evidence.packet,
text: evidence.packetText,
path: evidence.packetPath,
inputHash: evidence.packet.receipt.input_hash,
metrics: {
originalBytes: measurements.original_bytes,
inputEnvelopeBytes: evidence.inputEnvelopeBytes,
reducedBytes: measurements.reduced_bytes,
decisionEvidenceBytes: Buffer.byteLength(
`${JSON.stringify(evidence.packet.decision_evidence)}\n`, 'utf8'),
modelEvidenceBytes: Buffer.byteLength(evidence.modelEvidenceText, 'utf8'),
},
},
};
return capabilityResult(manifest, hook, {
value: normalized,
receipts: [evidence.valueReceipt],
events: [{
kind: 'EVIDENCE',
message: `${manifest.name}${payload.rerun ? ' rerun' : ''} packet: ${evidence.packetPath}`,
}],
});
},
};
}
33 changes: 33 additions & 0 deletions capabilities/tasks/opsle-capability.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
{
"schema": "opsle.capability-manifest.v1",
"id": "opsle.context-firewall",
"name": "Context Firewall",
"version": "0.1.0",
"adapter": "adapter.js",
"default_enabled": false,
"configuration_schema": "opsle.context-firewall.tasks-config.v1",
"configuration": {
"repository": {
"type": "path",
"environment": "OPSLE_CONTEXT_FIREWALL_REPO",
"base": "manifest",
"required": true
},
"maxBytes": {
"type": "integer",
"environment": "OPSLE_CONTEXT_FIREWALL_MAX_BYTES",
"default": 12000,
"required": true
}
},
"hooks": [
{
"name": "command.evidence",
"input_schema": "opsle.execution.command-run.v1",
"output_schema": "opsle.execution.command-evidence.v1",
"role": "authority",
"execution": "deterministic",
"failure": "required"
}
]
}
17 changes: 17 additions & 0 deletions capabilities/tasks/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"name": "@opsle/context-firewall-tasks-capability",
"version": "0.1.0",
"type": "module",
"license": "MIT",
"files": [
"adapter.js",
"reduce.js",
"utils.js",
"opsle-capability.json",
"README.md",
"LICENSE"
],
"engines": {
"node": ">=20"
}
}
135 changes: 135 additions & 0 deletions capabilities/tasks/reduce.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { spawnSync } from 'node:child_process';
import { readFileSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { sourceRevision } from './utils.js';

const INPUT_PROTOCOL = 'opsle.context-firewall.test-run-input/v1';
const PACKET_PROTOCOL = 'opsle.context-firewall.evidence-packet/v1';
const MODEL_EVIDENCE_PROTOCOL = 'opsle.context-firewall.model-evidence/v1';
const TASKS_DELIVERY = Object.freeze({
schema: 'opsle.tasks.context-firewall-delivery.v2',
command_output: 'stored',
canonical_packet: 'stored',
model_evidence: 'constructed',
submission: 'recorded_by_separate_delivery_receipt_when_repair_runs',
});
const LEGACY_PACKET_DISPLAY_METRICS = new Set([
'initial_model_visible_bytes',
'bytes_initially_avoided',
'initial_reduction_ratio',
]);

const hasExactKeys = (value, keys) => value && typeof value === 'object'
&& !Array.isArray(value)
&& Object.keys(value).sort().join('\0') === [...keys].sort().join('\0');

function evidenceSummary(packet, label) {
const evidence = packet.decision_evidence;
const counts = evidence.counts;
const lines = [`${label}: ${evidence.status}${counts ? ` (${counts.passed} passed, ${counts.failed} failed, ${counts.skipped} skipped)` : ''}.`];
for (const failure of evidence.failures || []) lines.push(`Failed: ${failure.identity}`);
for (const fatal of evidence.fatal_errors || []) lines.push(`Fatal: ${fatal.text || fatal.identity || 'runner error'}`);
for (const warning of evidence.warnings || []) lines.push(`Warning: ${warning.text || warning.identity || 'warning'}`);
if (evidence.disposition === 'NEEDS_RAW_EVIDENCE') lines.push('Context Firewall requires the retained raw evidence for a conclusive reading.');
return lines.join('\n');
}

export function reduceWithContextFirewall(config, {
taskId, phase, sourceId, run, maxBytes = 12_000, valueRunId = null,
}) {
const operationId = `task-${taskId}-${phase.toLowerCase()}-${Date.now()}`;
const rawReference = `file:${run.stdoutPath};file:${run.stderrPath}`;
const processExitCode = Number.isInteger(run.code) && run.code >= 0 && run.code <= 255
? run.code
: null;
const input = {
protocol_version: INPUT_PROTOCOL,
operation_id: operationId,
source: { id: sourceId, run_id: valueRunId || operationId, raw_evidence_ref: rawReference },
process: { exit_code: processExitCode, duration_ms: run.durationMs, interrupted: run.interrupted },
streams: [
{ name: 'stdout', encoding: 'utf8', data: run.stdout },
{ name: 'stderr', encoding: 'utf8', data: run.stderr },
],
};
const binary = resolve(config.contextFirewallRepo, 'bin', 'context-firewall.js');
const receiptPath = resolve(config.logsDir, `${operationId}.value-receipt.json`);
const modelEvidencePath = resolve(config.logsDir, `${operationId}.model-evidence.json`);
const revision = sourceRevision(config.contextFirewallRepo);
const args = [binary, 'reduce', '--max-bytes', String(maxBytes), '--model-evidence', modelEvidencePath,
'--value-receipt', receiptPath];
if (revision) args.push('--mechanism-revision', revision);
const inputText = JSON.stringify(input);
const result = spawnSync(process.execPath, args, {
input: inputText, encoding: 'utf8', timeout: 10_000, maxBuffer: 2_000_000,
});
if (result.error) throw new Error(`Context Firewall is unavailable: ${result.error.message}`);
if (result.status !== 0) throw new Error(`Context Firewall rejected command evidence: ${result.stderr || 'unknown error'}`);
let packet;
try { packet = JSON.parse(result.stdout); } catch { throw new Error('Context Firewall returned invalid JSON.'); }
const expectedRunId = valueRunId || operationId;
if (packet.protocol_version !== PACKET_PROTOCOL
|| packet.operation_id !== operationId
|| packet.receipt?.source?.id !== sourceId
|| packet.receipt?.source?.run_id !== expectedRunId
|| packet.receipt?.raw_evidence?.reference !== rawReference
|| packet.decision_evidence?.process?.exit_code !== processExitCode
|| packet.decision_evidence?.process?.duration_ms !== run.durationMs
|| packet.decision_evidence?.process?.interrupted !== run.interrupted) {
throw new Error('Context Firewall returned evidence for a different command invocation.');
}
const packetPath = resolve(config.logsDir, `${operationId}.context-firewall.json`);
const packetText = result.stdout;
writeFileSync(packetPath, packetText, { mode: 0o600 });
let modelEvidenceText; let modelEvidence;
try {
modelEvidenceText = readFileSync(modelEvidencePath, 'utf8');
modelEvidence = JSON.parse(modelEvidenceText);
} catch { throw new Error('Context Firewall model evidence is missing or invalid.'); }
if (!hasExactKeys(modelEvidence, ['protocol_version', 'operation_id', 'decision_evidence'])
|| modelEvidence.protocol_version !== MODEL_EVIDENCE_PROTOCOL
|| modelEvidence.operation_id !== packet.operation_id
|| JSON.stringify(modelEvidence.decision_evidence) !== JSON.stringify(packet.decision_evidence)
) {
throw new Error('Context Firewall returned inconsistent model evidence.');
}
let producerReceipt;
try { producerReceipt = JSON.parse(readFileSync(receiptPath, 'utf8')); }
catch { throw new Error('Visible Value failed: Context Firewall receipt is missing or invalid.'); }
const valueReceipt = {
...producerReceipt,
measurements: Array.isArray(producerReceipt.measurements)
? producerReceipt.measurements.map(measurement =>
LEGACY_PACKET_DISPLAY_METRICS.has(measurement.id) ? {
...measurement,
operator_display: false,
limitations: [
...(measurement.limitations || []),
'Opsle Tasks stores the canonical packet as audit evidence; this legacy packet metric is not model delivery.',
],
} : measurement)
: producerReceipt.measurements,
extensions: {
...(producerReceipt.extensions || {}),
opsle_tasks_delivery: {
...TASKS_DELIVERY,
canonical_packet_bytes: Buffer.byteLength(packetText, 'utf8'),
model_evidence_bytes: Buffer.byteLength(modelEvidenceText, 'utf8'),
},
},
};
return {
packet,
packetPath,
packetText,
modelEvidence,
modelEvidencePath,
modelEvidenceText,
valueReceipt,
valueRunId,
receiptPath,
run,
inputEnvelopeBytes: Buffer.byteLength(inputText, 'utf8'),
summary: evidenceSummary(packet, phase),
};
}
Loading
Loading