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
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,18 @@ no-phone-home among its unproven gaps.
- Reviewer axes: is the proof exercised or derived; honesty of the strength category; failure
tokens wired to policy consequence; no autonomy on report.

### Post-merge notes

- The negative-egress check originally shipped as a hardcoded `negativeEgressProbePassed: false`
with no mechanism behind it and no escalation recorded — exactly the "declared constant" shape
this phase exists to forbid. The remediation replaces the constant with an exercised, injectable
egress-attempt check: the confined probe child dials a routable non-loopback target before
teardown and records the observed outcome (`blocked`/`open`/`ambiguous`) in the probe result
and attestation; only an explicit local/policy denial counts as `blocked` for the TEST-NET
target. On the current macOS process-group (weak) host the honest observation remains
not-blocked, so `strong` stays unreachable — the deliverable is the exercised mechanism and
recorded observation, not an EVRUN-full real-effect proof.

## Out Of Scope

- Remote execution hosts (product deferral).
Expand Down
7 changes: 6 additions & 1 deletion packages/jig-sdk/src/ports.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { ContainmentMechanism } from './providers/real/confinement.js';
import type { ContainmentMechanism, NegativeEgressOutcome } from './providers/real/confinement.js';
import type { PlanInstance, RunEvent, Story, WorkerResult } from './types.js';

export type IsolationStrength = 'none' | 'weak' | 'strong';
Expand All @@ -19,6 +19,11 @@ export interface CapabilityAttestation {
provenIsolationStrength?: IsolationStrength;
provenBy?: 'exercised-confinement-proof';
containmentMechanism?: ContainmentMechanism;
/**
* Raw observed outcome of the probe's negative-egress dial attempt, when the probe exercised
* one — lets downstream evidence cite the observation, not just derived booleans.
*/
negativeEgressObservedOutcome?: NegativeEgressOutcome;
failureToken?: HostFailureToken;
}

Expand Down
122 changes: 120 additions & 2 deletions packages/jig-sdk/src/providers/real/confinement.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,52 @@
import { spawn } from 'node:child_process';
import { once } from 'node:events';
import { createServer } from 'node:net';
import { createConnection, createServer } from 'node:net';
import { setTimeout as delay } from 'node:timers/promises';
import { type Clock, decideFreshness } from '../../clock.js';
import type { CapabilityFreshness, HostFailureToken, IsolationStrength } from '../../ports.js';
import type { SubstrateRequest } from '../../substrate.js';

export type ContainmentMechanism = 'process-group' | 'kernel-tree' | 'job-object';
const LOOPBACK_EGRESS_REQUEST = 'loopback-tcp-connect';
const NEGATIVE_EGRESS_REQUEST = 'negative-egress-tcp-connect';
const PROBE_GRACE_PERIOD_MS = 250;
const PROBE_FRESHNESS_WINDOW_MS = 60_000;

// TEST-NET-3 (RFC 5737, 203.0.113.0/24) is reserved documentation address space: it is
// routable-shaped (not loopback or link-local) but never assigned, so the dial attempt is a
// genuine outbound egress exercise that can never reach or disturb a real service — the
// no-phone-home discipline holds even when the real dialer runs in the smoke lane.
const NEGATIVE_EGRESS_TARGET_HOST = '203.0.113.1';
const NEGATIVE_EGRESS_TARGET_PORT = 443;
const NEGATIVE_EGRESS_TIMEOUT_MS = 1_500;

/**
* Observed outcome of the negative-egress dial attempt:
*
* - `blocked` — the local stack demonstrably denied the attempt by explicit local/policy
* refusal (EPERM/EACCES) before any route or peer response. Only this outcome may count
* toward a passed negative-egress probe.
* - `open` — the attempt demonstrably left the host: either the connection established, or a
* peer/network response came back (ECONNREFUSED/ECONNRESET means a packet round-trip
* happened, which proves egress was not blocked).
* - `ambiguous` — nothing conclusive was observed (timeout with no answer, or an unrecognized
* error). On an open-egress host dialing TEST-NET-3 this is the common honest outcome: no
* answer is indistinguishable from a silent block, so it must never count as `blocked`.
*/
export type NegativeEgressOutcome = 'blocked' | 'open' | 'ambiguous';

export interface NegativeEgressAttemptOptions {
host: string;
port: number;
timeoutMs: number;
}

interface ChildProbePayload {
observedAt: string;
observedExecArgv: string[];
parentPid: number;
loopbackReachable: boolean;
negativeEgressObservedOutcome: NegativeEgressOutcome;
}

