@lyupro/codex-bridge 0.5.2 → 0.5.3
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 +420 -336
- package/README.md +3 -3
- package/cli/doctor.mjs +399 -392
- package/cli/settings-merge.mjs +55 -10
- package/package.json +2 -2
- package/scripts/run-tests.mjs +30 -1
- package/scripts/suite-count.mjs +65 -0
- package/src/home/hooks/shell-write-intent.mjs +120 -0
- package/src/home/hooks/worktree-lock.mjs +109 -77
- package/src/home/hooks/worktree-witness.mjs +113 -0
- package/src/home/lib/heartbeat.mjs +4 -1
- package/src/home/lib/hook-definitions.mjs +10 -1
- package/src/home/lib/runner/codex-cmd.mjs +398 -392
- package/src/home/lib/runner/sandbox-flags.mjs +27 -0
package/cli/settings-merge.mjs
CHANGED
|
@@ -134,29 +134,41 @@ const hasOwnCommand = (group, commands) => {
|
|
|
134
134
|
};
|
|
135
135
|
|
|
136
136
|
/**
|
|
137
|
-
* Presence is decided by the command, not by the matcher it currently sits under
|
|
137
|
+
* Presence is decided by the command, not by the matcher it currently sits under; writing then
|
|
138
|
+
* places that command under the matcher the package declares. These are two halves of one rule.
|
|
138
139
|
*
|
|
139
140
|
* The matcher is generated from the tool list in hook-definitions.mjs, so it changes whenever a
|
|
140
141
|
* host spelling is added — `Agent` became `Agent|Task` in one release, and the write-tool matcher
|
|
141
142
|
* will grow the same way. A host installed under the old matcher would then be invisible to the
|
|
142
143
|
* new lookup: update would register a duplicate and uninstall would leave a hook pointing at a
|
|
143
|
-
* deleted file.
|
|
144
|
-
*
|
|
144
|
+
* deleted file. Conversely, the 2026-08-17 host proved that finding alone is insufficient: its
|
|
145
|
+
* worktree lock stayed under the pre-shell matcher, so Bash never reached the guard. The command
|
|
146
|
+
* is this package's own absolute path, so it identifies our entry wherever the group ended up,
|
|
147
|
+
* and merge can move it without creating a duplicate.
|
|
145
148
|
*/
|
|
146
149
|
export async function inspectHook(settingsPath, specOrPath) {
|
|
147
150
|
const state = await readSettings(settingsPath);
|
|
148
151
|
const spec = normalizeSpec(specOrPath);
|
|
149
152
|
const commands = [spec.command, ...spec.alternateCommands];
|
|
150
153
|
let matchedCommand;
|
|
154
|
+
let matchedMatcher;
|
|
155
|
+
let requiresMove = false;
|
|
151
156
|
for (const group of groups(state.settings, spec.event)) {
|
|
152
|
-
const hook
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
+
for (const hook of groupHooks(group)) {
|
|
158
|
+
if (hook?.type !== 'command' || !commands.includes(hook.command)) continue;
|
|
159
|
+
matchedCommand ??= hook.command;
|
|
160
|
+
matchedMatcher ??= group?.matcher;
|
|
161
|
+
if (group?.matcher !== spec.matcher) requiresMove = true;
|
|
157
162
|
}
|
|
158
163
|
}
|
|
159
|
-
return {
|
|
164
|
+
return {
|
|
165
|
+
...state,
|
|
166
|
+
...spec,
|
|
167
|
+
present: Boolean(matchedCommand),
|
|
168
|
+
matchedCommand,
|
|
169
|
+
matchedMatcher,
|
|
170
|
+
requiresMove,
|
|
171
|
+
};
|
|
160
172
|
}
|
|
161
173
|
|
|
162
174
|
function backupName(settingsPath) {
|
|
@@ -227,10 +239,43 @@ export async function mergeHook(settingsPath, specOrPath, inspected) {
|
|
|
227
239
|
return withMutationRun(settingsPath, async () => {
|
|
228
240
|
const spec = normalizeSpec(specOrPath);
|
|
229
241
|
const state = inspected || await inspectHook(settingsPath, spec);
|
|
230
|
-
if (state.present) return { changed: false, createdGroup: false };
|
|
242
|
+
if (state.present && !state.requiresMove) return { changed: false, createdGroup: false };
|
|
231
243
|
const settings = structuredClone(state.settings);
|
|
232
244
|
settings.hooks ??= {};
|
|
233
245
|
if (!Array.isArray(settings.hooks[spec.event])) settings.hooks[spec.event] = [];
|
|
246
|
+
if (state.present) {
|
|
247
|
+
const eventGroups = settings.hooks[spec.event];
|
|
248
|
+
const commands = [spec.command, ...spec.alternateCommands];
|
|
249
|
+
let hookToMove;
|
|
250
|
+
let keptDeclaredHook = false;
|
|
251
|
+
for (let index = eventGroups.length - 1; index >= 0; index -= 1) {
|
|
252
|
+
const group = eventGroups[index];
|
|
253
|
+
if (!Array.isArray(group?.hooks)) continue;
|
|
254
|
+
const hadOwnHook = hasOwnCommand(group, commands);
|
|
255
|
+
group.hooks = group.hooks.filter((hook) => {
|
|
256
|
+
if (hook?.type !== 'command' || !commands.includes(hook.command)) return true;
|
|
257
|
+
hookToMove ??= hook;
|
|
258
|
+
if (group.matcher === spec.matcher && !keptDeclaredHook) {
|
|
259
|
+
keptDeclaredHook = true;
|
|
260
|
+
return true;
|
|
261
|
+
}
|
|
262
|
+
return false;
|
|
263
|
+
});
|
|
264
|
+
if (hadOwnHook && group.hooks.length === 0) eventGroups.splice(index, 1);
|
|
265
|
+
}
|
|
266
|
+
let group = eventGroups.find((entry) => entry?.matcher === spec.matcher);
|
|
267
|
+
const createdGroup = !group;
|
|
268
|
+
if (!keptDeclaredHook) {
|
|
269
|
+
if (!group) {
|
|
270
|
+
group = { matcher: spec.matcher, hooks: [] };
|
|
271
|
+
eventGroups.push(group);
|
|
272
|
+
}
|
|
273
|
+
if (!Array.isArray(group.hooks)) group.hooks = [];
|
|
274
|
+
group.hooks.push(hookToMove);
|
|
275
|
+
}
|
|
276
|
+
await atomicWrite(settingsPath, settings, state);
|
|
277
|
+
return { changed: true, createdGroup, moved: true };
|
|
278
|
+
}
|
|
234
279
|
let group = settings.hooks[spec.event].find((entry) => entry?.matcher === spec.matcher);
|
|
235
280
|
const createdGroup = !group;
|
|
236
281
|
if (!group) {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lyupro/codex-bridge",
|
|
3
|
-
"version": "0.5.
|
|
4
|
-
"description": "Claude Code-to-Codex delegation with
|
|
3
|
+
"version": "0.5.3",
|
|
4
|
+
"description": "Claude Code-to-Codex delegation with 6 guards, 757 tests, zero runtime dependencies, and zero build steps.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude-code",
|
|
7
7
|
"codex",
|
package/scripts/run-tests.mjs
CHANGED
|
@@ -16,21 +16,50 @@
|
|
|
16
16
|
import fs from 'node:fs';
|
|
17
17
|
import os from 'node:os';
|
|
18
18
|
import path from 'node:path';
|
|
19
|
+
import { fileURLToPath } from 'node:url';
|
|
19
20
|
import { spawnSync } from 'node:child_process';
|
|
20
21
|
import { ISOLATED_ROOTS } from './isolated-roots.mjs';
|
|
22
|
+
import { readmeText, suiteCountMismatch } from './suite-count.mjs';
|
|
21
23
|
|
|
22
24
|
const created = ISOLATED_ROOTS.map((name) => [
|
|
23
25
|
name,
|
|
24
26
|
fs.mkdtempSync(path.join(os.tmpdir(), `codex-bridge-test-${name.toLowerCase()}-`)),
|
|
25
27
|
]);
|
|
26
28
|
const pattern = process.argv[2] || 'tests/**/*.test.mjs';
|
|
29
|
+
// Only a whole-suite run can judge the README's number; a single file legitimately reports two.
|
|
30
|
+
const wholeSuite = !process.argv[2];
|
|
31
|
+
const tapFile = wholeSuite
|
|
32
|
+
? path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'codex-bridge-test-summary-')), 'summary.tap')
|
|
33
|
+
: null;
|
|
34
|
+
const repositoryRoot = path.resolve(fileURLToPath(new URL('..', import.meta.url)));
|
|
35
|
+
|
|
36
|
+
// The spec reporter is named explicitly because the second reporter would otherwise replace it,
|
|
37
|
+
// and the operator would lose the live output this command exists to show.
|
|
38
|
+
const reporters = tapFile
|
|
39
|
+
? ['--test-reporter=spec', '--test-reporter-destination=stdout',
|
|
40
|
+
'--test-reporter=tap', `--test-reporter-destination=${tapFile}`]
|
|
41
|
+
: [];
|
|
27
42
|
|
|
28
43
|
try {
|
|
29
|
-
const result = spawnSync(process.execPath, ['--test', pattern], {
|
|
44
|
+
const result = spawnSync(process.execPath, ['--test', ...reporters, pattern], {
|
|
30
45
|
stdio: 'inherit',
|
|
31
46
|
env: { ...process.env, ...Object.fromEntries(created) },
|
|
32
47
|
});
|
|
33
48
|
process.exitCode = result.status ?? 1;
|
|
49
|
+
if (tapFile && process.exitCode === 0) {
|
|
50
|
+
let tap = null;
|
|
51
|
+
try {
|
|
52
|
+
tap = fs.readFileSync(tapFile, 'utf8');
|
|
53
|
+
} catch {
|
|
54
|
+
tap = null;
|
|
55
|
+
}
|
|
56
|
+
const mismatch = suiteCountMismatch(readmeText(path.join(repositoryRoot, 'README.md')), tap);
|
|
57
|
+
if (mismatch) {
|
|
58
|
+
process.stderr.write(`\nsuite-count: ${mismatch}\n`);
|
|
59
|
+
process.exitCode = 1;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
34
62
|
} finally {
|
|
35
63
|
for (const [, directory] of created) fs.rmSync(directory, { recursive: true, force: true });
|
|
64
|
+
if (tapFile) fs.rmSync(path.dirname(tapFile), { recursive: true, force: true });
|
|
36
65
|
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compares the README's advertised suite size with what the suite actually reported.
|
|
3
|
+
*
|
|
4
|
+
* The number is prose, so nothing checked it: README said 705 tests while the suite ran 751, and
|
|
5
|
+
* package.json still advertised 604 from three releases earlier. Both were found by eye during
|
|
6
|
+
* the 0.5.3 release, which is the third release in a row where a shipped number was wrong. The
|
|
7
|
+
* count is the first thing a reader judges the package by, and it goes stale silently with every
|
|
8
|
+
* test added — so the runner that knows the real number is the place the claim is verified.
|
|
9
|
+
*/
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
|
|
12
|
+
export const SUITE_CLAIM =
|
|
13
|
+
/\*\*(\d+) automated tests: (\d+) passing and (\d+) skipped\*\*/;
|
|
14
|
+
|
|
15
|
+
/** Read the TAP summary a second reporter wrote; null when the file says nothing usable. */
|
|
16
|
+
export function reportedCounts(tapOutput) {
|
|
17
|
+
if (typeof tapOutput !== 'string' || !tapOutput.trim()) return null;
|
|
18
|
+
const read = (key) => {
|
|
19
|
+
const found = tapOutput.match(new RegExp(`^# ${key} (\\d+)$`, 'm'));
|
|
20
|
+
return found ? Number(found[1]) : null;
|
|
21
|
+
};
|
|
22
|
+
const tests = read('tests');
|
|
23
|
+
const pass = read('pass');
|
|
24
|
+
const skipped = read('skipped');
|
|
25
|
+
if (tests === null || pass === null || skipped === null) return null;
|
|
26
|
+
return { tests, pass, skipped };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Read the claim out of README text; null when the sentence is absent. */
|
|
30
|
+
export function claimedCounts(readmeText) {
|
|
31
|
+
const found = typeof readmeText === 'string' ? readmeText.match(SUITE_CLAIM) : null;
|
|
32
|
+
if (!found) return null;
|
|
33
|
+
return { tests: Number(found[1]), pass: Number(found[2]), skipped: Number(found[3]) };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Return the message a mismatch should print, or null when the claim holds. A missing claim is a
|
|
38
|
+
* mismatch too: the sentence is what the gate protects, and deleting it must not silence the gate.
|
|
39
|
+
*/
|
|
40
|
+
export function suiteCountMismatch(readmeText, tapOutput) {
|
|
41
|
+
const reported = reportedCounts(tapOutput);
|
|
42
|
+
if (!reported) return null;
|
|
43
|
+
const claimed = claimedCounts(readmeText);
|
|
44
|
+
if (!claimed) {
|
|
45
|
+
return 'README no longer states the suite size. Restore the sentence: '
|
|
46
|
+
+ `**${reported.tests} automated tests: ${reported.pass} passing and ${reported.skipped} skipped**.`;
|
|
47
|
+
}
|
|
48
|
+
if (claimed.tests === reported.tests
|
|
49
|
+
&& claimed.pass === reported.pass
|
|
50
|
+
&& claimed.skipped === reported.skipped) {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
return 'README states the wrong suite size. '
|
|
54
|
+
+ `It says ${claimed.tests} tests (${claimed.pass} passing, ${claimed.skipped} skipped); `
|
|
55
|
+
+ `this run had ${reported.tests} (${reported.pass} passing, ${reported.skipped} skipped). `
|
|
56
|
+
+ 'Update the sentence in README.md before the release.';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function readmeText(readmePath) {
|
|
60
|
+
try {
|
|
61
|
+
return fs.readFileSync(readmePath, 'utf8');
|
|
62
|
+
} catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reports whether a shell command plainly intends to write files and the target paths it names.
|
|
3
|
+
*
|
|
4
|
+
* This is deliberately not a complete list, and it cannot become one. Enumerating every way a
|
|
5
|
+
* shell can write would reproduce the original defect one level up: the 2026-08-16 incident is
|
|
6
|
+
* why the worktree witness hook exists beside this cheap prevention check.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const COMMANDS = new Set(['cp', 'mv', 'rm', 'touch', 'truncate', 'tee', 'sed']);
|
|
10
|
+
const INTERPRETERS = /\b(?:python(?:\d+(?:\.\d+)?)?|node|perl|ruby)(?:\.exe)?\b/i;
|
|
11
|
+
|
|
12
|
+
function unquote(value) {
|
|
13
|
+
const trimmed = value.trim().replace(/[;,]+$/, '');
|
|
14
|
+
if ((trimmed.startsWith('"') && trimmed.endsWith('"'))
|
|
15
|
+
|| (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
|
|
16
|
+
return trimmed.slice(1, -1);
|
|
17
|
+
}
|
|
18
|
+
return trimmed;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// A candidate holding a character no file name may contain is not a file name. The redirection
|
|
22
|
+
// pattern cannot tell `>` the operator from `>` the redirect, so `node -e "i>0?'yes':'no'"` was
|
|
23
|
+
// refused on 2026-08-23 naming the path `0?'yes` — a false refusal on a command that writes
|
|
24
|
+
// nothing. Narrowing here costs nothing the witness does not already cover.
|
|
25
|
+
const IMPOSSIBLE_IN_A_NAME = /[?*<>|"]/;
|
|
26
|
+
|
|
27
|
+
function addPath(paths, value) {
|
|
28
|
+
const candidate = unquote(value);
|
|
29
|
+
if (candidate
|
|
30
|
+
&& !['&', '$', '%', '`'].includes(candidate[0])
|
|
31
|
+
&& !IMPOSSIBLE_IN_A_NAME.test(candidate)
|
|
32
|
+
&& !paths.includes(candidate)) paths.push(candidate);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function tokens(segment) {
|
|
36
|
+
return segment.match(/"(?:\\.|[^"])*"|'[^']*'|[^\s]+/g) ?? [];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function positional(args) {
|
|
40
|
+
return args.filter((arg) => !arg.startsWith('-'));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Strip the directory and the Windows executable suffix before matching the command name.
|
|
45
|
+
* `INTERPRETERS` has always allowed `python.exe`; the command list did not, so `sed.exe -i` —
|
|
46
|
+
* the spelling Git Bash and PowerShell hand over on this platform — walked past the guard while
|
|
47
|
+
* the identical `sed -i` was refused (found accepting the 2026-08-17 smoke run, which had recorded
|
|
48
|
+
* that gap as intended behaviour).
|
|
49
|
+
*/
|
|
50
|
+
function commandName(token) {
|
|
51
|
+
return token.replace(/^.*[\\/]/, '').replace(/\.exe$/i, '').toLowerCase();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Split a heredoc command into the shell around it and the document inside it.
|
|
56
|
+
*
|
|
57
|
+
* `shell` is everything the shell itself runs — the line opening the document AND every command
|
|
58
|
+
* after its closing marker. Dropping that tail hid `… <<EOF … EOF; echo x >> README.md` from the
|
|
59
|
+
* guard completely, and a live probe on 2026-08-23 wrote to a tracked file during a held run
|
|
60
|
+
* exactly that way. `paths` holds only what the document body names, which the caller uses just
|
|
61
|
+
* when the interpreter is itself the writer.
|
|
62
|
+
*/
|
|
63
|
+
function heredoc(command) {
|
|
64
|
+
const header = command.match(/^(.*?<<-?\s*(['"]?)([A-Za-z_][\w-]*)\2[^\r\n]*)[\r\n]/s);
|
|
65
|
+
if (!header) return null;
|
|
66
|
+
const paths = [];
|
|
67
|
+
const marker = header[3];
|
|
68
|
+
const body = command.slice(header[0].length).split(/\r?\n/);
|
|
69
|
+
const end = body.findIndex((line) => line.trim() === marker);
|
|
70
|
+
const shell = [header[1], ...(end < 0 ? [] : body.slice(end + 1))].join('\n');
|
|
71
|
+
if (!INTERPRETERS.test(header[1])) return { shell, paths, writes: false };
|
|
72
|
+
const source = body.slice(0, end < 0 ? body.length : end).join('\n');
|
|
73
|
+
for (const match of source.matchAll(/(['"])(.*?)\1/g)) {
|
|
74
|
+
const value = match[2];
|
|
75
|
+
if (/^(?:[A-Za-z]:[\\/]|[\\/]|\.\.?[\\/])/.test(value)
|
|
76
|
+
|| /[\\/]/.test(value)
|
|
77
|
+
|| /(?:^|[\\/])[^\\/]+\.[A-Za-z0-9_-]+$/.test(value)) addPath(paths, value);
|
|
78
|
+
}
|
|
79
|
+
return { header: header[1], paths, writes: true };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Returns `{ writes, paths }` for the deliberately obvious write forms this guard recognises. */
|
|
83
|
+
export function shellWriteIntent(command) {
|
|
84
|
+
if (typeof command !== 'string' || !command.trim()) return { writes: false, paths: [] };
|
|
85
|
+
const paths = [];
|
|
86
|
+
const document = heredoc(command);
|
|
87
|
+
const shell = document?.header ?? command;
|
|
88
|
+
let writes = document?.writes ?? false;
|
|
89
|
+
|
|
90
|
+
for (const match of shell.matchAll(/(?<![<>=])(?:\d*)>>?(?![=>&])\s*("(?:\\.|[^"])*"|'[^']*'|[^\s;|&]+)/g)) {
|
|
91
|
+
writes = true;
|
|
92
|
+
addPath(paths, match[1]);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// A real output redirect identifies the writer's destination. Body text is only evidence when
|
|
96
|
+
// the interpreter itself is the writer, as in the 2026-08-16 python heredoc incident.
|
|
97
|
+
if (!paths.length && document?.writes) {
|
|
98
|
+
for (const candidate of document.paths) addPath(paths, candidate);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
for (const segment of shell.split(/(?:&&|\|\||[;|\r\n])/)) {
|
|
102
|
+
const parts = tokens(segment);
|
|
103
|
+
const index = parts.findIndex((part) => COMMANDS.has(commandName(part)));
|
|
104
|
+
if (index < 0) continue;
|
|
105
|
+
const name = commandName(parts[index]);
|
|
106
|
+
const args = parts.slice(index + 1);
|
|
107
|
+
if (name === 'sed' && !args.some((arg) => /^-.*i/.test(arg))) continue;
|
|
108
|
+
writes = true;
|
|
109
|
+
const values = positional(args);
|
|
110
|
+
if (name === 'cp' || name === 'mv') {
|
|
111
|
+
if (values.length > 1) addPath(paths, values.at(-1));
|
|
112
|
+
} else if (name === 'sed') {
|
|
113
|
+
for (const value of values.slice(1)) addPath(paths, value);
|
|
114
|
+
} else {
|
|
115
|
+
for (const value of values) addPath(paths, value);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return { writes, paths };
|
|
120
|
+
}
|
|
@@ -1,77 +1,109 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* PreToolUse lock for file
|
|
4
|
-
*
|
|
5
|
-
* A live codex-build runner snapshots its repository before and after editing. Letting the
|
|
6
|
-
* orchestrator write during that window folds unrelated work into the runner's result, exactly
|
|
7
|
-
* as the 2026-08-05 incident did. This hook denies only a recognised file path inside a live
|
|
8
|
-
* build run's repository; every malformed or unreadable input passes so diagnostics never break
|
|
9
|
-
* unrelated host work.
|
|
10
|
-
*/
|
|
11
|
-
import fs from 'node:fs';
|
|
12
|
-
import path from 'node:path';
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
if (!
|
|
33
|
-
|
|
34
|
-
const
|
|
35
|
-
if (typeof
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* PreToolUse lock for file tools and plainly recognisable shell writes in the Claude Code host.
|
|
4
|
+
*
|
|
5
|
+
* A live codex-build runner snapshots its repository before and after editing. Letting the
|
|
6
|
+
* orchestrator write during that window folds unrelated work into the runner's result, exactly
|
|
7
|
+
* as the 2026-08-05 incident did. This hook denies only a recognised file path inside a live
|
|
8
|
+
* build run's repository; every malformed or unreadable input passes so diagnostics never break
|
|
9
|
+
* unrelated host work.
|
|
10
|
+
*/
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { spawnSync } from 'node:child_process';
|
|
14
|
+
import { formatSilence, heartbeatAge } from '../lib/heartbeat.mjs';
|
|
15
|
+
import { SHELL_TOOLS, WRITE_TOOLS } from '../lib/hook-definitions.mjs';
|
|
16
|
+
import { runsRoot } from '../lib/runner/runs-root.mjs';
|
|
17
|
+
import { allLiveRuns, normalizePath } from './live-runs.mjs';
|
|
18
|
+
import { shellWriteIntent } from './shell-write-intent.mjs';
|
|
19
|
+
|
|
20
|
+
const WRITE_TOOL_NAMES = new Set(WRITE_TOOLS);
|
|
21
|
+
const SHELL_TOOL_NAMES = new Set(SHELL_TOOLS);
|
|
22
|
+
const pass = () => process.exit(0);
|
|
23
|
+
|
|
24
|
+
let input;
|
|
25
|
+
try {
|
|
26
|
+
input = JSON.parse(fs.readFileSync(0, 'utf8'));
|
|
27
|
+
} catch {
|
|
28
|
+
pass();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) pass();
|
|
32
|
+
if (!WRITE_TOOL_NAMES.has(input.tool_name) && !SHELL_TOOL_NAMES.has(input.tool_name)) pass();
|
|
33
|
+
|
|
34
|
+
const toolInput = input.tool_input;
|
|
35
|
+
if (!toolInput || typeof toolInput !== 'object' || Array.isArray(toolInput)) pass();
|
|
36
|
+
let rawPaths;
|
|
37
|
+
if (WRITE_TOOL_NAMES.has(input.tool_name)) {
|
|
38
|
+
const pathField = input.tool_name === 'NotebookEdit' ? 'notebook_path' : 'file_path';
|
|
39
|
+
const rawPath = toolInput[pathField];
|
|
40
|
+
if (typeof rawPath !== 'string' || !rawPath.trim()) pass();
|
|
41
|
+
rawPaths = [rawPath];
|
|
42
|
+
} else {
|
|
43
|
+
const intent = shellWriteIntent(toolInput.command);
|
|
44
|
+
if (!intent.writes || !intent.paths.length) pass();
|
|
45
|
+
rawPaths = intent.paths;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
let liveRuns;
|
|
49
|
+
try {
|
|
50
|
+
liveRuns = allLiveRuns(runsRoot());
|
|
51
|
+
} catch {
|
|
52
|
+
pass();
|
|
53
|
+
}
|
|
54
|
+
let match;
|
|
55
|
+
for (const rawPath of rawPaths) {
|
|
56
|
+
let targetPath;
|
|
57
|
+
try {
|
|
58
|
+
if (path.isAbsolute(rawPath)) targetPath = rawPath;
|
|
59
|
+
else if (typeof input.cwd === 'string' && input.cwd.trim()) targetPath = path.resolve(input.cwd, rawPath);
|
|
60
|
+
else continue;
|
|
61
|
+
} catch {
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
const target = normalizePath(targetPath);
|
|
65
|
+
if (!target) continue;
|
|
66
|
+
const owner = liveRuns.find(({ status }) => {
|
|
67
|
+
if (status.agent !== 'codex-build') return false;
|
|
68
|
+
const repository = normalizePath(status.repo);
|
|
69
|
+
return Boolean(repository) && (target === repository || target.startsWith(`${repository}/`));
|
|
70
|
+
});
|
|
71
|
+
if (owner) {
|
|
72
|
+
match = { owner, targetPath };
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (!match) pass();
|
|
77
|
+
|
|
78
|
+
const { status, dir } = match.owner;
|
|
79
|
+
// A run is only ever failed for what `git status --porcelain` reports, and an ignored path never
|
|
80
|
+
// appears there — the witness that grades a run does not see it either. Meanwhile the working
|
|
81
|
+
// order for a live run is to touch exactly those files: plans and checklists live in ignored
|
|
82
|
+
// folders. On 2026-08-23 this guard refused four such edits in five minutes, which is worse than
|
|
83
|
+
// a miss: a miss has the witness behind it, a false refusal has nothing and reads as a broken
|
|
84
|
+
// tool. Only a proven `ignored` passes — anything else (not a repository, git absent, any other
|
|
85
|
+
// error) keeps the refusal, because "no proof it is ignored" is not "proof it is harmless".
|
|
86
|
+
const repository = normalizePath(status.repo);
|
|
87
|
+
const target = normalizePath(match.targetPath);
|
|
88
|
+
const gitMetadata = target === `${repository}/.git` || target.startsWith(`${repository}/.git/`);
|
|
89
|
+
if (!gitMetadata) {
|
|
90
|
+
const ignored = spawnSync('git', ['-C', status.repo, 'check-ignore', '--quiet', '--', match.targetPath], {
|
|
91
|
+
stdio: 'ignore',
|
|
92
|
+
});
|
|
93
|
+
if (ignored.status === 0) pass();
|
|
94
|
+
}
|
|
95
|
+
process.stdout.write(JSON.stringify({
|
|
96
|
+
hookSpecificOutput: {
|
|
97
|
+
hookEventName: 'PreToolUse',
|
|
98
|
+
permissionDecision: 'deny',
|
|
99
|
+
permissionDecisionReason: `Worktree lock denied ${input.tool_name}: ${match.targetPath} is inside repository `
|
|
100
|
+
+ `${status.repo}, held by live run folder ${dir} (agent ${status.agent}, slug ${status.slug}). `
|
|
101
|
+
// "Working, last progress X ago" rather than "silent for X": this hook only ever denies a
|
|
102
|
+
// run whose heartbeat is still fresh — a stale one is not live here and the edit passes. The
|
|
103
|
+
// first live probe printed "silent for 10 seconds" next to a stop command on a perfectly
|
|
104
|
+
// healthy run, which reads as "it hung, kill it" and would cost the operator 20 minutes of
|
|
105
|
+
// work they meant to keep.
|
|
106
|
+
+ `It is working; last progress ${formatSilence(heartbeatAge(dir))} ago. `
|
|
107
|
+
+ `To take the repository back before it finishes: codex-bridge stop ${path.basename(dir)}.`,
|
|
108
|
+
},
|
|
109
|
+
}));
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* PostToolUse witness for shell writes into a live codex-build repository.
|
|
4
|
+
*
|
|
5
|
+
* A shell can write through Python, redirection, or any other command while bypassing the
|
|
6
|
+
* file-tool lock. That is how the orchestrator changed CHANGELOG.md during the 2026-08-16
|
|
7
|
+
* split-guard run and made an honest build appear out of scope. This hook compares the live
|
|
8
|
+
* run's recorded git state with the repository after every shell tool and directs the
|
|
9
|
+
* orchestrator to undo any change outside the run's declared scope.
|
|
10
|
+
*/
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { spawnSync } from 'node:child_process';
|
|
14
|
+
import { SHELL_TOOLS } from '../lib/hook-definitions.mjs';
|
|
15
|
+
import { parseJsonText } from '../lib/json-file.mjs';
|
|
16
|
+
import { globToRegExp } from '../lib/meta/paths.mjs';
|
|
17
|
+
import { runsRoot } from '../lib/runner/runs-root.mjs';
|
|
18
|
+
import { allLiveRuns, normalizePath } from './live-runs.mjs';
|
|
19
|
+
|
|
20
|
+
const SHELL_TOOL_NAMES = new Set(SHELL_TOOLS);
|
|
21
|
+
const pass = () => process.exit(0);
|
|
22
|
+
|
|
23
|
+
function readRequired(file) {
|
|
24
|
+
try {
|
|
25
|
+
return fs.readFileSync(file, 'utf8');
|
|
26
|
+
} catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function statusMap(text) {
|
|
32
|
+
if (typeof text !== 'string') return null;
|
|
33
|
+
const result = new Map();
|
|
34
|
+
for (const line of text.split(/\r?\n/).filter(Boolean)) {
|
|
35
|
+
if (!/^.. /.test(line)) return null;
|
|
36
|
+
const rawPath = line.slice(3);
|
|
37
|
+
const file = rawPath.includes(' -> ') ? rawPath.slice(rawPath.lastIndexOf(' -> ') + 4) : rawPath;
|
|
38
|
+
if (!file.trim()) return null;
|
|
39
|
+
result.set(file, line.slice(0, 2));
|
|
40
|
+
}
|
|
41
|
+
return result;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
let input;
|
|
45
|
+
try {
|
|
46
|
+
input = parseJsonText('stdin', fs.readFileSync(0, 'utf8'));
|
|
47
|
+
} catch {
|
|
48
|
+
pass();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) pass();
|
|
52
|
+
if (!SHELL_TOOL_NAMES.has(input.tool_name)) pass();
|
|
53
|
+
const cwd = normalizePath(input.cwd);
|
|
54
|
+
if (!cwd) pass();
|
|
55
|
+
|
|
56
|
+
let liveRuns;
|
|
57
|
+
try {
|
|
58
|
+
liveRuns = allLiveRuns(runsRoot());
|
|
59
|
+
} catch {
|
|
60
|
+
pass();
|
|
61
|
+
}
|
|
62
|
+
const owner = liveRuns.find(({ status }) => {
|
|
63
|
+
if (status.agent !== 'codex-build') return false;
|
|
64
|
+
const repository = normalizePath(status.repo);
|
|
65
|
+
return Boolean(repository) && (cwd === repository || cwd.startsWith(`${repository}/`));
|
|
66
|
+
});
|
|
67
|
+
if (!owner) pass();
|
|
68
|
+
|
|
69
|
+
const { dir, status } = owner;
|
|
70
|
+
const beforeText = readRequired(path.join(dir, 'git-before.txt'));
|
|
71
|
+
const scopeText = readRequired(path.join(dir, 'scope.txt'));
|
|
72
|
+
if (beforeText === null || scopeText === null) pass();
|
|
73
|
+
|
|
74
|
+
const before = statusMap(beforeText);
|
|
75
|
+
let allowed;
|
|
76
|
+
try {
|
|
77
|
+
const patterns = scopeText.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
78
|
+
if (!patterns.length) pass();
|
|
79
|
+
allowed = patterns.map(globToRegExp);
|
|
80
|
+
} catch {
|
|
81
|
+
pass();
|
|
82
|
+
}
|
|
83
|
+
if (!before) pass();
|
|
84
|
+
|
|
85
|
+
let currentResult;
|
|
86
|
+
try {
|
|
87
|
+
currentResult = spawnSync('git', ['-C', status.repo, 'status', '--porcelain'], {
|
|
88
|
+
encoding: 'utf8',
|
|
89
|
+
});
|
|
90
|
+
} catch {
|
|
91
|
+
pass();
|
|
92
|
+
}
|
|
93
|
+
if (currentResult.error || currentResult.status !== 0 || typeof currentResult.stdout !== 'string') pass();
|
|
94
|
+
const current = statusMap(currentResult.stdout);
|
|
95
|
+
if (!current) pass();
|
|
96
|
+
|
|
97
|
+
const changed = [];
|
|
98
|
+
for (const [file, state] of current) if (before.get(file) !== state) changed.push(file);
|
|
99
|
+
for (const file of before.keys()) if (!current.has(file)) changed.push(file);
|
|
100
|
+
const outside = [...new Set(changed)].filter((file) => !allowed.some((pattern) => pattern.test(file)));
|
|
101
|
+
if (!outside.length) pass();
|
|
102
|
+
|
|
103
|
+
const folder = path.basename(dir);
|
|
104
|
+
const paths = outside.join(', ');
|
|
105
|
+
process.stdout.write(JSON.stringify({
|
|
106
|
+
hookSpecificOutput: {
|
|
107
|
+
hookEventName: 'PostToolUse',
|
|
108
|
+
additionalContext: `WORKTREE WITNESS — act now: the shell changed paths outside the scope of `
|
|
109
|
+
+ `live run folder ${dir} (agent ${status.agent}, slug ${status.slug}): ${paths}. `
|
|
110
|
+
+ `Revert those paths now, or run codex-bridge stop ${folder} to take the repository back `
|
|
111
|
+
+ `before making further changes.`,
|
|
112
|
+
},
|
|
113
|
+
}));
|
|
@@ -2,7 +2,10 @@
|
|
|
2
2
|
* The one definition of run progress: a heartbeat file in the run folder, its age, and the
|
|
3
3
|
* stale threshold. On 2026-08-06 run 2026-08-06_204007_build left a live worker waiting on
|
|
4
4
|
* Codex's grandchild; a worker timer alone would have kept that dead run looking alive, so
|
|
5
|
-
* the timer stops at exit.
|
|
5
|
+
* the timer stops at exit. The file's modification time is the run's best-effort sign of recent
|
|
6
|
+
* progress. A failed stamp is swallowed so this hint cannot prevent the run's normal verdict and
|
|
7
|
+
* artifacts. For an old record, a missing file is a legacy state that keeps its lock until its pid
|
|
8
|
+
* dies.
|
|
6
9
|
*/
|
|
7
10
|
import fs from 'node:fs';
|
|
8
11
|
import path from 'node:path';
|