@junghanacs/entwurf 0.36.0 → 0.36.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +194 -0
- package/DELIVERY.md +2 -0
- package/VERIFY.md +3 -3
- package/docs/acp-backend-rail.md +69 -5
- package/package.json +3 -3
- package/run.sh +4 -1
- package/scripts/check-acp-prompt-lifecycle.ts +4 -2
- package/scripts/check-acp-session-reuse.ts +4 -2
- package/scripts/check-acp-stop-reason.ts +4 -2
- package/scripts/check-acp-stream-hooks.ts +4 -2
- package/scripts/check-acp-usage-accounting.ts +4 -2
- package/scripts/check-gate-qualification.ts +2 -2
- package/scripts/check-herdr-supply.ts +32 -7
- package/scripts/fixtures/herdr-supply.json +10 -9
- package/scripts/lib/acp-gate-home.ts +75 -0
- package/scripts/lib/codex-socket-listener.ts +429 -0
- package/scripts/mutants/acp-stop-reason.json +11 -0
- package/scripts/mutants/codex-native.json +52 -0
- package/scripts/mutants/herdr-supply.json +6 -6
- package/scripts/smoke-codex-fresh-live.ts +31 -7
- package/scripts/smoke-pi-durable-fresh-live.ts +22 -3
|
@@ -3442,7 +3442,7 @@ let manifestCount: number;
|
|
|
3442
3442
|
"acp-overlay": 1,
|
|
3443
3443
|
"acp-prompt-lifecycle": 15,
|
|
3444
3444
|
"acp-set-model-readback": 2,
|
|
3445
|
-
"acp-stop-reason":
|
|
3445
|
+
"acp-stop-reason": 7,
|
|
3446
3446
|
"acp-stream-hooks": 10,
|
|
3447
3447
|
"acp-usage-accounting": 12,
|
|
3448
3448
|
"acp-vendor-rejection": 4,
|
|
@@ -3453,7 +3453,7 @@ let manifestCount: number;
|
|
|
3453
3453
|
"codex-app-server-launch": 10,
|
|
3454
3454
|
"codex-caller-seat": 28,
|
|
3455
3455
|
"control-send-receipt": 19,
|
|
3456
|
-
"codex-native":
|
|
3456
|
+
"codex-native": 87,
|
|
3457
3457
|
"compaction-send-guard": 7,
|
|
3458
3458
|
"control-socket-disconnect": 4,
|
|
3459
3459
|
"demo-reproducibility": 12,
|
|
@@ -32,7 +32,8 @@
|
|
|
32
32
|
* HSUP-CI-REQUIRES-HERDR that floor runs with absence turned red
|
|
33
33
|
* HSUP-CI-LINUX-ONLY only the Linux check job takes this; the other jobs are untouched
|
|
34
34
|
* HSUP-SETUP-NEVER-INSTALLS the product's own setup/package never installs herdr (Rule 17)
|
|
35
|
-
* HSUP-
|
|
35
|
+
* HSUP-EXECUTION-RECEIPT-BOUND an asset recorded as executed declares a receipt bound to this
|
|
36
|
+
* pin's version and its own full digest; an unexecuted one has none
|
|
36
37
|
*/
|
|
37
38
|
|
|
38
39
|
import assert from "node:assert/strict";
|
|
@@ -62,7 +63,34 @@ interface Supply {
|
|
|
62
63
|
measuredOn: string;
|
|
63
64
|
measuredArch: string;
|
|
64
65
|
releaseUrlPrefix: string;
|
|
65
|
-
assets: Record<
|
|
66
|
+
assets: Record<
|
|
67
|
+
string,
|
|
68
|
+
{ name: string; sha256: string; executed: boolean; executedNote: string; executedReceipt?: string }
|
|
69
|
+
>;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** An asset may be RECORDED as executed only with a declared receipt bound to THIS pin: the
|
|
73
|
+
* receipt BEGINS with the exact token `herdr <supply.version> ` (a substring match would let
|
|
74
|
+
* `herdr 0.9.40` stand for 0.9.4) and names the asset's FULL sha256. That checks the
|
|
75
|
+
* declaration's binding and nothing more — it is not a cryptographic receipt and cannot prove the
|
|
76
|
+
* binary ran. An unexecuted asset carries no receipt, both states keep a note, and the
|
|
77
|
+
* architecture the manifest says it was measured on must itself be recorded as executed. The
|
|
78
|
+
* architecture is never hardcoded: a future x86_64 `true` is admitted the same way, with its own. */
|
|
79
|
+
function executionReceiptsBound(supply: Supply): boolean {
|
|
80
|
+
const assets = Object.values(supply.assets);
|
|
81
|
+
const noted = (note: unknown): boolean => typeof note === "string" && note.trim().length > 0;
|
|
82
|
+
return (
|
|
83
|
+
assets.length > 0 &&
|
|
84
|
+
assets.every((a) =>
|
|
85
|
+
a.executed === true
|
|
86
|
+
? typeof a.executedReceipt === "string" &&
|
|
87
|
+
a.executedReceipt.startsWith(`herdr ${supply.version} `) &&
|
|
88
|
+
a.executedReceipt.includes(a.sha256) &&
|
|
89
|
+
noted(a.executedNote)
|
|
90
|
+
: a.executed === false && a.executedReceipt === undefined && noted(a.executedNote),
|
|
91
|
+
) &&
|
|
92
|
+
supply.assets[supply.measuredArch]?.executed === true
|
|
93
|
+
);
|
|
66
94
|
}
|
|
67
95
|
|
|
68
96
|
function main(): void {
|
|
@@ -171,11 +199,8 @@ function main(): void {
|
|
|
171
199
|
|
|
172
200
|
// ── honesty about what has actually run ──────────────────────────────────────────────
|
|
173
201
|
ok(
|
|
174
|
-
"[QK:HSUP-
|
|
175
|
-
supply
|
|
176
|
-
supply.assets.aarch64?.executed === true &&
|
|
177
|
-
supply.measuredArch === "aarch64" &&
|
|
178
|
-
(supply.assets.x86_64?.executedNote ?? "").length > 0,
|
|
202
|
+
"[QK:HSUP-EXECUTION-RECEIPT-BOUND] an asset is recorded as executed only with a DECLARED receipt bound to this pin's version and its own full digest, and an unexecuted one carries none — a pinned digest is never evidence that its cell ran, and a receipt from another pin or another asset is not this one's. This checks the declaration's binding, not that the binary ran",
|
|
203
|
+
executionReceiptsBound(supply),
|
|
179
204
|
);
|
|
180
205
|
|
|
181
206
|
console.log(`\n[check-herdr-supply] ${passed} assertions ok`);
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
|
-
"$comment": "SSOT for the herdr binary this repository has MEASURED against. Nothing else may carry a version or a digest: the CI installer, the sandbox gate's boundary check and the launch-rail document all read or cite this file. Re-measuring a new herdr means
|
|
3
|
-
"version": "0.9.
|
|
4
|
-
"tag": "v0.9.
|
|
5
|
-
"tagCommit": "
|
|
2
|
+
"$comment": "SSOT for the herdr binary this repository has MEASURED against. Nothing else may carry a version or a digest: the CI installer, the sandbox gate's boundary check and the launch-rail document all read or cite this file. Re-measuring a new herdr means measuring it FIRST — fetched by digest and verified before it is made executable, then run against a private sandbox server (at least version, status-client protocol and integration rows) — then editing this file and repairing the measured sentences in docs/herdr-launch-rail.md in the same commit; miss one and the gate fails on version mismatch, which is the point. A private baseline is not the full gate: check-herdr-sandbox at a new pin is certified by its exact-SHA CI run unless it was also run locally, and an asset is recorded `executed` only with a receipt bound to this version and its full digest (check-herdr-supply HSUP-EXECUTION-RECEIPT-BOUND).",
|
|
3
|
+
"version": "0.9.4",
|
|
4
|
+
"tag": "v0.9.4",
|
|
5
|
+
"tagCommit": "7e26bad94044eba205033111a0ce8033ede7e24f",
|
|
6
6
|
"socketProtocol": 22,
|
|
7
|
-
"measuredOn": "2026-10-
|
|
7
|
+
"measuredOn": "2026-10-14",
|
|
8
8
|
"measuredArch": "aarch64",
|
|
9
9
|
"releaseUrlPrefix": "https://github.com/herdrdev/herdr/releases/download",
|
|
10
10
|
"integrity": {
|
|
@@ -15,15 +15,16 @@
|
|
|
15
15
|
"assets": {
|
|
16
16
|
"x86_64": {
|
|
17
17
|
"name": "herdr-linux-x86_64",
|
|
18
|
-
"sha256": "
|
|
18
|
+
"sha256": "493304fbefb0ed0ea0455f373b47f48d24e8774bc7974f4c916ad2e937675b23",
|
|
19
19
|
"executed": false,
|
|
20
|
-
"executedNote": "
|
|
20
|
+
"executedNote": "not yet executed at this pin: the digest is pinned and named by GitHub's release attestation for pkg:github/herdrdev/herdr@v0.9.4, and the first CI check job at a SHA carrying this pin will be its first run. The previous pin's x86_64 asset (v0.9.3, sha256 18a8dc65…) did run there — CI run 38456259822, check-herdr-sandbox 12 assertions — which is dated history of that asset, not evidence for this one"
|
|
21
21
|
},
|
|
22
22
|
"aarch64": {
|
|
23
23
|
"name": "herdr-linux-aarch64",
|
|
24
|
-
"sha256": "
|
|
24
|
+
"sha256": "9511cb70982c1ef115f232058e7ed7cf9640aa5a2c0ca78934cefda3aeb093fd",
|
|
25
25
|
"executed": true,
|
|
26
|
-
"
|
|
26
|
+
"executedReceipt": "herdr 0.9.4 linux-aarch64 sha256 9511cb70982c1ef115f232058e7ed7cf9640aa5a2c0ca78934cefda3aeb093fd — 2026-10-14, oracle aarch64, Phase A private CLI baseline (docs/herdr-launch-rail.md, 재측정 2026-10-14): `herdr --version` 0.9.4; `status client` protocol 22, endpoint_protocol_generation 1; `integration status` 18 rows, zero diff against 0.9.3; pi (v9) and claude (v10) integrations seeded by 0.9.3 read `current` and a 0.9.4 reinstall leaves their three files byte-identical; `server stop` leaves no socket and no process",
|
|
27
|
+
"executedNote": "the receipt is a private CLI baseline only. check-herdr-sandbox has NOT run on this architecture at this pin — its operator-untouched cell reads the operator's own panes — so the full gate's first run at this pin is the CI check job, on the x86_64 asset — not this one. The operator host still runs 0.9.3 and was not touched. The 0.9.3 receipt (2026-10-03, #126) stays dated in docs/herdr-launch-rail.md"
|
|
27
28
|
}
|
|
28
29
|
}
|
|
29
30
|
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// In-process HOME sandbox for the ACP gates that drive the compiled `backend.streamAcpTurn`
|
|
2
|
+
// (Rule 12: offline verification never rewires the operator).
|
|
3
|
+
//
|
|
4
|
+
// The turn loop resolves the REAL routed adapter from the model id and calls its
|
|
5
|
+
// `ensureOverlay()`; there is no deps seam for it (`AcpTurnDeps`). Claude's overlay, the session
|
|
6
|
+
// cache and the operator settings path are `homedir()` constants evaluated when the compiled
|
|
7
|
+
// modules load. So the sandbox must exist BEFORE the first import of the emitted backend, and the
|
|
8
|
+
// gate proves it by reading the overlay constant from that same emitted module graph. Same env
|
|
9
|
+
// set as `pi-extensions/lib/acp/backend.vendor-rejection.test.ts`.
|
|
10
|
+
|
|
11
|
+
import { strict as assert } from "node:assert";
|
|
12
|
+
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
|
|
13
|
+
import { tmpdir } from "node:os";
|
|
14
|
+
import { join, resolve } from "node:path";
|
|
15
|
+
import { pathToFileURL } from "node:url";
|
|
16
|
+
|
|
17
|
+
const SANDBOXED_ENV = [
|
|
18
|
+
"HOME",
|
|
19
|
+
"XDG_CONFIG_HOME",
|
|
20
|
+
"XDG_DATA_HOME",
|
|
21
|
+
"XDG_STATE_HOME",
|
|
22
|
+
"XDG_CACHE_HOME",
|
|
23
|
+
"PI_CODING_AGENT_DIR",
|
|
24
|
+
"PI_SESSION_ID",
|
|
25
|
+
"PI_AGENT_ID",
|
|
26
|
+
"CLAUDE_AGENT_ACP_COMMAND",
|
|
27
|
+
"PI_EMACS_AGENT_SOCKET",
|
|
28
|
+
] as const;
|
|
29
|
+
|
|
30
|
+
export interface AcpGateHome {
|
|
31
|
+
readonly home: string;
|
|
32
|
+
/** Restore every sandboxed variable and remove the sandbox root. */
|
|
33
|
+
restore(): void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Point HOME, XDG and the pi agent dir into a fresh temp root. Call before importing backend. */
|
|
37
|
+
export function sandboxAcpGateHome(prefix: string): AcpGateHome {
|
|
38
|
+
const root = mkdtempSync(join(tmpdir(), prefix));
|
|
39
|
+
const saved = new Map<string, string | undefined>(SANDBOXED_ENV.map((key) => [key, process.env[key]]));
|
|
40
|
+
const home = join(root, "home");
|
|
41
|
+
mkdirSync(home, { recursive: true });
|
|
42
|
+
process.env.HOME = home;
|
|
43
|
+
process.env.XDG_CONFIG_HOME = join(root, "xdg-config");
|
|
44
|
+
process.env.XDG_DATA_HOME = join(root, "xdg-data");
|
|
45
|
+
process.env.XDG_STATE_HOME = join(root, "xdg-state");
|
|
46
|
+
process.env.XDG_CACHE_HOME = join(root, "xdg-cache");
|
|
47
|
+
process.env.PI_CODING_AGENT_DIR = join(home, ".pi", "agent");
|
|
48
|
+
delete process.env.PI_SESSION_ID;
|
|
49
|
+
delete process.env.PI_AGENT_ID;
|
|
50
|
+
delete process.env.CLAUDE_AGENT_ACP_COMMAND;
|
|
51
|
+
delete process.env.PI_EMACS_AGENT_SOCKET;
|
|
52
|
+
return {
|
|
53
|
+
home,
|
|
54
|
+
restore() {
|
|
55
|
+
for (const [key, value] of saved) {
|
|
56
|
+
if (value === undefined) delete process.env[key];
|
|
57
|
+
else process.env[key] = value;
|
|
58
|
+
}
|
|
59
|
+
rmSync(root, { recursive: true, force: true });
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Refuse to drive a turn unless the emitted overlay constant resolves inside the sandbox HOME. */
|
|
65
|
+
export async function assertOverlayInSandbox(emitDir: string, sandbox: AcpGateHome): Promise<void> {
|
|
66
|
+
const overlayMod = (await import(pathToFileURL(resolve(emitDir, "pi-extensions/lib/acp/overlay.js")).href)) as {
|
|
67
|
+
CLAUDE_CONFIG_OVERLAY_DIR: string;
|
|
68
|
+
};
|
|
69
|
+
assert.equal(
|
|
70
|
+
overlayMod.CLAUDE_CONFIG_OVERLAY_DIR,
|
|
71
|
+
join(sandbox.home, ".pi", "agent", "claude-config-overlay"),
|
|
72
|
+
"the compiled backend resolved the Claude overlay outside the gate's sandbox HOME, so its real " +
|
|
73
|
+
"ensureOverlay() would refresh the operator's overlay [QK:ACP-GATE-HOME-SANDBOXED]",
|
|
74
|
+
);
|
|
75
|
+
}
|
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* codex-socket-listener — does the operator-named Codex app-server's OWN process subtree hold the
|
|
3
|
+
* LISTEN socket at the control endpoint Entwurf actually connects to? (#128 CP-A1)
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS EXISTS, measured. `smoke-codex-fresh-live` asserted "the explicit app-server PID owns
|
|
6
|
+
* the same default UDS" by comparing argv against `--listen unix://<resolved default path>`. That
|
|
7
|
+
* is a statement of intent, and the two hosts that ran it measured the difference:
|
|
8
|
+
*
|
|
9
|
+
* - oracle, the 0.36.0 cut (`[측정 2026-10-14]`, cleanup receipts codex-procs-before.txt and
|
|
10
|
+
* codex-socket-before.txt): the named pid 851181 was the pnpm node WRAPPER, and its argv
|
|
11
|
+
* matched. The LISTEN socket was held by its child 851228, the native binary, at the 0.162.1
|
|
12
|
+
* physical path under `/tmp/codex-daemon-<euid>/`. The check passed on a process that held
|
|
13
|
+
* no socket.
|
|
14
|
+
* - thinkpad (#128 comment 6157960561): the named pid WAS the listener, but it was started with
|
|
15
|
+
* `--listen unix://` (the vendor default, no path), so the check refused it.
|
|
16
|
+
*
|
|
17
|
+
* So ownership is read from the kernel, never from argv. The endpoint is not resolved here: the
|
|
18
|
+
* caller passes the `connectPath` of the production classifier `checkCodexSocketFile`, the same
|
|
19
|
+
* socket delivery connects to. This leaf adds no alias rule and has no path seam that could stand
|
|
20
|
+
* in for that check.
|
|
21
|
+
*
|
|
22
|
+
* Two halves, kept apart on purpose:
|
|
23
|
+
*
|
|
24
|
+
* OBSERVE (read-only IO, real `/proc` only). It reads the named process's own network
|
|
25
|
+
* namespace table (`/proc/<pid>/net/unix`) and keeps only the LISTEN stream rows bound at
|
|
26
|
+
* exactly the endpoint. With exactly one such row, it walks DOWN from the named pid alone: the
|
|
27
|
+
* kernel's children lists, each child's parent re-read from its own `stat`, and `fd` links
|
|
28
|
+
* compared against that one inode. Then a bounded SECOND read: every walked node's `stat`
|
|
29
|
+
* again (parent and start time), and the `fd` links again of each node that held the inode.
|
|
30
|
+
* The table is read last. There is no host-wide pid search, no environment read and no reach
|
|
31
|
+
* above or beside the named pid.
|
|
32
|
+
*
|
|
33
|
+
* JUDGE (pure). Positive ONLY when exactly one process inside that subtree held exactly that
|
|
34
|
+
* inode, still holds it on the second read, its parent chain reaches the named pid through
|
|
35
|
+
* observed nodes, and the named pid is still the process whose identity the caller checked
|
|
36
|
+
* (its start time, captured by the caller BEFORE those checks). Descent alone is not
|
|
37
|
+
* evidence — the held inode is. Everything else fails closed, and the reason says which kind
|
|
38
|
+
* of failure it is:
|
|
39
|
+
* not-owned a LISTEN row exists and NO process in the named subtree holds it. This does
|
|
40
|
+
* not name a foreign owner; nothing outside the subtree was read.
|
|
41
|
+
* indeterminate zero or several rows, several holders, an unreadable or unrecognised entry,
|
|
42
|
+
* or a pid, parent, fd or row that moved between the reads. None of these say
|
|
43
|
+
* who owns the socket, so none of them may read as an answer either way.
|
|
44
|
+
*
|
|
45
|
+
* What a positive does NOT mean: a self-listener (thinkpad shape) being owned says nothing
|
|
46
|
+
* about whether a vendor-managed daemon is an admitted operator shape, nor about the tmux seat
|
|
47
|
+
* the same smoke checks separately. Those stay their own contracts.
|
|
48
|
+
*/
|
|
49
|
+
import * as fs from "node:fs";
|
|
50
|
+
|
|
51
|
+
/** One LISTEN-state stream socket bound at exactly the endpoint, as a kernel table row. */
|
|
52
|
+
export interface UnixListenRow {
|
|
53
|
+
readonly inode: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export class UnixTableError extends Error {}
|
|
57
|
+
|
|
58
|
+
const TABLE_HEADER = /^Num\s+RefCount\s+Protocol\s+Flags\s+Type\s+St\s+Inode(?:\s+Path)?\s*$/;
|
|
59
|
+
// `%pK: %08X %08X %08X %04X %02X %5lu` and then ` <path>` when the socket is bound.
|
|
60
|
+
const TABLE_ROW =
|
|
61
|
+
/^[0-9A-Fa-f]+:\s+[0-9A-Fa-f]{8}\s+[0-9A-Fa-f]{8}\s+([0-9A-Fa-f]{8})\s+([0-9A-Fa-f]{4})\s+[0-9A-Fa-f]{2}\s+(\d+)(?: (.*))?$/;
|
|
62
|
+
const SO_ACCEPTCON = 0x10000;
|
|
63
|
+
const SOCK_STREAM = 0x0001;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The LISTEN stream rows of a `/proc/<pid>/net/unix` table whose path is byte-equal to
|
|
67
|
+
* `endpoint`. A connected row at the same path, a datagram socket, a path that only shares a
|
|
68
|
+
* prefix and an abstract socket are not rows of this endpoint. A table this parser does not
|
|
69
|
+
* recognise throws rather than being read around.
|
|
70
|
+
*/
|
|
71
|
+
export function listenRowsAt(table: string, endpoint: string): UnixListenRow[] {
|
|
72
|
+
const lines = table.split("\n");
|
|
73
|
+
if (!TABLE_HEADER.test(lines[0] ?? "")) {
|
|
74
|
+
throw new UnixTableError(`unrecognised unix socket table header: ${JSON.stringify(lines[0] ?? "")}`);
|
|
75
|
+
}
|
|
76
|
+
const rows: UnixListenRow[] = [];
|
|
77
|
+
for (const line of lines.slice(1)) {
|
|
78
|
+
if (line === "") continue;
|
|
79
|
+
const match = TABLE_ROW.exec(line.trimStart());
|
|
80
|
+
if (match === null) throw new UnixTableError(`unrecognised unix socket table row: ${JSON.stringify(line)}`);
|
|
81
|
+
const [, flags, type, inode, boundPath] = match;
|
|
82
|
+
if (boundPath !== endpoint) continue;
|
|
83
|
+
if ((Number.parseInt(flags ?? "", 16) & SO_ACCEPTCON) === 0) continue;
|
|
84
|
+
if (Number.parseInt(type ?? "", 16) !== SOCK_STREAM) continue;
|
|
85
|
+
rows.push({ inode: inode as string });
|
|
86
|
+
}
|
|
87
|
+
return rows;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface ProcStat {
|
|
91
|
+
readonly comm: string;
|
|
92
|
+
readonly ppid: number;
|
|
93
|
+
readonly startTime: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* `/proc/<pid>/stat`. `comm` may itself contain spaces and parentheses, so the fixed fields are
|
|
98
|
+
* counted from the LAST `)`: field 4 is the parent pid and field 22 the start time in clock
|
|
99
|
+
* ticks since boot — the pair that tells a pid apart from a later process that reused it.
|
|
100
|
+
*/
|
|
101
|
+
export function parseProcStat(text: string): ProcStat {
|
|
102
|
+
const open = text.indexOf("(");
|
|
103
|
+
const close = text.lastIndexOf(")");
|
|
104
|
+
if (open < 0 || close < open) throw new Error(`unrecognised stat line: ${JSON.stringify(text)}`);
|
|
105
|
+
const fields = text
|
|
106
|
+
.slice(close + 1)
|
|
107
|
+
.trim()
|
|
108
|
+
.split(/\s+/);
|
|
109
|
+
const ppid = Number(fields[1]);
|
|
110
|
+
const startTime = fields[19] ?? "";
|
|
111
|
+
if (!Number.isInteger(ppid) || ppid < 0 || !/^\d+$/.test(startTime)) {
|
|
112
|
+
throw new Error(`unrecognised stat fields: ${JSON.stringify(text)}`);
|
|
113
|
+
}
|
|
114
|
+
return { comm: text.slice(open + 1, close), ppid, startTime };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export interface SubtreeNode {
|
|
118
|
+
readonly pid: number;
|
|
119
|
+
readonly ppid: number;
|
|
120
|
+
readonly comm: string;
|
|
121
|
+
readonly startTime: string;
|
|
122
|
+
/** fd numbers whose link is exactly `socket:[<the endpoint's LISTEN inode>]`. */
|
|
123
|
+
readonly holdingFds: readonly number[];
|
|
124
|
+
/**
|
|
125
|
+
* The second read, after the whole walk. `null` = the pid could not be read again. `holdingFds`
|
|
126
|
+
* is re-read only for a node that held the inode on the first read, and is `null` otherwise.
|
|
127
|
+
*/
|
|
128
|
+
readonly after: {
|
|
129
|
+
readonly ppid: number;
|
|
130
|
+
readonly startTime: string;
|
|
131
|
+
readonly holdingFds: readonly number[] | null;
|
|
132
|
+
} | null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export type ObservationFailureReason =
|
|
136
|
+
| "subtree-unreadable"
|
|
137
|
+
| "subtree-changed"
|
|
138
|
+
| "subtree-too-large"
|
|
139
|
+
| "table-malformed";
|
|
140
|
+
|
|
141
|
+
export interface CodexSocketObservation {
|
|
142
|
+
readonly namedPid: number;
|
|
143
|
+
/** The named pid's start time as the CALLER read it before its own identity checks. */
|
|
144
|
+
readonly namedStartTime: string;
|
|
145
|
+
readonly endpoint: string;
|
|
146
|
+
readonly rows: readonly UnixListenRow[];
|
|
147
|
+
readonly rowsAfter: readonly UnixListenRow[];
|
|
148
|
+
/** Breadth-first from the named pid; empty unless exactly one row was found. */
|
|
149
|
+
readonly nodes: readonly SubtreeNode[];
|
|
150
|
+
readonly failure: { readonly reason: ObservationFailureReason; readonly detail: string } | null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export type CodexSocketOwnership =
|
|
154
|
+
| {
|
|
155
|
+
readonly verdict: "owned";
|
|
156
|
+
readonly inode: string;
|
|
157
|
+
/** Named pid first, the listener last. */
|
|
158
|
+
readonly chain: readonly SubtreeNode[];
|
|
159
|
+
readonly fds: readonly number[];
|
|
160
|
+
}
|
|
161
|
+
| { readonly verdict: "not-owned"; readonly reason: "not-held-in-named-subtree"; readonly detail: string }
|
|
162
|
+
| {
|
|
163
|
+
readonly verdict: "indeterminate";
|
|
164
|
+
readonly reason:
|
|
165
|
+
| ObservationFailureReason
|
|
166
|
+
| "no-listen-row"
|
|
167
|
+
| "several-listen-rows"
|
|
168
|
+
| "listen-row-changed"
|
|
169
|
+
| "named-identity-moved"
|
|
170
|
+
| "several-holders"
|
|
171
|
+
| "listener-fd-released"
|
|
172
|
+
| "chain-broken";
|
|
173
|
+
readonly detail: string;
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
export function judgeCodexSocketOwnership(o: CodexSocketObservation): CodexSocketOwnership {
|
|
177
|
+
if (o.failure !== null) return { verdict: "indeterminate", reason: o.failure.reason, detail: o.failure.detail };
|
|
178
|
+
if (o.rows.length === 0) {
|
|
179
|
+
return {
|
|
180
|
+
verdict: "indeterminate",
|
|
181
|
+
reason: "no-listen-row",
|
|
182
|
+
detail:
|
|
183
|
+
`no LISTEN stream socket in pid ${o.namedPid}'s network namespace is bound at exactly ${o.endpoint}; ` +
|
|
184
|
+
"ownership cannot be read, and the absence does not name another owner",
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
if (o.rows.length > 1) {
|
|
188
|
+
return {
|
|
189
|
+
verdict: "indeterminate",
|
|
190
|
+
reason: "several-listen-rows",
|
|
191
|
+
detail: `${o.rows.length} LISTEN rows are bound at ${o.endpoint} (inodes ${o.rows.map((r) => r.inode).join(", ")})`,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
const inode = (o.rows[0] as UnixListenRow).inode;
|
|
195
|
+
if (o.rowsAfter.length !== 1 || o.rowsAfter[0]?.inode !== inode) {
|
|
196
|
+
return {
|
|
197
|
+
verdict: "indeterminate",
|
|
198
|
+
reason: "listen-row-changed",
|
|
199
|
+
detail: `the LISTEN row at ${o.endpoint} was inode ${inode} before the fd join and ${JSON.stringify(o.rowsAfter.map((r) => r.inode))} after`,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
const named = o.nodes[0];
|
|
203
|
+
if (named?.pid !== o.namedPid || named.startTime !== o.namedStartTime) {
|
|
204
|
+
return {
|
|
205
|
+
verdict: "indeterminate",
|
|
206
|
+
reason: "named-identity-moved",
|
|
207
|
+
detail:
|
|
208
|
+
`pid ${o.namedPid} started at ${o.namedStartTime} when its identity was checked, but the walk began at ` +
|
|
209
|
+
`${named === undefined ? "no node" : `pid ${named.pid} started at ${named.startTime}`}`,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
const moved = o.nodes.filter((node) => node.after?.startTime !== node.startTime || node.after?.ppid !== node.ppid);
|
|
213
|
+
if (moved.length > 0) {
|
|
214
|
+
return {
|
|
215
|
+
verdict: "indeterminate",
|
|
216
|
+
reason: "subtree-changed",
|
|
217
|
+
detail: `pid(s) ${moved.map((node) => node.pid).join(", ")} exited, were reused or changed parent while the subtree was read`,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
const holders = o.nodes.filter((node) => node.holdingFds.length > 0);
|
|
221
|
+
if (holders.length === 0) {
|
|
222
|
+
return {
|
|
223
|
+
verdict: "not-owned",
|
|
224
|
+
reason: "not-held-in-named-subtree",
|
|
225
|
+
detail:
|
|
226
|
+
`no process in pid ${o.namedPid}'s subtree (${o.nodes.map((node) => node.pid).join(", ")}) holds the LISTEN socket ` +
|
|
227
|
+
`inode ${inode} at ${o.endpoint}; nothing outside that subtree was read, so no owner is named`,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
if (holders.length > 1) {
|
|
231
|
+
return {
|
|
232
|
+
verdict: "indeterminate",
|
|
233
|
+
reason: "several-holders",
|
|
234
|
+
detail: `pids ${holders.map((node) => node.pid).join(", ")} all hold LISTEN inode ${inode}`,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
const listener = holders[0] as SubtreeNode;
|
|
238
|
+
// Same pid, same start time and a stable row are not enough: the holder may have closed or
|
|
239
|
+
// handed off its fd while a process this walk never re-read kept the inode open.
|
|
240
|
+
const heldAfter = listener.after?.holdingFds ?? [];
|
|
241
|
+
if (heldAfter.length === 0) {
|
|
242
|
+
return {
|
|
243
|
+
verdict: "indeterminate",
|
|
244
|
+
reason: "listener-fd-released",
|
|
245
|
+
detail: `pid ${listener.pid} held LISTEN inode ${inode} on fd ${listener.holdingFds.join(",")} but no longer did on the second read`,
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
const byPid = new Map(o.nodes.map((node) => [node.pid, node] as const));
|
|
249
|
+
const chain: SubtreeNode[] = [listener];
|
|
250
|
+
for (let node = listener; node.pid !== o.namedPid; ) {
|
|
251
|
+
const parent = byPid.get(node.ppid);
|
|
252
|
+
if (parent === undefined || chain.includes(parent)) {
|
|
253
|
+
return {
|
|
254
|
+
verdict: "indeterminate",
|
|
255
|
+
reason: "chain-broken",
|
|
256
|
+
detail: `listener ${listener.pid}'s parent chain does not reach the named pid ${o.namedPid} through observed nodes`,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
chain.unshift(parent);
|
|
260
|
+
node = parent;
|
|
261
|
+
}
|
|
262
|
+
return { verdict: "owned", inode, chain, fds: heldAfter };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** One line for an assertion detail or a receipt. */
|
|
266
|
+
export function describeCodexSocketOwnership(ownership: CodexSocketOwnership): string {
|
|
267
|
+
if (ownership.verdict !== "owned") return `${ownership.verdict} (${ownership.reason}): ${ownership.detail}`;
|
|
268
|
+
const listener = ownership.chain[ownership.chain.length - 1] as SubtreeNode;
|
|
269
|
+
return (
|
|
270
|
+
`owned: LISTEN inode ${ownership.inode} held by pid ${listener.pid} (${listener.comm}) fd ${ownership.fds.join(",")}; ` +
|
|
271
|
+
`chain ${ownership.chain.map((node) => `${node.pid}(${node.comm})`).join(" -> ")}`
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ---------------------------------------------------------------------------
|
|
276
|
+
// OBSERVE — real /proc only
|
|
277
|
+
// ---------------------------------------------------------------------------
|
|
278
|
+
|
|
279
|
+
/** Bound on the walk. The Codex trees measured so far have one to three processes. */
|
|
280
|
+
const SUBTREE_LIMIT = 64;
|
|
281
|
+
|
|
282
|
+
class ObservationFailure extends Error {
|
|
283
|
+
readonly reason: ObservationFailureReason;
|
|
284
|
+
constructor(reason: ObservationFailureReason, detail: string) {
|
|
285
|
+
super(detail);
|
|
286
|
+
this.reason = reason;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function failureFrom(error: unknown, what: string): ObservationFailure {
|
|
291
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
292
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
293
|
+
// A pid, task or fd that disappeared between two reads moved under us. A permission refusal
|
|
294
|
+
// or anything else means the entry could not be read, which is not the same statement.
|
|
295
|
+
if (code === "ENOENT" || code === "ESRCH") return new ObservationFailure("subtree-changed", `${what}: ${message}`);
|
|
296
|
+
return new ObservationFailure("subtree-unreadable", `${what}: ${message}`);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function readStat(pid: number): ProcStat {
|
|
300
|
+
try {
|
|
301
|
+
return parseProcStat(fs.readFileSync(`/proc/${pid}/stat`, "utf8"));
|
|
302
|
+
} catch (error) {
|
|
303
|
+
throw failureFrom(error, `stat of pid ${pid}`);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function readListenRows(namedPid: number, endpoint: string): UnixListenRow[] {
|
|
308
|
+
let table: string;
|
|
309
|
+
try {
|
|
310
|
+
table = fs.readFileSync(`/proc/${namedPid}/net/unix`, "utf8");
|
|
311
|
+
} catch (error) {
|
|
312
|
+
throw failureFrom(error, `unix socket table of pid ${namedPid}`);
|
|
313
|
+
}
|
|
314
|
+
try {
|
|
315
|
+
return listenRowsAt(table, endpoint);
|
|
316
|
+
} catch (error) {
|
|
317
|
+
throw new ObservationFailure("table-malformed", error instanceof Error ? error.message : String(error));
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function holdingFds(pid: number, target: string): number[] {
|
|
322
|
+
const dir = `/proc/${pid}/fd`;
|
|
323
|
+
let entries: string[];
|
|
324
|
+
try {
|
|
325
|
+
entries = fs.readdirSync(dir);
|
|
326
|
+
} catch (error) {
|
|
327
|
+
throw failureFrom(error, `fd list of pid ${pid}`);
|
|
328
|
+
}
|
|
329
|
+
const held: number[] = [];
|
|
330
|
+
for (const entry of entries) {
|
|
331
|
+
let link: string;
|
|
332
|
+
try {
|
|
333
|
+
link = fs.readlinkSync(`${dir}/${entry}`);
|
|
334
|
+
} catch (error) {
|
|
335
|
+
throw failureFrom(error, `fd ${entry} of pid ${pid}`);
|
|
336
|
+
}
|
|
337
|
+
if (link === target) held.push(Number(entry));
|
|
338
|
+
}
|
|
339
|
+
return held;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function childPids(pid: number): number[] {
|
|
343
|
+
let tids: string[];
|
|
344
|
+
try {
|
|
345
|
+
tids = fs.readdirSync(`/proc/${pid}/task`);
|
|
346
|
+
} catch (error) {
|
|
347
|
+
throw failureFrom(error, `task list of pid ${pid}`);
|
|
348
|
+
}
|
|
349
|
+
const children: number[] = [];
|
|
350
|
+
for (const tid of tids) {
|
|
351
|
+
const file = `/proc/${pid}/task/${tid}/children`;
|
|
352
|
+
let text: string;
|
|
353
|
+
try {
|
|
354
|
+
text = fs.readFileSync(file, "utf8");
|
|
355
|
+
} catch (error) {
|
|
356
|
+
// The task still being there while its children list is not means this kernel does not
|
|
357
|
+
// expose one, which is unreadable rather than a process that moved.
|
|
358
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT" && fs.existsSync(`/proc/${pid}/task/${tid}`)) {
|
|
359
|
+
throw new ObservationFailure("subtree-unreadable", `${file}: this kernel exposes no children list`);
|
|
360
|
+
}
|
|
361
|
+
throw failureFrom(error, file);
|
|
362
|
+
}
|
|
363
|
+
for (const token of text.trim().split(/\s+/)) if (token !== "") children.push(Number(token));
|
|
364
|
+
}
|
|
365
|
+
return children;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function statOrNull(pid: number): ProcStat | null {
|
|
369
|
+
try {
|
|
370
|
+
return parseProcStat(fs.readFileSync(`/proc/${pid}/stat`, "utf8"));
|
|
371
|
+
} catch {
|
|
372
|
+
// Bounded probe: a pid that is gone answers null, and the judge reads null as moved.
|
|
373
|
+
return null;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* @param namedStartTime the named pid's start time as the caller read it BEFORE its own identity
|
|
379
|
+
* checks (uid, argv, environment), so this observation is bound to the process those checks read.
|
|
380
|
+
*/
|
|
381
|
+
export function observeCodexSocketSubtree(
|
|
382
|
+
namedPid: number,
|
|
383
|
+
endpoint: string,
|
|
384
|
+
namedStartTime: string,
|
|
385
|
+
): CodexSocketObservation {
|
|
386
|
+
const base = { namedPid, namedStartTime, endpoint };
|
|
387
|
+
let rows: UnixListenRow[] = [];
|
|
388
|
+
try {
|
|
389
|
+
rows = readListenRows(namedPid, endpoint);
|
|
390
|
+
if (rows.length !== 1) return { ...base, rows, rowsAfter: rows, nodes: [], failure: null };
|
|
391
|
+
const target = `socket:[${(rows[0] as UnixListenRow).inode}]`;
|
|
392
|
+
const visited: Array<Omit<SubtreeNode, "after">> = [];
|
|
393
|
+
const queue: Array<{ pid: number; parent: number | null }> = [{ pid: namedPid, parent: null }];
|
|
394
|
+
while (queue.length > 0) {
|
|
395
|
+
const next = queue.shift() as { pid: number; parent: number | null };
|
|
396
|
+
if (visited.length >= SUBTREE_LIMIT) {
|
|
397
|
+
throw new ObservationFailure(
|
|
398
|
+
"subtree-too-large",
|
|
399
|
+
`pid ${namedPid}'s subtree exceeds ${SUBTREE_LIMIT} processes`,
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
const stat = readStat(next.pid);
|
|
403
|
+
if (next.parent !== null && stat.ppid !== next.parent) {
|
|
404
|
+
throw new ObservationFailure(
|
|
405
|
+
"subtree-changed",
|
|
406
|
+
`pid ${next.pid} was listed as a child of ${next.parent} but its stat names parent ${stat.ppid}`,
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
const fds = holdingFds(next.pid, target);
|
|
410
|
+
for (const child of childPids(next.pid)) queue.push({ pid: child, parent: next.pid });
|
|
411
|
+
visited.push({ pid: next.pid, ppid: stat.ppid, comm: stat.comm, startTime: stat.startTime, holdingFds: fds });
|
|
412
|
+
}
|
|
413
|
+
// The second read is bounded to what the first one found: every walked node's stat, and the
|
|
414
|
+
// fds of the nodes that held the inode. The table is read last, after both.
|
|
415
|
+
const nodes: SubtreeNode[] = visited.map((node) => {
|
|
416
|
+
const stat = statOrNull(node.pid);
|
|
417
|
+
if (stat === null) return { ...node, after: null };
|
|
418
|
+
const held = node.holdingFds.length > 0 ? holdingFds(node.pid, target) : null;
|
|
419
|
+
return { ...node, after: { ppid: stat.ppid, startTime: stat.startTime, holdingFds: held } };
|
|
420
|
+
});
|
|
421
|
+
const rowsAfter = readListenRows(namedPid, endpoint);
|
|
422
|
+
return { ...base, rows, rowsAfter, nodes, failure: null };
|
|
423
|
+
} catch (error) {
|
|
424
|
+
// Every read above converts its own failure. Anything else is a defect here, not a
|
|
425
|
+
// fact about the subtree, and crashes instead of becoming a verdict.
|
|
426
|
+
if (!(error instanceof ObservationFailure)) throw error;
|
|
427
|
+
return { ...base, rows, rowsAfter: [], nodes: [], failure: { reason: error.reason, detail: error.message } };
|
|
428
|
+
}
|
|
429
|
+
}
|
|
@@ -75,6 +75,17 @@
|
|
|
75
75
|
"timeoutSeconds": 120,
|
|
76
76
|
"signature": "[QK:STOP-PENDING-SEED]",
|
|
77
77
|
"signatureSource": "scripts/check-acp-stop-reason.ts"
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
"claim": "ACP-GATE-HOME-SANDBOXED",
|
|
81
|
+
"title": "the ACP gate sandbox leaves HOME on the operator's home — the compiled backend resolves the Claude overlay there, so its real ensureOverlay() would refresh the operator's overlay",
|
|
82
|
+
"subject": "scripts/lib/acp-gate-home.ts",
|
|
83
|
+
"find": ["\tprocess.env.HOME = home;"],
|
|
84
|
+
"replace": ["\t// HOME left on the operator's home"],
|
|
85
|
+
"gate": ["bash", "run.sh", "check-acp-stop-reason"],
|
|
86
|
+
"timeoutSeconds": 120,
|
|
87
|
+
"signature": "[QK:ACP-GATE-HOME-SANDBOXED]",
|
|
88
|
+
"signatureSource": "scripts/lib/acp-gate-home.ts"
|
|
78
89
|
}
|
|
79
90
|
]
|
|
80
91
|
}
|