@lyupro/codex-bridge 0.6.1 → 0.6.4

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.
@@ -9,34 +9,17 @@
9
9
  *
10
10
  * The heartbeat is required here and deliberately NOT in meta/run-state.mjs: releasing a lock
11
11
  * early costs an operator one knowing edit, while closing a record early makes a second writer
12
- * of its meta.json. See the comment above pidAlive() there.
12
+ * of its meta.json. See the comment above markAbandoned() in meta/run-state.mjs.
13
13
  */
14
14
  import fs from 'node:fs';
15
15
  import path from 'node:path';
16
16
  import { heartbeatAge, isHeartbeatFresh } from '../lib/heartbeat.mjs';
17
17
  import { readJsonFileSync } from '../lib/json-file.mjs';
18
- import {
19
- IDENTITY_ALIVE,
20
- IDENTITY_DEAD,
21
- IDENTITY_FOREIGN,
22
- processIdentity,
23
- } from '../lib/process-identity.mjs';
18
+ import { runLiveness } from '../lib/meta/run-liveness.mjs';
19
+ import { IDENTITY_ALIVE } from '../lib/process-identity.mjs';
24
20
 
25
21
  export const RECENT_RUN_MAX_AGE_MS = 24 * 60 * 60 * 1_000;
26
22
 
