@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.
- package/CHANGELOG.md +716 -592
- package/README.md +155 -155
- package/cli/runs-inventory.mjs +5 -3
- package/cli/settings-merge.mjs +36 -17
- package/cli/stop.mjs +2 -2
- package/cli/unlock.mjs +2 -2
- package/package.json +49 -49
- 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/meta/events.mjs +4 -0
- package/src/home/lib/meta/launch-rows.mjs +45 -0
- package/src/home/lib/meta/reply.mjs +1 -32
- package/src/home/lib/meta/run-liveness.mjs +64 -0
- package/src/home/lib/meta/run-state.mjs +26 -34
- package/src/home/lib/meta/verdict.mjs +11 -0
- package/src/home/lib/runner/args.mjs +7 -4
- package/src/home/lib/runner/attach.mjs +5 -13
- package/src/home/lib/runner/codex-args.mjs +11 -3
- package/src/home/lib/runner/codex-cmd.mjs +104 -18
- package/src/home/lib/runner/git-state.mjs +6 -1
- package/src/home/lib/runner/launcher.mjs +20 -27
- package/src/home/lib/runner/preflight.mjs +35 -0
- package/src/home/lib/runner/sandbox-probe.mjs +109 -0
- package/src/home/lib/runner/scope-check.mjs +22 -2
- package/src/home/lib/write-meta.mjs +5 -1
|
@@ -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
|
|
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
|
-
|
|
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
|
-
&&
|
|
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 {
|
|
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
|
-
|
|
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,
|
|
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
|
}
|