@lyupro/codex-bridge 0.6.2 → 0.6.5
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 +146 -0
- package/README.md +3 -3
- package/cli/runs-inventory.mjs +5 -3
- package/cli/stop.mjs +2 -2
- package/cli/unlock.mjs +2 -2
- package/package.json +2 -2
- package/src/agents/codex-advisor.md +187 -0
- package/src/agents/codex-build.md +4 -0
- package/src/commands/env.md +5 -4
- package/src/home/hooks/live-runs.mjs +19 -21
- package/src/home/hooks/reply-guard.mjs +5 -2
- package/src/home/hooks/shell-write-intent.mjs +320 -154
- package/src/home/hooks/worktree-lock.mjs +9 -2
- package/src/home/hooks/worktree-witness.mjs +21 -40
- package/src/home/lib/agents.mjs +3 -0
- package/src/home/lib/budgets.mjs +64 -0
- package/src/home/lib/config-validate.mjs +3 -39
- package/src/home/lib/meta/advice-status.mjs +61 -0
- package/src/home/lib/meta/advice-verdict.mjs +226 -0
- package/src/home/lib/meta/advisor-task.mjs +69 -0
- package/src/home/lib/meta/events.mjs +4 -0
- package/src/home/lib/meta/launch-rows.mjs +45 -0
- package/src/home/lib/meta/reply.mjs +42 -34
- package/src/home/lib/meta/run-liveness.mjs +64 -0
- package/src/home/lib/meta/run-state.mjs +28 -34
- package/src/home/lib/meta/verdict.mjs +18 -0
- package/src/home/lib/required-inputs.mjs +25 -4
- package/src/home/lib/run-codex.mjs +1 -0
- package/src/home/lib/run-config.mjs +5 -1
- package/src/home/lib/runner/args.mjs +8 -4
- package/src/home/lib/runner/attach.mjs +5 -13
- package/src/home/lib/runner/codex-args.mjs +14 -5
- package/src/home/lib/runner/codex-cmd.mjs +105 -19
- package/src/home/lib/runner/git-state.mjs +6 -1
- package/src/home/lib/runner/launcher.mjs +36 -30
- package/src/home/lib/runner/preflight.mjs +109 -0
- package/src/home/lib/runner/prompts.mjs +39 -0
- package/src/home/lib/runner/sandbox-probe.mjs +109 -0
- package/src/home/lib/runner/schemas.mjs +137 -1
- package/src/home/lib/runner/scope-check.mjs +31 -3
- package/src/home/lib/runner/worker-order.mjs +4 -2
- package/src/home/lib/runner/worker.mjs +2 -2
- package/src/home/lib/write-meta.mjs +18 -1
|
@@ -1,154 +1,320 @@
|
|
|
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,
|
|
28
|
-
|
|
29
|
-
if (candidate
|
|
30
|
-
&& !['&', '$', '%', '`'].includes(candidate[0])
|
|
31
|
-
&& !IMPOSSIBLE_IN_A_NAME.test(candidate)
|
|
32
|
-
&& !paths.includes(candidate)) paths.push(candidate);
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
const
|
|
43
|
-
let quote = null;
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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, candidate) {
|
|
28
|
+
// D4c: word values are already unquoted; raw redirects keep their separate unquote() call.
|
|
29
|
+
if (candidate
|
|
30
|
+
&& !['&', '$', '%', '`'].includes(candidate[0])
|
|
31
|
+
&& !IMPOSSIBLE_IN_A_NAME.test(candidate)
|
|
32
|
+
&& !paths.includes(candidate)) paths.push(candidate);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// One quote reader owns redirects, command boundaries and words: the 2026-09-16 awk refusal
|
|
36
|
+
// repeated the 2026-08-23 node, 2026-08-28 sed and 2026-09-06 heredoc text-as-shell incidents.
|
|
37
|
+
function lexShell(command) {
|
|
38
|
+
const literal = new Uint8Array(command.length);
|
|
39
|
+
const state = () => ({ commands: [], words: [], word: '', started: false, escaped: false, previousLiteral: false });
|
|
40
|
+
let parsed = state();
|
|
41
|
+
const plain = state();
|
|
42
|
+
const stack = [];
|
|
43
|
+
let quote = null;
|
|
44
|
+
let doubleEscape = false;
|
|
45
|
+
let comment = false;
|
|
46
|
+
|
|
47
|
+
function finish(current, endCommand) {
|
|
48
|
+
if (current.word || (current !== plain && current.started)) {
|
|
49
|
+
current.words.push(current.word);
|
|
50
|
+
current.word = '';
|
|
51
|
+
current.started = false;
|
|
52
|
+
}
|
|
53
|
+
if (endCommand && current.words.length) {
|
|
54
|
+
current.commands.push(current.words);
|
|
55
|
+
current.words = [];
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function append(current, index, isLiteral) {
|
|
60
|
+
const character = command[index];
|
|
61
|
+
if (current.escaped) {
|
|
62
|
+
current.escaped = false;
|
|
63
|
+
isLiteral = true;
|
|
64
|
+
} else if (!isLiteral && character === '\\'
|
|
65
|
+
&& (/[;|&<>() \t'"\\]/.test(command[index + 1] ?? '')
|
|
66
|
+
|| (current !== plain && /[#$]/.test(command[index + 1] ?? '')))) {
|
|
67
|
+
// Windows paths use backslashes too: Git Bash escapes shell metacharacters here,
|
|
68
|
+
// but consuming every backslash would corrupt C:\tools\sed.exe and its targets.
|
|
69
|
+
current.escaped = true;
|
|
70
|
+
current.previousLiteral = true;
|
|
71
|
+
current.started = true;
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
const pipeAmpersand = character === '&' && !current.previousLiteral && command[index - 1] === '|';
|
|
75
|
+
const operatorAmpersand = character === '&'
|
|
76
|
+
&& ((!current.previousLiteral && /[<>|]/.test(command[index - 1] ?? ''))
|
|
77
|
+
|| command[index + 1] === '>');
|
|
78
|
+
current.previousLiteral = isLiteral;
|
|
79
|
+
if (!isLiteral && pipeAmpersand) return false;
|
|
80
|
+
if (!isLiteral && (';|()\r\n'.includes(character)
|
|
81
|
+
|| (character === '&' && !operatorAmpersand))) finish(current, true);
|
|
82
|
+
else if (!isLiteral && /\s/.test(character)) finish(current, false);
|
|
83
|
+
else {
|
|
84
|
+
current.word += character;
|
|
85
|
+
current.started = true;
|
|
86
|
+
}
|
|
87
|
+
return isLiteral;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
for (let index = 0; index < command.length; index += 1) {
|
|
91
|
+
const character = command[index];
|
|
92
|
+
// Keep the original quote-blind fallback for malformed input (D4c review).
|
|
93
|
+
append(plain, index, false);
|
|
94
|
+
if (comment) {
|
|
95
|
+
if (!'\r\n'.includes(character)) {
|
|
96
|
+
literal[index] = 1;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
comment = false;
|
|
100
|
+
}
|
|
101
|
+
let isLiteral = quote !== null || parsed.escaped || doubleEscape;
|
|
102
|
+
let quoteBoundary = false;
|
|
103
|
+
if (doubleEscape) doubleEscape = false;
|
|
104
|
+
else if (!parsed.escaped) {
|
|
105
|
+
if (quote === '"' && character === '\\' && index + 1 < command.length) {
|
|
106
|
+
doubleEscape = true;
|
|
107
|
+
} else if (quote) {
|
|
108
|
+
if (character === quote) {
|
|
109
|
+
quote = null;
|
|
110
|
+
quoteBoundary = true;
|
|
111
|
+
}
|
|
112
|
+
} else if (character === "'" || character === '"') {
|
|
113
|
+
quote = character;
|
|
114
|
+
isLiteral = true;
|
|
115
|
+
quoteBoundary = true;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
// D4c: quote fragments belong to one word; their delimiters are not its value.
|
|
119
|
+
if (quoteBoundary) {
|
|
120
|
+
parsed.started = true;
|
|
121
|
+
parsed.previousLiteral = true;
|
|
122
|
+
literal[index] = 1;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (!isLiteral && character === '#' && !parsed.started) {
|
|
126
|
+
comment = true;
|
|
127
|
+
literal[index] = 1;
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if (!isLiteral && character === '(') {
|
|
131
|
+
const substitution = command[index - 1] === '$' && !parsed.previousLiteral;
|
|
132
|
+
if (!parsed.started || substitution) {
|
|
133
|
+
// D4c: suspend the outer command, retaining an unresolved $() in its word.
|
|
134
|
+
// Flattening parentheses made a substitution suffix look like a new command.
|
|
135
|
+
if (substitution) parsed.word += '()';
|
|
136
|
+
stack.push(parsed);
|
|
137
|
+
parsed = { ...state(), commands: parsed.commands };
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
isLiteral = true;
|
|
141
|
+
}
|
|
142
|
+
if (!isLiteral && character === ')') {
|
|
143
|
+
if (stack.length) {
|
|
144
|
+
finish(parsed, true);
|
|
145
|
+
parsed = stack.pop();
|
|
146
|
+
parsed.previousLiteral = false;
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
isLiteral = true;
|
|
150
|
+
}
|
|
151
|
+
literal[index] = append(parsed, index, isLiteral);
|
|
152
|
+
}
|
|
153
|
+
finish(parsed, true);
|
|
154
|
+
while (stack.length) {
|
|
155
|
+
parsed = stack.pop();
|
|
156
|
+
finish(parsed, true);
|
|
157
|
+
}
|
|
158
|
+
finish(plain, true);
|
|
159
|
+
return { literal: quote ? null : literal, commands: quote ? plain.commands : parsed.commands };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function positional(args) {
|
|
163
|
+
return args.filter((arg) => !arg.startsWith('-'));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Strip the directory and the Windows executable suffix before matching the command name.
|
|
168
|
+
* `INTERPRETERS` has always allowed `python.exe`; the command list did not, so `sed.exe -i` —
|
|
169
|
+
* the spelling Git Bash and PowerShell hand over on this platform — walked past the guard while
|
|
170
|
+
* the identical `sed -i` was refused (found accepting the 2026-08-17 smoke run, which had recorded
|
|
171
|
+
* that gap as intended behaviour).
|
|
172
|
+
*/
|
|
173
|
+
function commandName(token) {
|
|
174
|
+
return token.replace(/^.*[\\/]/, '').replace(/\.exe$/i, '').toLowerCase();
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const COMMAND_PREFIXES = new Set(['if', 'then', 'else', 'elif', 'while', 'until', 'do', '!', '{', 'time']);
|
|
178
|
+
const LAUNCHERS = new Set(['xargs', 'env', 'nohup', 'timeout', 'nice', 'command', 'exec', 'sudo']);
|
|
179
|
+
|
|
180
|
+
// Plan_57 D11: `git log --grep rm` and `grep -rn touch` were refused because arguments became
|
|
181
|
+
// commands. These launchers only move command position; unknown launchers leave a lock gap
|
|
182
|
+
// covered after execution by worktree-witness.mjs, without falsely refusing a read.
|
|
183
|
+
function* writingCommands(parts) {
|
|
184
|
+
// D4c review: 6000 nested find actions overflowed recursive yield*. Index ranges and
|
|
185
|
+
// precomputed terminators keep traversal iterative without repeatedly copying/scanning tails.
|
|
186
|
+
const terminators = new Array(parts.length);
|
|
187
|
+
let next = parts.length;
|
|
188
|
+
for (let index = parts.length - 1; index >= 0; index -= 1) {
|
|
189
|
+
if ([';', '+'].includes(parts[index])) next = index;
|
|
190
|
+
terminators[index] = next;
|
|
191
|
+
}
|
|
192
|
+
const pending = [{ start: 0, end: parts.length }];
|
|
193
|
+
while (pending.length) {
|
|
194
|
+
const { start, end } = pending.pop();
|
|
195
|
+
let launched = false;
|
|
196
|
+
for (let index = start; index < end; index += 1) {
|
|
197
|
+
const part = parts[index];
|
|
198
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(part) || COMMAND_PREFIXES.has(part)) continue;
|
|
199
|
+
// A case pattern (`a)`, `*)`) precedes a command position the way `then` does. The lexer keeps
|
|
200
|
+
// an unmatched `)` literal because `echo ) rm x` runs nothing, which alone hid
|
|
201
|
+
// `case x in a) rm b.txt;; esac` — a write the scanner caught before D4c.
|
|
202
|
+
if (part === 'case') {
|
|
203
|
+
while (index + 1 < end && !parts[index].endsWith(')')) index += 1;
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
if (part.endsWith(')')) continue;
|
|
207
|
+
if (/^(?:\d*[<>]|&>)/.test(part)) {
|
|
208
|
+
if (/^(?:\d*[<>]+&?|&>>?)$/.test(part)) index += 1;
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
if (launched && (part.startsWith('-') || /^\d+[smhd]?$/.test(part))) {
|
|
212
|
+
if (launched === 'command' && /^-[^-]*[vV]/.test(part)) break;
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
const name = commandName(part);
|
|
216
|
+
if (LAUNCHERS.has(name)) {
|
|
217
|
+
launched = name;
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
if (COMMANDS.has(name)) yield parts.slice(index, end);
|
|
221
|
+
else if (name === 'find') {
|
|
222
|
+
const actions = [];
|
|
223
|
+
for (index += 1; index < end; index += 1) {
|
|
224
|
+
if (!['-exec', '-execdir', '-ok', '-okdir'].includes(parts[index])) continue;
|
|
225
|
+
const actionStart = index + 1;
|
|
226
|
+
index = Math.min(terminators[actionStart] ?? end, end);
|
|
227
|
+
// D11: terminators and later predicates are not action arguments.
|
|
228
|
+
actions.push({ start: actionStart, end: index });
|
|
229
|
+
}
|
|
230
|
+
for (let action = actions.length - 1; action >= 0; action -= 1) pending.push(actions[action]);
|
|
231
|
+
}
|
|
232
|
+
break;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Split a heredoc command into the shell around it and the document inside it.
|
|
239
|
+
*
|
|
240
|
+
* `shell` is everything the shell itself runs — the line opening the document AND every command
|
|
241
|
+
* after its closing marker. Dropping that tail hid `… <<EOF … EOF; echo x >> README.md` from the
|
|
242
|
+
* guard completely, and a live probe on 2026-08-23 wrote to a tracked file during a held run
|
|
243
|
+
* exactly that way. `paths` holds only what the document body names, which the caller uses just
|
|
244
|
+
* when the interpreter is itself the writer.
|
|
245
|
+
*/
|
|
246
|
+
function heredoc(command) {
|
|
247
|
+
const header = command.match(/^(.*?<<-?\s*(['"]?)([A-Za-z_][\w-]*)\2[^\r\n]*)[\r\n]/s);
|
|
248
|
+
if (!header) return null;
|
|
249
|
+
const paths = [];
|
|
250
|
+
const marker = header[3];
|
|
251
|
+
const body = command.slice(header[0].length).split(/\r?\n/);
|
|
252
|
+
const end = body.findIndex((line) => line.trim() === marker);
|
|
253
|
+
const shell = [header[1], ...(end < 0 ? [] : body.slice(end + 1))].join('\n');
|
|
254
|
+
if (!INTERPRETERS.test(header[1])) return { shell, paths, writes: false };
|
|
255
|
+
const source = body.slice(0, end < 0 ? body.length : end).join('\n');
|
|
256
|
+
for (const match of source.matchAll(/(['"])(.*?)\1/g)) {
|
|
257
|
+
const value = match[2];
|
|
258
|
+
if (/^(?:[A-Za-z]:[\\/]|[\\/]|\.\.?[\\/])/.test(value)
|
|
259
|
+
|| /[\\/]/.test(value)
|
|
260
|
+
|| /(?:^|[\\/])[^\\/]+\.[A-Za-z0-9_-]+$/.test(value)) addPath(paths, unquote(value));
|
|
261
|
+
}
|
|
262
|
+
return { shell, paths, writes: true };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Returns `{ writes, paths }` for the deliberately obvious write forms this guard recognises. */
|
|
266
|
+
export function shellWriteIntent(command) {
|
|
267
|
+
if (typeof command !== 'string' || !command.trim()) return { writes: false, paths: [] };
|
|
268
|
+
const paths = [];
|
|
269
|
+
const document = heredoc(command);
|
|
270
|
+
// The document body is prose until an interpreter makes it code. Reading `header` here while
|
|
271
|
+
// heredoc() returned `shell` meant the whole command, body included, went through shell parsing
|
|
272
|
+
// whenever the writer was plain `cat`: on 2026-09-06 the lock refused four harmless commands,
|
|
273
|
+
// the last of them because a sentence in a task file said "Do not touch the installer" and
|
|
274
|
+
// `touch` is in COMMANDS, so every following word became a write target under the repository.
|
|
275
|
+
const shell = document?.shell ?? command;
|
|
276
|
+
let writes = document?.writes ?? false;
|
|
277
|
+
const { literal, commands } = lexShell(shell);
|
|
278
|
+
|
|
279
|
+
const redirects = /(?<![<=])(?:\d*)>>?(?![=>&])\s*("(?:\\.|[^"])*"|'[^']*'|[^\s;|&]+)/g;
|
|
280
|
+
let match;
|
|
281
|
+
while ((match = redirects.exec(shell))) {
|
|
282
|
+
if (literal?.[match.index]
|
|
283
|
+
|| (shell[match.index - 1] === '>' && !literal?.[match.index - 1])) {
|
|
284
|
+
// D4c: a literal > must not consume the real redirect immediately after it.
|
|
285
|
+
redirects.lastIndex = match.index + 1;
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
writes = true;
|
|
289
|
+
addPath(paths, unquote(match[1]));
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// A real output redirect identifies the writer's destination. Body text is only evidence when
|
|
293
|
+
// the interpreter itself is the writer, as in the 2026-08-16 python heredoc incident.
|
|
294
|
+
if (!paths.length && document?.writes) {
|
|
295
|
+
for (const candidate of document.paths) addPath(paths, unquote(candidate));
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
for (const parts of commands) {
|
|
299
|
+
// D11 keeps the pre-position fallback: an unfinished quote makes word positions untrusted.
|
|
300
|
+
const candidates = literal ? writingCommands(parts) : [parts];
|
|
301
|
+
for (const candidate of candidates) {
|
|
302
|
+
const index = literal ? 0 : candidate.findIndex((part) => COMMANDS.has(commandName(part)));
|
|
303
|
+
if (index < 0) continue;
|
|
304
|
+
const name = commandName(candidate[index]);
|
|
305
|
+
const args = candidate.slice(index + 1);
|
|
306
|
+
if (name === 'sed' && !args.some((arg) => /^-.*i/.test(arg))) continue;
|
|
307
|
+
writes = true;
|
|
308
|
+
const values = positional(args).map((value) => literal ? value : unquote(value));
|
|
309
|
+
if (name === 'cp' || name === 'mv') {
|
|
310
|
+
if (values.length > 1) addPath(paths, values.at(-1));
|
|
311
|
+
} else if (name === 'sed') {
|
|
312
|
+
for (const value of values.slice(1)) addPath(paths, value);
|
|
313
|
+
} else {
|
|
314
|
+
for (const value of values) addPath(paths, value);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
return { writes, paths };
|
|
320
|
+
}
|
|
@@ -39,8 +39,15 @@ if (WRITE_TOOL_NAMES.has(input.tool_name)) {
|
|
|
39
39
|
const rawPath = toolInput[pathField];
|
|
40
40
|
if (typeof rawPath !== 'string' || !rawPath.trim()) pass();
|
|
41
41
|
rawPaths = [rawPath];
|
|
42
|
-
} else {
|
|
43
|
-
|
|
42
|
+
} else {
|
|
43
|
+
let intent;
|
|
44
|
+
try {
|
|
45
|
+
intent = shellWriteIntent(toolInput.command);
|
|
46
|
+
} catch {
|
|
47
|
+
// D4c review (2026-09-17_131740_plan-57-d4-review): deep find actions overflowed
|
|
48
|
+
// the scanner. Unrecognised input must fail open; the witness checks writes afterwards.
|
|
49
|
+
pass();
|
|
50
|
+
}
|
|
44
51
|
if (!intent.writes || !intent.paths.length) pass();
|
|
45
52
|
rawPaths = intent.paths;
|
|
46
53
|
}
|
|
@@ -7,13 +7,17 @@
|
|
|
7
7
|
* split-guard run and made an honest build appear out of scope. This hook compares the live
|
|
8
8
|
* run's recorded git state with the repository after every shell tool and directs the
|
|
9
9
|
* orchestrator to undo any change outside the run's declared scope.
|
|
10
|
+
* The 2026-09-19 run accused itself of editing its own artifacts: the witness must use
|
|
11
|
+
* the verdict's snapshot and environment split, not a second definition of changed work.
|
|
10
12
|
*/
|
|
11
13
|
import fs from 'node:fs';
|
|
12
14
|
import path from 'node:path';
|
|
13
|
-
import { spawnSync } from 'node:child_process';
|
|
14
15
|
import { SHELL_TOOLS } from '../lib/hook-definitions.mjs';
|
|
15
16
|
import { parseJsonText } from '../lib/json-file.mjs';
|
|
16
|
-
import {
|
|
17
|
+
import { splitRunChanges } from '../lib/meta/environment.mjs';
|
|
18
|
+
import { changedPaths } from '../lib/meta/paths.mjs';
|
|
19
|
+
import { outOfScope } from '../lib/meta/verdict.mjs';
|
|
20
|
+
import { git, worktreeSnapshot } from '../lib/runner/git-state.mjs';
|
|
17
21
|
import { runsRoot } from '../lib/runner/runs-root.mjs';
|
|
18
22
|
import { allLiveRuns, normalizePath } from './live-runs.mjs';
|
|
19
23
|
|
|
@@ -28,19 +32,6 @@ function readRequired(file) {
|
|
|
28
32
|
}
|
|
29
33
|
}
|
|
30
34
|
|
|
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
35
|
let input;
|
|
45
36
|
try {
|
|
46
37
|
input = parseJsonText('stdin', fs.readFileSync(0, 'utf8'));
|
|
@@ -67,37 +58,26 @@ const owner = liveRuns.find(({ status }) => {
|
|
|
67
58
|
if (!owner) pass();
|
|
68
59
|
|
|
69
60
|
const { dir, status } = owner;
|
|
70
|
-
const beforeText = readRequired(path.join(dir, '
|
|
61
|
+
const beforeText = readRequired(path.join(dir, 'state-before.txt'));
|
|
71
62
|
const scopeText = readRequired(path.join(dir, 'scope.txt'));
|
|
72
63
|
if (beforeText === null || scopeText === null) pass();
|
|
73
64
|
|
|
74
|
-
|
|
75
|
-
let allowed;
|
|
65
|
+
let outside;
|
|
76
66
|
try {
|
|
67
|
+
// changedPaths tolerates malformed rows; a hook must instead fail open on a damaged baseline.
|
|
68
|
+
if (beforeText.split(/\r?\n/).filter(Boolean).some((row) =>
|
|
69
|
+
!/^(?:\d+\t\d+|-\t-|U\t\d+)\t\S.*$/.test(row))) pass();
|
|
77
70
|
const patterns = scopeText.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
78
71
|
if (!patterns.length) pass();
|
|
79
|
-
|
|
72
|
+
// The shared snapshot returns empty output on git errors; do not call that a restored tree.
|
|
73
|
+
const repository = git(status.repo, ['rev-parse', '--is-inside-work-tree']);
|
|
74
|
+
if (repository.error || repository.status !== 0 || repository.stdout?.trim() !== 'true') pass();
|
|
75
|
+
const current = worktreeSnapshot(status.repo);
|
|
76
|
+
const { work } = splitRunChanges(dir, changedPaths(beforeText, current));
|
|
77
|
+
outside = outOfScope(work, patterns);
|
|
80
78
|
} catch {
|
|
81
79
|
pass();
|
|
82
80
|
}
|
|
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
81
|
if (!outside.length) pass();
|
|
102
82
|
|
|
103
83
|
const folder = path.basename(dir);
|
|
@@ -105,9 +85,10 @@ const paths = outside.join(', ');
|
|
|
105
85
|
process.stdout.write(JSON.stringify({
|
|
106
86
|
hookSpecificOutput: {
|
|
107
87
|
hookEventName: 'PostToolUse',
|
|
108
|
-
additionalContext: `WORKTREE WITNESS —
|
|
109
|
-
+ `
|
|
110
|
-
+ `
|
|
88
|
+
additionalContext: `WORKTREE WITNESS — the run's own folder, environment paths and gitignored `
|
|
89
|
+
+ `files are already excluded. These are the orchestrator's own edits inside the repository `
|
|
90
|
+
+ `of live run folder ${dir} (agent ${status.agent}, slug ${status.slug}), outside its scope: ${paths}. `
|
|
91
|
+
+ `Act now: revert those paths, or run codex-bridge stop ${folder} to take the repository back `
|
|
111
92
|
+ `before making further changes.`,
|
|
112
93
|
},
|
|
113
94
|
}));
|
package/src/home/lib/agents.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Defines each delegated agent's role, wall-clock budget, write access and result file.
|
|
3
|
+
* D2: a budget declares positive minutes or named phases; budgets.mjs alone normalizes it.
|
|
3
4
|
*
|
|
4
5
|
* The three agents were listed four times: twice in run-config.mjs for budgets and models,
|
|
5
6
|
* once in codex-args.mjs for role lookup, and once in order-gate.mjs for guarded names.
|
|
@@ -10,6 +11,8 @@ export const AGENTS = {
|
|
|
10
11
|
'codex-scout': { role: 'scout', budget: 15, writes: false, result: 'result.json' },
|
|
11
12
|
'codex-build': { role: 'build', budget: 25, writes: true, result: 'result.json' },
|
|
12
13
|
'codex-review': { role: 'review', budget: 20, writes: false, result: 'review.json' },
|
|
14
|
+
// Plan_59: design advice needs a bounded scope pass before the continued decision pass.
|
|
15
|
+
'codex-advisor': { role: 'advisor', budget: { scope: 5, advise: 15 }, writes: false, result: 'result.json' },
|
|
13
16
|
};
|
|
14
17
|
|
|
15
18
|
export const agentRole = (agent) => AGENTS[agent]?.role;
|