27
- /**
28
- * The hook requires pid judgment plus a fresh heartbeat. Identity uncertainty stays live here
29
- * (fail-open), while the identity module deliberately treats a missing heartbeat as unverified;
30
- * isHeartbeatFresh() keeps its pre-Plan_20 missing-file compatibility for this separate question.
31
- */
32
- // Plan_31 needs confirmed process identity before warning about paid work that TaskStop leaves
33
- // behind; existing lock/reply guards retain the fail-open default for uncertain identities.
34
- export const isPidAlive = (pid, runDir, status = {}, options = {}) => {
35
- const identity = processIdentity({ runDir, status: { ...status, pid } });
36
- if (options.requireConfirmedIdentity === true) return identity === IDENTITY_ALIVE;
37
- return identity !== IDENTITY_DEAD && identity !== IDENTITY_FOREIGN;
38
- };
39
-
40
23
  /**
41
24
  * Match the package's repository comparison without resolving symlinks: Windows realpath adds
42
25
  * `\\?\\` and UNC spellings, turning equal paths into unequal ones. A null result is uncertainty,
@@ -58,9 +41,24 @@ function isNonEmptyString(value) {
58
41
  return typeof value === 'string' && Boolean(value.trim());
59
42
  }
60
43
 
44
+ /**
45
+ * The hook requires pid judgment plus a fresh heartbeat. Identity uncertainty stays live here
46
+ * (fail-open), while the identity module deliberately treats a missing heartbeat as unverified;
47
+ * isHeartbeatFresh() keeps its pre-Plan_20 missing-file compatibility for this separate question.
48
+ */
61
49
  function recognizedStatus(runDir, status, options) {
62
50
  const heartbeatFresh = isHeartbeatFresh(runDir)
63
51
  && (options.requireConfirmedIdentity !== true || heartbeatAge(runDir) !== null);
52
+ // Judged last: an identity probe can spawn PowerShell, and every hook call scans every project,
53
+ // so a record already ruled out by its fields or a stale heartbeat must never pay for one.
54
+ const judged = () => {
55
+ const liveness = runLiveness({ runDir, status });
56
+ // Plan_31 needs confirmed process identity before warning about paid work that TaskStop leaves
57
+ // behind; existing lock/reply guards retain the fail-open default for uncertain identities.
58
+ return options.requireConfirmedIdentity === true
59
+ ? liveness.identity === IDENTITY_ALIVE
60
+ : liveness.processMayBeAlive === true;
61
+ };
64
62
  return Boolean(status)
65
63
  && typeof status === 'object'
66
64
  && !Array.isArray(status)
@@ -70,7 +68,7 @@ function recognizedStatus(runDir, status, options) {
70
68
  && isNonEmptyString(status.slug)
71
69
  && isNonEmptyString(status.repo)
72
70
  && heartbeatFresh
73
- && isPidAlive(status.pid, runDir, status, options);
71
+ && judged();
74
72
  }
75
73
 
76
74
  function readStatus(runDir) {
@@ -33,9 +33,10 @@ import { AGENTS } from '../lib/agents.mjs';
33
33
  import { runOrderMismatch, transcriptOrderId } from '../lib/dispatcher-order.mjs';
34
34
  import { recognizeHostRefusal } from '../lib/host-refusal.mjs';
35
35
  import { readJsonFileSync } from '../lib/json-file.mjs';
36
+ import { runLiveness } from '../lib/meta/run-liveness.mjs';
36
37
  import { resolveProjectRunsDir } from '../lib/runner/project-dir.mjs';
37
38
  import { runsRoot } from '../lib/runner/runs-root.mjs';
38
- import { isPidAlive, liveRuns, normalizePath, recentRuns } from './live-runs.mjs';
39
+ import { liveRuns, normalizePath, recentRuns } from './live-runs.mjs';
39
40
  import { FORM, MAX_STATE_BLOCKS, STATE, takeTry } from './guard-tries.mjs';
40
41
  import { parseReply } from './reply-parser.mjs';
41
42
  import {
@@ -233,7 +234,9 @@ if (fs.existsSync(statusPath)) {
233
234
  }
234
235
 
235
236
  if (runStatus?.state === 'running') {
236
- if (isPidAlive(runStatus.pid)) {
237
+ // Plan_57 D5/D27: a bare pid mistook a reused foreign process for this run's worker.
238
+ // The shared judge uses run identity and keeps unverified processes live (fail open).
239
+ if (runLiveness({ runDir, status: runStatus }).processMayBeAlive) {
237
240
  blockState(
238
241
  liveRunReason,
239
242
  liveRunStop(MAX_STATE_BLOCKS, runStatus, runDir),
@@ -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, 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
- // A `>` inside a quoted argument is text, not a redirect. The 2026-08-28 worktree-lock refusal
40
- // named `#'` from `sed -E 's#(A ).*#\1<redacted>#'` because this scan previously read raw text.
41
- function quotedCharacters(command) {
42
- const quoted = new Uint8Array(command.length);
43
- let quote = null;
44
- for (let index = 0; index < command.length; index += 1) {
45
- const character = command[index];
46
- if (quote === "'") {
47
- if (character === "'") quote = null;
48
- else quoted[index] = 1;
49
- } else if (quote === '"') {
50
- if (character === '\\' && index + 1 < command.length) {
51
- quoted[index] = 1;
52
- quoted[index + 1] = 1;
53
- index += 1;
54
- } else if (character === '"') quote = null;
55
- else quoted[index] = 1;
56
- } else if (character === '\\' && index + 1 < command.length) {
57
- index += 1;
58
- } else if (character === "'" || character === '"') {
59
- quote = character;
60
- quoted[index] = 1;
61
- }
62
- }
63
- return quote ? null : quoted;
64
- }
65
-
66
- function positional(args) {
67
- return args.filter((arg) => !arg.startsWith('-'));
68
- }
69
-
70
- /**
71
- * Strip the directory and the Windows executable suffix before matching the command name.
72
- * `INTERPRETERS` has always allowed `python.exe`; the command list did not, so `sed.exe -i` —
73
- * the spelling Git Bash and PowerShell hand over on this platform — walked past the guard while
74
- * the identical `sed -i` was refused (found accepting the 2026-08-17 smoke run, which had recorded
75
- * that gap as intended behaviour).
76
- */
77
- function commandName(token) {
78
- return token.replace(/^.*[\\/]/, '').replace(/\.exe$/i, '').toLowerCase();
79
- }
80
-
81
- /**
82
- * Split a heredoc command into the shell around it and the document inside it.
83
- *
84
- * `shell` is everything the shell itself runs — the line opening the document AND every command
85
- * after its closing marker. Dropping that tail hid `… <<EOF … EOF; echo x >> README.md` from the
86
- * guard completely, and a live probe on 2026-08-23 wrote to a tracked file during a held run
87
- * exactly that way. `paths` holds only what the document body names, which the caller uses just
88
- * when the interpreter is itself the writer.
89
- */
90
- function heredoc(command) {
91
- const header = command.match(/^(.*?<<-?\s*(['"]?)([A-Za-z_][\w-]*)\2[^\r\n]*)[\r\n]/s);
92
- if (!header) return null;
93
- const paths = [];
94
- const marker = header[3];
95
- const body = command.slice(header[0].length).split(/\r?\n/);
96
- const end = body.findIndex((line) => line.trim() === marker);
97
- const shell = [header[1], ...(end < 0 ? [] : body.slice(end + 1))].join('\n');
98
- if (!INTERPRETERS.test(header[1])) return { shell, paths, writes: false };
99
- const source = body.slice(0, end < 0 ? body.length : end).join('\n');
100
- for (const match of source.matchAll(/(['"])(.*?)\1/g)) {
101
- const value = match[2];
102
- if (/^(?:[A-Za-z]:[\\/]|[\\/]|\.\.?[\\/])/.test(value)
103
- || /[\\/]/.test(value)
104
- || /(?:^|[\\/])[^\\/]+\.[A-Za-z0-9_-]+$/.test(value)) addPath(paths, value);
105
- }
106
- return { shell, paths, writes: true };
107
- }
108
-
109
- /** Returns `{ writes, paths }` for the deliberately obvious write forms this guard recognises. */
110
- export function shellWriteIntent(command) {
111
- if (typeof command !== 'string' || !command.trim()) return { writes: false, paths: [] };
112
- const paths = [];
113
- const document = heredoc(command);
114
- // The document body is prose until an interpreter makes it code. Reading `header` here while
115
- // heredoc() returned `shell` meant the whole command, body included, went through shell parsing
116
- // whenever the writer was plain `cat`: on 2026-09-06 the lock refused four harmless commands,
117
- // the last of them because a sentence in a task file said "Do not touch the installer" and
118
- // `touch` is in COMMANDS, so every following word became a write target under the repository.
119
- const shell = document?.shell ?? command;
120
- let writes = document?.writes ?? false;
121
- const quoted = quotedCharacters(shell);
122
-
123
- for (const match of shell.matchAll(/(?<![<>=])(?:\d*)>>?(?![=>&])\s*("(?:\\.|[^"])*"|'[^']*'|[^\s;|&]+)/g)) {
124
- if (quoted && quoted[match.index]) continue;
125
- writes = true;
126
- addPath(paths, match[1]);
127
- }
128
-
129
- // A real output redirect identifies the writer's destination. Body text is only evidence when
130
- // the interpreter itself is the writer, as in the 2026-08-16 python heredoc incident.
131
- if (!paths.length && document?.writes) {
132
- for (const candidate of document.paths) addPath(paths, candidate);
133
- }
134
-
135
- for (const segment of shell.split(/(?:&&|\|\||[;|\r\n])/)) {
136
- const parts = tokens(segment);
137
- const index = parts.findIndex((part) => COMMANDS.has(commandName(part)));
138
- if (index < 0) continue;
139
- const name = commandName(parts[index]);
140
- const args = parts.slice(index + 1);
141
- if (name === 'sed' && !args.some((arg) => /^-.*i/.test(arg))) continue;
142
- writes = true;
143
- const values = positional(args);
144
- if (name === 'cp' || name === 'mv') {
145
- if (values.length > 1) addPath(paths, values.at(-1));
146
- } else if (name === 'sed') {
147
- for (const value of values.slice(1)) addPath(paths, value);
148
- } else {
149
- for (const value of values) addPath(paths, value);
150
- }
151
- }
152
-
153
- return { writes, paths };
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
- const intent = shellWriteIntent(toolInput.command);
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
  }