type ProbeStream = Pick<NodeJS.ReadableStream, 'on'>;
Expand Down Expand Up @@ -65,11 +96,36 @@ const PROBE_CHILD_SOURCE = [
" socket.once('error', () => finish(false));",
' socket.setTimeout(500, () => finish(false));',
'});',
'const negativeEgressObservedOutcome = await new Promise((resolve) => {',
` const socket = createConnection({ host: ${JSON.stringify(NEGATIVE_EGRESS_TARGET_HOST)}, port: ${String(NEGATIVE_EGRESS_TARGET_PORT)} });`,
' let settled = false;',
' const finish = (outcome) => {',
' if (settled) return;',
' settled = true;',
' socket.destroy();',
' resolve(outcome);',
' };',
" socket.once('connect', () => finish('open'));",
" socket.once('error', (error) => {",
" const code = error instanceof Error && 'code' in error && typeof error.code === 'string' ? error.code : undefined;",
" if (code === 'ECONNREFUSED' || code === 'ECONNRESET') {",
" finish('open');",
' return;',
' }',
" if (code === 'EPERM' || code === 'EACCES') {",
" finish('blocked');",
' return;',
' }',
" finish('ambiguous');",
' });',
` socket.setTimeout(${String(NEGATIVE_EGRESS_TIMEOUT_MS)}, () => finish('ambiguous'));`,
'});',
'const payload = {',
' observedAt: new Date().toISOString(),',
' observedExecArgv: process.execArgv,',
' parentPid: process.ppid,',
' loopbackReachable,',
' negativeEgressObservedOutcome,',
'};',
'await new Promise((resolve, reject) => {',
" process.stdout.write(JSON.stringify(payload) + '\\n', (error) => {",
Expand All @@ -88,6 +144,13 @@ export interface ConfinementProbeResult {
freshnessWindowMs: number;
terminationProvedEmpty: boolean;
negativeEgressProbePassed: boolean;
/**
* The raw observed outcome of the negative-egress dial attempt, recorded alongside the
* derived pass/fail boolean so downstream evidence (attestations, the EVRUN-full
* no-phone-home leg) can cite what an exercised check actually observed instead of a
* constant. Absent only for probe doubles that do not exercise the check at all.
*/
negativeEgressObservedOutcome?: NegativeEgressOutcome;
containmentMechanism?: ContainmentMechanism;
commandBindingPassed: boolean;
parentageProbePassed: boolean;
Expand All @@ -101,6 +164,7 @@ export interface ConfinementProof {
provenIsolationStrength?: IsolationStrength;
provenBy?: 'exercised-confinement-proof';
containmentMechanism?: ContainmentMechanism;
negativeEgressObservedOutcome?: NegativeEgressOutcome;
failureToken?: HostFailureToken;
}

Expand Down Expand Up @@ -130,6 +194,57 @@ function proofSupportsClaimedStrength(result: ConfinementProbeResult): boolean {
return result.provenIsolationStrength === 'weak' || result.provenIsolationStrength === 'none';
}

const BLOCKED_EGRESS_ERROR_CODES = new Set(['EPERM', 'EACCES']);
const OPEN_EGRESS_ERROR_CODES = new Set(['ECONNREFUSED', 'ECONNRESET']);

export function classifyNegativeEgressDialError(error: unknown): NegativeEgressOutcome {
if (error instanceof Error && 'code' in error && typeof error.code === 'string') {
if (OPEN_EGRESS_ERROR_CODES.has(error.code)) {
return 'open';
}
if (BLOCKED_EGRESS_ERROR_CODES.has(error.code)) {
return 'blocked';
}
}

return 'ambiguous';
}

/**
* The real negative-egress dialer: attempts a TCP connect toward the configured target and
* classifies what was actually observed. The confinement probe child uses the same semantics;
* this helper exists so hermetic unit tests can exercise that classification with an injected
* `createConnection` double and without real network I/O.
*/
export async function attemptNegativeEgressDial(
options: NegativeEgressAttemptOptions,
createConnectionImpl: typeof createConnection = createConnection,
): Promise<NegativeEgressOutcome> {
return await new Promise((resolve) => {
const socket = createConnectionImpl({ host: options.host, port: options.port });
let settled = false;
const finish = (outcome: NegativeEgressOutcome) => {
if (settled) {
return;
}
settled = true;
socket.destroy();
resolve(outcome);
};
socket.once('connect', () => finish('open'));
socket.once('error', (error) => finish(classifyNegativeEgressDialError(error)));
socket.setTimeout(options.timeoutMs, () => finish('ambiguous'));
});
}

export function defaultNegativeEgressAttemptOptions(): NegativeEgressAttemptOptions {
return {
host: NEGATIVE_EGRESS_TARGET_HOST,
port: NEGATIVE_EGRESS_TARGET_PORT,
timeoutMs: NEGATIVE_EGRESS_TIMEOUT_MS,
};
}

function defaultConfinementRuntime(): ProcessGroupConfinementRuntime {
return {
currentPid: process.pid,
Expand Down Expand Up @@ -168,6 +283,7 @@ export function localProcessGroupProbeSubstrateRequests(execPath = process.execP
return [
{ kind: 'argv', value: localProcessGroupProbeCommand(execPath) },
{ kind: 'egress', value: LOOPBACK_EGRESS_REQUEST },
{ kind: 'egress', value: NEGATIVE_EGRESS_REQUEST },
];
}

Expand Down Expand Up @@ -276,7 +392,8 @@ export function createMacosProcessGroupConfinementProbe(
observedAt: payload.observedAt,
freshnessWindowMs: PROBE_FRESHNESS_WINDOW_MS,
terminationProvedEmpty,
negativeEgressProbePassed: false,
negativeEgressProbePassed: payload.negativeEgressObservedOutcome === 'blocked',
negativeEgressObservedOutcome: payload.negativeEgressObservedOutcome,
containmentMechanism: 'process-group',
commandBindingPassed,
parentageProbePassed,
Expand Down Expand Up @@ -305,6 +422,7 @@ export async function exerciseConfinementProbe(probe: ConfinementProbe, clock: C
provenIsolationStrength: result.provenIsolationStrength,
provenBy: positive ? 'exercised-confinement-proof' : undefined,
containmentMechanism: result.containmentMechanism,
negativeEgressObservedOutcome: result.negativeEgressObservedOutcome,
failureToken: positive ? undefined : 'containment-unproven',
};
}
21 changes: 18 additions & 3 deletions packages/jig-sdk/src/providers/real/host.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { type Clock, systemClock } from '../../clock.js';
import type { CapabilityAttestation, ExecutionHostPort, HostAttestation, IsolationStrength } from '../../ports.js';
import { type ApprovedSubstrateManifest, validateSubstrateRequest } from '../../substrate.js';
import {
type ApprovedSubstrateManifest,
SubstrateAuthorizationError,
validateSubstrateRequest,
} from '../../substrate.js';
import {
type ConfinementProbe,
createMacosProcessGroupConfinementProbe,
Expand Down Expand Up @@ -42,8 +46,17 @@ function isolationStrengthRank(strength: IsolationStrength): number {
export async function createRealExecutionHost(options: RealExecutionHostOptions): Promise<ExecutionHostPort> {
const capability = options.capability ?? 'filesystem-edit';
const runContext = options.runContext ?? 'local-real-host';
for (const request of options.probe.substrateRequests ?? []) {
if (options.substrateManifest) {
const probeSubstrateRequests = options.probe.substrateRequests ?? [];
if (probeSubstrateRequests.length > 0) {
// Fail closed: a probe that declares substrate requests without an approved manifest must
// refuse — silently skipping validation would let an unvetted request through unrecorded.
if (!options.substrateManifest) {
throw new SubstrateAuthorizationError(
probeSubstrateRequests[0],
`substrate-escalation: probe declared ${String(probeSubstrateRequests.length)} substrate request(s) but no approved substrate manifest was supplied`,
);
}
for (const request of probeSubstrateRequests) {
validateSubstrateRequest(options.substrateManifest, request);
}
}
Expand All @@ -62,6 +75,7 @@ export async function createRealExecutionHost(options: RealExecutionHostOptions)
provenIsolationStrength,
provenBy: proof.positive && !overstated ? proof.provenBy : undefined,
containmentMechanism: proof.containmentMechanism,
negativeEgressObservedOutcome: proof.negativeEgressObservedOutcome,
failureToken: overstated ? 'isolation-strength-overstated' : proof.failureToken,
};

Expand All @@ -81,6 +95,7 @@ export function createControlledStrongConfinementProbe(observedAt = new Date().t
freshnessWindowMs: 60_000,
terminationProvedEmpty: true,
negativeEgressProbePassed: true,
negativeEgressObservedOutcome: 'blocked',
containmentMechanism: 'process-group',
commandBindingPassed: true,
parentageProbePassed: true,
Expand Down
4 changes: 2 additions & 2 deletions packages/jig-sdk/src/substrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@ export interface ApprovedSubstrateManifest {
export class SubstrateAuthorizationError extends Error {
readonly request: SubstrateRequest;

constructor(request: SubstrateRequest) {
super(`substrate-escalation: ${request.kind} request is outside the approved manifest tuple`);
constructor(request: SubstrateRequest, message?: string) {
super(message ?? `substrate-escalation: ${request.kind} request is outside the approved manifest tuple`);
this.name = 'SubstrateAuthorizationError';
this.request = request;
}
Expand Down
Loading