@lyupro/codex-bridge 0.5.1 → 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 -252
- package/README.md +21 -4
- package/bin/codex-bridge.mjs +4 -0
- package/cli/doctor.mjs +399 -293
- package/cli/frontmatter.mjs +137 -0
- package/cli/hook.mjs +2 -2
- package/cli/install-record.mjs +2 -2
- package/cli/install.mjs +14 -2
- package/cli/manifest.mjs +23 -22
- package/cli/permissions.mjs +2 -2
- package/cli/projects.mjs +3 -3
- package/cli/prune-plan.mjs +2 -2
- package/cli/prune.mjs +1 -1
- package/cli/read.mjs +2 -2
- package/cli/rules-owners.mjs +2 -2
- package/cli/run-lookup.mjs +3 -3
- package/cli/runs-inventory.mjs +3 -3
- package/cli/settings-merge.mjs +56 -11
- package/cli/stop.mjs +5 -5
- package/cli/unlock.mjs +7 -7
- package/package.json +2 -2
- package/scripts/check-file-size.mjs +1 -1
- package/scripts/run-tests.mjs +30 -1
- package/scripts/suite-count.mjs +65 -0
- package/src/agents/codex-build.md +265 -231
- package/src/agents/codex-review.md +219 -184
- package/src/agents/codex-scout.md +232 -200
- package/src/{hooks → home/hooks}/guard-tries.mjs +1 -1
- package/src/{hooks → home/hooks}/live-runs.mjs +43 -3
- package/src/home/hooks/order-gate.mjs +135 -0
- package/src/{hooks → home/hooks}/prune-guard.mjs +2 -2
- package/src/{hooks → home/hooks}/reply-guard.mjs +117 -97
- package/src/home/hooks/reply-parser.mjs +27 -0
- package/src/home/hooks/reply-verdicts.mjs +174 -0
- package/src/home/hooks/shell-write-intent.mjs +120 -0
- package/src/{hooks → home/hooks}/stop-guard.mjs +3 -3
- package/src/home/hooks/worktree-lock.mjs +109 -0
- package/src/home/hooks/worktree-witness.mjs +113 -0
- package/src/home/lib/dispatcher-order.mjs +36 -0
- package/src/{heartbeat.mjs → home/lib/heartbeat.mjs} +4 -1
- package/src/{hook-definitions.mjs → home/lib/hook-definitions.mjs} +11 -2
- package/src/home/lib/host-refusal.mjs +20 -0
- package/src/{json-file.mjs → home/lib/json-file.mjs} +4 -4
- package/src/{meta → home/lib/meta}/paths.mjs +20 -1
- package/src/{meta → home/lib/meta}/reply.mjs +1 -1
- package/src/{meta → home/lib/meta}/verdict.mjs +1 -1
- package/src/{no-self-execution.mjs → home/lib/no-self-execution.mjs} +7 -2
- package/src/{required-inputs.mjs → home/lib/required-inputs.mjs} +56 -5
- package/src/{retention.mjs → home/lib/retention.mjs} +1 -1
- package/src/{run-codex.mjs → home/lib/run-codex.mjs} +39 -11
- package/src/{run-config.mjs → home/lib/run-config.mjs} +2 -2
- package/src/{runner → home/lib/runner}/args.mjs +86 -10
- package/src/{runner → home/lib/runner}/attach.mjs +58 -9
- package/src/{runner → home/lib/runner}/codex-cmd.mjs +398 -392
- package/src/{runner → home/lib/runner}/launcher.mjs +33 -21
- package/src/home/lib/runner/order-owner.mjs +28 -0
- package/src/{runner → home/lib/runner}/run-env.mjs +1 -1
- package/src/home/lib/runner/sandbox-flags.mjs +27 -0
- package/src/home/lib/runner/task-file.mjs +92 -0
- package/src/home/lib/runner/task-input.mjs +26 -0
- package/src/{runner → home/lib/runner}/worker.mjs +1 -1
- package/src/home/lib/shell-unsafe.mjs +26 -0
- package/src/{write-meta.mjs → home/lib/write-meta.mjs} +12 -1
- package/src/hooks/order-gate.mjs +0 -73
- package/src/hooks/worktree-lock.mjs +0 -77
- /package/src/{run-config.json → home/config.json} +0 -0
- /package/src/{conventions.md → home/conventions.md} +0 -0
- /package/src/{cli-names.mjs → home/lib/cli-names.mjs} +0 -0
- /package/src/{meta → home/lib/meta}/chain.mjs +0 -0
- /package/src/{meta → home/lib/meta}/deadline.mjs +0 -0
- /package/src/{meta → home/lib/meta}/environment.mjs +0 -0
- /package/src/{meta → home/lib/meta}/events.mjs +0 -0
- /package/src/{meta → home/lib/meta}/outcome.mjs +0 -0
- /package/src/{meta → home/lib/meta}/pre-start.mjs +0 -0
- /package/src/{meta → home/lib/meta}/reason.mjs +0 -0
- /package/src/{meta → home/lib/meta}/run-state.mjs +0 -0
- /package/src/{meta → home/lib/meta}/startup.mjs +0 -0
- /package/src/{meta → home/lib/meta}/transport.mjs +0 -0
- /package/src/{process-identity.mjs → home/lib/process-identity.mjs} +0 -0
- /package/src/{runner → home/lib/runner}/continuation.mjs +0 -0
- /package/src/{runner → home/lib/runner}/conventions.mjs +0 -0
- /package/src/{runner → home/lib/runner}/git-state.mjs +0 -0
- /package/src/{runner → home/lib/runner}/project-dir.mjs +0 -0
- /package/src/{runner → home/lib/runner}/prompts.mjs +0 -0
- /package/src/{runner → home/lib/runner}/run-context.mjs +0 -0
- /package/src/{runner → home/lib/runner}/runs-root.mjs +0 -0
- /package/src/{runner → home/lib/runner}/schemas.mjs +0 -0
- /package/src/{runner → home/lib/runner}/scope-check.mjs +0 -0
- /package/src/{stop-contract.mjs → home/lib/stop-contract.mjs} +0 -0
|
@@ -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,8 +1,8 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import { STOP_TOOLS } from '../hook-definitions.mjs';
|
|
4
|
-
import { runsRoot } from '../runner/runs-root.mjs';
|
|
5
|
-
import { renderStopCommand, STOP_REASON } from '../stop-contract.mjs';
|
|
3
|
+
import { STOP_TOOLS } from '../lib/hook-definitions.mjs';
|
|
4
|
+
import { runsRoot } from '../lib/runner/runs-root.mjs';
|
|
5
|
+
import { renderStopCommand, STOP_REASON } from '../lib/stop-contract.mjs';
|
|
6
6
|
import { allLiveRuns, normalizePath } from './live-runs.mjs';
|
|
7
7
|
import { takeTry } from './guard-tries.mjs';
|
|
8
8
|
|
|
@@ -0,0 +1,109 @@
|
|
|
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
|
+
}));
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/** Reads and compares the order identity carried between dispatcher hook boundaries. */
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import { parseJsonText } from './json-file.mjs';
|
|
4
|
+
import { extractValue } from './required-inputs.mjs';
|
|
5
|
+
|
|
6
|
+
/** The transcript is diagnostic evidence; malformed or unavailable evidence must fail open. */
|
|
7
|
+
export function transcriptOrderId(transcriptPath) {
|
|
8
|
+
try {
|
|
9
|
+
const firstEntry = fs.readFileSync(transcriptPath, 'utf8').split(/\r?\n/, 1)[0];
|
|
10
|
+
const transcriptEntry = parseJsonText(transcriptPath, firstEntry);
|
|
11
|
+
const content = transcriptEntry?.message?.content;
|
|
12
|
+
const promptText = typeof content === 'string'
|
|
13
|
+
? content
|
|
14
|
+
: Array.isArray(content)
|
|
15
|
+
? content.filter((part) => part?.type === 'text').map((part) => part.text).join('\n')
|
|
16
|
+
: '';
|
|
17
|
+
return String(extractValue(promptText, 'order id') || '').trim();
|
|
18
|
+
} catch {
|
|
19
|
+
return '';
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** On 2026-08-15 another order's saved reply was returned as the current run's verdict. */
|
|
24
|
+
export function runOrderMismatch(orderedOrderId, runStatus, runDir) {
|
|
25
|
+
const ordered = String(orderedOrderId ?? '').trim();
|
|
26
|
+
const recorded = String(runStatus?.order_id ?? '').trim();
|
|
27
|
+
if (!ordered || !recorded || ordered === recorded) return null;
|
|
28
|
+
return {
|
|
29
|
+
reason: `Contract violated: the dispatcher was ordered order id ${JSON.stringify(ordered)}, ` +
|
|
30
|
+
`but run folder ${runDir} records order_id ${JSON.stringify(recorded)}. Run the ordered ` +
|
|
31
|
+
'order id and return that run\'s stdout verbatim.',
|
|
32
|
+
observed: `The dispatcher transcript orders ${JSON.stringify(ordered)}, but status.json in ` +
|
|
33
|
+
`${runDir} records order_id=${JSON.stringify(recorded)}. Run the ordered order id and ` +
|
|
34
|
+
`return that run's stdout verbatim.`,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
@@ -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';
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* subagent, and the first host that renames it would leave a registered hook listening for a
|
|
6
6
|
* tool that never fires — silence indistinguishable from approval, which is the exact failure
|
|
7
7
|
* the gate exists to end. `cli/manifest.mjs` builds the settings matcher from SUBAGENT_TOOLS
|
|
8
|
-
* and `src/hooks/order-gate.mjs` recognises the same names, so a matcher can never drift away
|
|
8
|
+
* and `src/home/hooks/order-gate.mjs` recognises the same names, so a matcher can never drift away
|
|
9
9
|
* from what the hook actually checks.
|
|
10
10
|
*/
|
|
11
11
|
|
|
@@ -46,6 +46,14 @@ export const STOP_TOOLS = Object.freeze(['TaskStop']);
|
|
|
46
46
|
/** A PreToolUse matcher is a regular expression over tool names, so the list remains authoritative. */
|
|
47
47
|
export const STOP_TOOL_MATCHER = STOP_TOOLS.join('|');
|
|
48
48
|
|
|
49
|
+
/**
|
|
50
|
+
* The lock listens to file tools and shells through one registration, not two. A second definition
|
|
51
|
+
* naming the same file lands twice in the installation record, and that record forbids duplicate
|
|
52
|
+
* files — 33 tests failed on it the day the shell side was added. One hook, one entry, one matcher
|
|
53
|
+
* wide enough for both.
|
|
54
|
+
*/
|
|
55
|
+
export const LOCKED_TOOL_MATCHER = [...WRITE_TOOLS, ...SHELL_TOOLS].join('|');
|
|
56
|
+
|
|
49
57
|
/**
|
|
50
58
|
* Stable CLI names live beside each event and file because the Plan_19 drift incident left
|
|
51
59
|
* registration, diagnostics, and the guard itself carrying different hook identities. The
|
|
@@ -54,7 +62,8 @@ export const STOP_TOOL_MATCHER = STOP_TOOLS.join('|');
|
|
|
54
62
|
export const HOOK_DEFINITIONS = Object.freeze([
|
|
55
63
|
Object.freeze({ name: 'reply-guard', event: 'SubagentStop', matcher: '*', file: 'reply-guard.mjs' }),
|
|
56
64
|
Object.freeze({ name: 'order-gate', event: 'PreToolUse', matcher: SUBAGENT_TOOL_MATCHER, file: 'order-gate.mjs' }),
|
|
57
|
-
Object.freeze({ name: 'worktree-lock', event: 'PreToolUse', matcher:
|
|
65
|
+
Object.freeze({ name: 'worktree-lock', event: 'PreToolUse', matcher: LOCKED_TOOL_MATCHER, file: 'worktree-lock.mjs' }),
|
|
58
66
|
Object.freeze({ name: 'prune-guard', event: 'PreToolUse', matcher: SHELL_TOOL_MATCHER, file: 'prune-guard.mjs' }),
|
|
67
|
+
Object.freeze({ name: 'worktree-witness', event: 'PostToolUse', matcher: SHELL_TOOL_MATCHER, file: 'worktree-witness.mjs' }),
|
|
59
68
|
Object.freeze({ name: 'stop-guard', event: 'PreToolUse', matcher: STOP_TOOL_MATCHER, file: 'stop-guard.mjs' }),
|
|
60
69
|
]);
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** Recognises whether a dispatcher reply contains every required part of a host refusal. */
|
|
2
|
+
|
|
3
|
+
const ORDER_ID_PATTERNS = [
|
|
4
|
+
/--order-id(?:=|\s+)[`"']?([a-z0-9][a-z0-9._-]*)/i,
|
|
5
|
+
/\border[\s_-]*id\s*(?::|=)\s*[`"']?([a-z0-9][a-z0-9._-]*)/i,
|
|
6
|
+
/\border[\s_-]*id\s+[`"']([^`"']+)[`"']/i,
|
|
7
|
+
];
|
|
8
|
+
|
|
9
|
+
export function recognizeHostRefusal(reply) {
|
|
10
|
+
const text = String(reply ?? '');
|
|
11
|
+
const declaresFailure = /(^|\n)\s*`*FAIL\b/i.test(text);
|
|
12
|
+
const namesOrderId = ORDER_ID_PATTERNS.some((pattern) => pattern.test(text));
|
|
13
|
+
const namesInstallRemedy = /\bcodex-bridge\s+install\b/i.test(text);
|
|
14
|
+
const missing = [
|
|
15
|
+
...(!declaresFailure ? ['FAIL declaration'] : []),
|
|
16
|
+
...(!namesOrderId ? ['order id'] : []),
|
|
17
|
+
...(!namesInstallRemedy ? ['codex-bridge install remedy'] : []),
|
|
18
|
+
];
|
|
19
|
+
return { recognized: missing.length === 0, declaresFailure, namesOrderId, namesInstallRemedy, missing };
|
|
20
|
+
}
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import fs from 'node:fs';
|
|
8
8
|
import fsp from 'node:fs/promises';
|
|
9
9
|
|
|
10
|
-
function
|
|
10
|
+
export function parseJsonText(file, raw) {
|
|
11
11
|
const text = raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw;
|
|
12
12
|
try {
|
|
13
13
|
return JSON.parse(text);
|
|
@@ -17,14 +17,14 @@ function parseJson(file, raw) {
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
export async function readJsonFile(file) {
|
|
20
|
-
return
|
|
20
|
+
return parseJsonText(file, await fsp.readFile(file, 'utf8'));
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
export async function readJsonFileWithRaw(file) {
|
|
24
24
|
const raw = await fsp.readFile(file, 'utf8');
|
|
25
|
-
return { raw, value:
|
|
25
|
+
return { raw, value: parseJsonText(file, raw) };
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
export function readJsonFileSync(file) {
|
|
29
|
-
return
|
|
29
|
+
return parseJsonText(file, fs.readFileSync(file, 'utf8'));
|
|
30
30
|
}
|
|
@@ -10,7 +10,26 @@ import fs from 'node:fs';
|
|
|
10
10
|
import { readJsonFileSync } from '../json-file.mjs';
|
|
11
11
|
|
|
12
12
|
/** One line, no newlines, bounded length — a five-line reply must stay five lines. */
|
|
13
|
-
export const line = (value, max = 200) =>
|
|
13
|
+
export const line = (value, max = 200) => {
|
|
14
|
+
const text = String(value ?? '').replace(/\s+/g, ' ').trim();
|
|
15
|
+
if (text.length <= max) return text;
|
|
16
|
+
|
|
17
|
+
const ellipsis = '...';
|
|
18
|
+
if (max <= ellipsis.length) return text.slice(0, Math.max(0, max));
|
|
19
|
+
|
|
20
|
+
const contentLimit = max - ellipsis.length;
|
|
21
|
+
const boundary = text.lastIndexOf(' ', contentLimit);
|
|
22
|
+
// `All 298 tests pass` once became `All 29`; a reply must omit a whole word, not corrupt it.
|
|
23
|
+
// Retreating to a space is the whole fix, and it is enough on its own: what survives ends
|
|
24
|
+
// where a word ended. Dropping a trailing number here as well cost `All 298 12345678 rest`
|
|
25
|
+
// its 298 — a number that fitted whole — because the digit test compared the limit rather
|
|
26
|
+
// than the point the text was actually cut at.
|
|
27
|
+
if (boundary > 0) return `${text.slice(0, boundary)}${ellipsis}`;
|
|
28
|
+
|
|
29
|
+
// No space to retreat to: the limit lands inside a single long word. Only here can the cut
|
|
30
|
+
// land mid-number, so only here is a partial number dropped.
|
|
31
|
+
return `${text.slice(0, contentLimit).replace(/\d+$/, '')}${ellipsis}`;
|
|
32
|
+
};
|
|
14
33
|
|
|
15
34
|
export const readText = (file) => {
|
|
16
35
|
try {
|
|
@@ -108,7 +108,7 @@ function buildReply(ctx) {
|
|
|
108
108
|
: paths || 'worktree untouched';
|
|
109
109
|
const retention = retentionReply(ctx.runDir);
|
|
110
110
|
return [
|
|
111
|
-
`OK — ${line(r.summary,
|
|
111
|
+
`OK — ${line(r.summary, 300)}`,
|
|
112
112
|
`Files: ${touchedPaths.length} changed · ${files}`,
|
|
113
113
|
// Only when something outside the run wrote to the tree. Subtracting those paths from the
|
|
114
114
|
// verdict without naming them would hide a real edit behind a pattern.
|
|
@@ -356,7 +356,7 @@ export function resolveStatus({ resultOk, exit, agent, result, runDir, events })
|
|
|
356
356
|
// Hooks on is the known cause of this exact failure, so the reason says so instead
|
|
357
357
|
// of leaving the operator to rediscover it from the log.
|
|
358
358
|
const hooks = readJson(path.join(runDir, 'env.json'))?.hooks
|
|
359
|
-
? ' (hooks are enabled in
|
|
359
|
+
? ' (hooks are enabled in config.json — a likely cause)'
|
|
360
360
|
: '';
|
|
361
361
|
return { status: 'FAIL', reason: `${verdict.reason}${hooks}` };
|
|
362
362
|
}
|
|
@@ -2,12 +2,17 @@
|
|
|
2
2
|
* Keeps the dispatcher's first rule identical across all three prompts.
|
|
3
3
|
* On 2026-08-06 codex-build wrote 19 files using the Claude quota, which is why a failed
|
|
4
4
|
* delegation must end in an explicit failure instead of inviting the dispatcher to continue.
|
|
5
|
+
*
|
|
6
|
+
* The runner is named here by its command, never by its file. This block used to say "start a run
|
|
7
|
+
* through run-codex.mjs", and on 2026-08-15 a dispatcher refused by the host did exactly that:
|
|
8
|
+
* called the file by absolute path, then through PowerShell, then asked its operator to grant a
|
|
9
|
+
* permission rule on it. The prompt was not merely failing to forbid the detour — it was naming it.
|
|
5
10
|
*/
|
|
6
11
|
const NO_SELF_EXECUTION = `## No self-execution
|
|
7
12
|
|
|
8
|
-
Your only job is to start a run
|
|
13
|
+
Your only job is to start a run with the \`codex-bridge run\` command and report its result; you are not the one who performs the task.
|
|
9
14
|
|
|
10
|
-
- If
|
|
15
|
+
- If the command is missing, is refused, does not start, exits without \`RUN=\`/\`ATTACH=\`, or the run fails to begin for any reason, immediately return \`FAIL — could not start the Codex run: <reason>\` and stop. Never reach for the runner file by path, an interpreter, or another shell to get around it.
|
|
11
16
|
- Performing the task yourself when the run could not be started is prohibited: do not read code, write files, or fix tests. Doing it yourself spends the Claude Max quota on exactly what the delegation existed to avoid, and does so silently.
|
|
12
17
|
- Claiming "files were created" without a run that actually happened is prohibited under all circumstances. No run, no result.
|
|
13
18
|
|