@sema-agent/core 7.10.0 → 7.11.0
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 +64 -0
- package/dist/agents/child-model-seat.d.ts +45 -18
- package/dist/agents/child-model-seat.js +12 -8
- package/dist/agents/subagent.js +6 -5
- package/dist/agents/teacher.js +2 -2
- package/dist/core/auto-mode-defaults.d.ts +15 -3
- package/dist/core/auto-mode-defaults.js +1 -0
- package/dist/core/auto-mode.d.ts +24 -20
- package/dist/core/auto-mode.js +12 -12
- package/dist/core/gate-fold.js +1 -0
- package/dist/core/gate-lanes.d.ts +6 -1
- package/dist/core/gate-lanes.js +45 -18
- package/dist/core/hooks.d.ts +13 -0
- package/dist/core/permission-rule-model.d.ts +5 -3
- package/dist/core/permission-rule-model.js +7 -3
- package/dist/core/persisted-rule-arms.js +4 -3
- package/dist/core/read-only-shell-table.d.ts +87 -0
- package/dist/core/read-only-shell-table.js +485 -0
- package/dist/core/read-only-shell.d.ts +42 -0
- package/dist/core/read-only-shell.js +316 -0
- package/dist/core/roles.d.ts +3 -2
- package/dist/core/runner/gate-exit.d.ts +5 -0
- package/dist/core/runner/prepare-caps-and-workflow.js +22 -11
- package/dist/core/runner/prepare-gate-stations.js +9 -0
- package/dist/core/runner/prepare-task.js +1 -1
- package/dist/core/runner/prepare-turn-wiring.js +1 -1
- package/dist/core/shell-lexer.d.ts +18 -0
- package/dist/core/shell-lexer.js +17 -10
- package/dist/core/shell-wrapper-table.js +8 -5
- package/dist/core/tool-policy.d.ts +4 -1
- package/dist/core/tool-policy.js +1 -1
- package/dist/core/tools.d.ts +28 -7
- package/dist/core/tools.js +44 -4
- package/dist/core/trace.d.ts +15 -0
- package/dist/engine/harness/agent-harness.d.ts +3 -1
- package/dist/engine/harness/agent-harness.js +1 -1
- package/dist/engine/harness/types.d.ts +4 -2
- package/dist/index.d.ts +3 -1
- package/dist/index.js +2 -0
- package/dist/orchestration/run-workflow-tool.d.ts +5 -2
- package/dist/orchestration/run-workflow-tool.js +2 -1
- package/dist/orchestration/workflow-governance.d.ts +3 -2
- package/dist/orchestration/workflow-primitives.d.ts +4 -1
- package/dist/orchestration/workflow-primitives.js +1 -6
- package/dist/orchestration/workflow.d.ts +12 -4
- package/dist/orchestration/workflow.js +24 -7
- package/dist/prompt-assembly/turn-snapshot.d.ts +4 -2
- package/package.json +1 -1
- package/test/export-surface.snapshot.json +35 -1
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
import { readShellCommand } from "./shell-lexer.js";
|
|
2
|
+
import { FIND_ACTION_PRIMARIES, FIND_NEWER_PRIMARY, FIND_VALUE_PRIMARIES, FLAG_VALUE_ACCEPTS, READ_ONLY_BARE_ONLY, READ_ONLY_BARE_PROGRAMS, READ_ONLY_COMMAND_TABLE, READ_ONLY_ENV_NAMES, READ_ONLY_EXACT_FORMS, READ_ONLY_GLOB_PROGRAMS, XARGS_READ_ONLY_TARGETS, dockerRetargetDanger, } from "./read-only-shell-table.js";
|
|
3
|
+
const NOT = (reason) => ({ readOnly: false, reason });
|
|
4
|
+
const READ_ONLY = { readOnly: true };
|
|
5
|
+
const BARE_PROGRAMS = new Set(READ_ONLY_BARE_PROGRAMS.filter((p) => !p.includes(" ")));
|
|
6
|
+
const BARE_MULTIWORD = READ_ONLY_BARE_PROGRAMS.filter((p) => p.includes(" ")).map((p) => p.split(" "));
|
|
7
|
+
const GLOB_PROGRAMS = new Set(READ_ONLY_GLOB_PROGRAMS);
|
|
8
|
+
const BARE_ONLY = new Set(READ_ONLY_BARE_ONLY);
|
|
9
|
+
const ENV_NAMES = new Set(READ_ONLY_ENV_NAMES);
|
|
10
|
+
const FIND_ACTIONS = new Set(FIND_ACTION_PRIMARIES);
|
|
11
|
+
const FIND_VALUES = new Set(FIND_VALUE_PRIMARIES);
|
|
12
|
+
const XARGS_TARGETS = XARGS_READ_ONLY_TARGETS;
|
|
13
|
+
const TABLE_ROWS = Object.entries(READ_ONLY_COMMAND_TABLE)
|
|
14
|
+
.map(([key, row]) => ({ words: key.split(" "), row }))
|
|
15
|
+
.sort((a, b) => b.words.length - a.words.length);
|
|
16
|
+
const READING_REDIRECTS = new Set(["<", "<<", "<<-", "<&", "<<<"]);
|
|
17
|
+
const LITERAL_ASSIGNMENT = /^([A-Za-z_][A-Za-z0-9_]*)\+?=(?:"[^"$`\\]*"|'[^']*'|[A-Za-z0-9_./:+-]*)$/;
|
|
18
|
+
const ASSIGNMENT_NAME = /^([A-Za-z_][A-Za-z0-9_]*)(?:\[[^\]]*\])?\+?=/;
|
|
19
|
+
const FLAG_WORD = /^-[a-zA-Z0-9_-]/;
|
|
20
|
+
const NUMBER_LITERAL = /^[-+]?(0[xX][0-9a-fA-F]+|[0-9]+#[0-9a-zA-Z]+|[0-9]*\.?[0-9]+([eE][-+]?[0-9]+)?)$/;
|
|
21
|
+
const PRINTF_LENGTH = "[lLhqjzZt]*";
|
|
22
|
+
const PRINTF_ESCAPE = new RegExp(`%[^%a-zA-Z]*${PRINTF_LENGTH}\\\\[0-7xX]`);
|
|
23
|
+
const PRINTF_NUMERIC = new RegExp(`%[-+ 0#']*[0-9.*]*${PRINTF_LENGTH}[diouxXeEfFgGaAn]`);
|
|
24
|
+
const JQ_DANGER_FLAG = /^['"]?-[a-zA-Z]*[fL]/;
|
|
25
|
+
const JQ_DANGER_TEXT = /--from-file|--rawfile|--slurpfile|--run-tests|--library-path|\benv\b|\$ENV\b|\binclude\b|\bimport\b/;
|
|
26
|
+
export function readOnlyShellVerdict(command, shape = readShellCommand(command)) {
|
|
27
|
+
if (shape.grouped)
|
|
28
|
+
return NOT("a subshell, group or control structure — not a flat list of simple commands");
|
|
29
|
+
if (shape.segments.length === 0)
|
|
30
|
+
return NOT("no command");
|
|
31
|
+
if (shape.backgrounded)
|
|
32
|
+
return NOT("a background `&` defers execution past the approval-time reading");
|
|
33
|
+
if (shape.strayRedirection)
|
|
34
|
+
return NOT("a redirection on a piece that runs no program — the shell still opens its target");
|
|
35
|
+
for (const seg of shape.segments) {
|
|
36
|
+
if (seg.unreadable !== undefined)
|
|
37
|
+
return NOT(seg.unreadable);
|
|
38
|
+
if (seg.peelUnreadable !== undefined)
|
|
39
|
+
return NOT(seg.peelUnreadable);
|
|
40
|
+
for (const w of [...seg.argv, ...seg.redirections.map((r) => r.target)]) {
|
|
41
|
+
if (expansionKind(w) === "variable")
|
|
42
|
+
return NOT("a variable, arithmetic or command substitution — its value is not known at approval time");
|
|
43
|
+
if (w.expands !== false && /[()]/.test(w.raw))
|
|
44
|
+
return NOT("a pattern with a parenthesised part (an extended pattern or a shell-specific glob qualifier)");
|
|
45
|
+
if (/^=[A-Za-z_]/.test(w.raw) || w.raw.includes("~["))
|
|
46
|
+
return NOT("a shell-specific expansion spelling (`=cmd`, `~[…]`)");
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
const programs = shape.segments.map((s) => peelBuiltinPrefix(s.argv.slice(leadingAssignmentCount(s.argv)).map((w) => w.text))[0] ?? "");
|
|
50
|
+
if (shape.segments.length > 1 && programs.includes("cd") && programs.includes("git"))
|
|
51
|
+
return NOT("a compound that changes directory and runs git");
|
|
52
|
+
for (const seg of shape.segments) {
|
|
53
|
+
const verdict = segmentVerdict(seg);
|
|
54
|
+
if (!verdict.readOnly)
|
|
55
|
+
return verdict;
|
|
56
|
+
}
|
|
57
|
+
return READ_ONLY;
|
|
58
|
+
}
|
|
59
|
+
function expansionKind(w) {
|
|
60
|
+
if (w.expands === false)
|
|
61
|
+
return "none";
|
|
62
|
+
if (/[$`]|[<>]\(/.test(w.raw))
|
|
63
|
+
return "variable";
|
|
64
|
+
if (w.expands === "many")
|
|
65
|
+
return "glob";
|
|
66
|
+
return "none";
|
|
67
|
+
}
|
|
68
|
+
function leadingAssignmentCount(argv) {
|
|
69
|
+
let k = 0;
|
|
70
|
+
while (k < argv.length && ASSIGNMENT_NAME.test(argv[k].raw))
|
|
71
|
+
k++;
|
|
72
|
+
return k;
|
|
73
|
+
}
|
|
74
|
+
function peelBuiltinPrefix(words) {
|
|
75
|
+
let t = words;
|
|
76
|
+
for (;;) {
|
|
77
|
+
if (t[0] === "command") {
|
|
78
|
+
let r = 1;
|
|
79
|
+
while (t[r] !== undefined && /^-p+$/.test(t[r]))
|
|
80
|
+
r++;
|
|
81
|
+
if (t[r] === "--")
|
|
82
|
+
r++;
|
|
83
|
+
if (r >= t.length || t[r].startsWith("-"))
|
|
84
|
+
return t;
|
|
85
|
+
t = t.slice(r);
|
|
86
|
+
}
|
|
87
|
+
else if (t[0] === "builtin") {
|
|
88
|
+
const r = t[1] === "--" ? 2 : 1;
|
|
89
|
+
if (r >= t.length)
|
|
90
|
+
return t;
|
|
91
|
+
t = t.slice(r);
|
|
92
|
+
}
|
|
93
|
+
else if (t[0] === "noglob") {
|
|
94
|
+
if (t.length <= 1)
|
|
95
|
+
return t;
|
|
96
|
+
t = t.slice(1);
|
|
97
|
+
}
|
|
98
|
+
else
|
|
99
|
+
return t;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
function segmentVerdict(seg) {
|
|
103
|
+
for (const r of seg.redirections) {
|
|
104
|
+
const target = r.target.text;
|
|
105
|
+
if (!READING_REDIRECTS.has(r.op) && target !== "/dev/null" && !(r.op === ">&" && /^\d+$/.test(target)))
|
|
106
|
+
return NOT(`the redirection \`${r.op} ${target}\` writes`);
|
|
107
|
+
if (/^\/dev\/(tcp|udp)\//.test(target))
|
|
108
|
+
return NOT("a redirection to a network pseudo-device");
|
|
109
|
+
}
|
|
110
|
+
const k = leadingAssignmentCount(seg.argv);
|
|
111
|
+
for (const w of seg.argv.slice(0, k)) {
|
|
112
|
+
const m = LITERAL_ASSIGNMENT.exec(w.raw);
|
|
113
|
+
if (m === null)
|
|
114
|
+
return NOT(`the assignment \`${w.raw}\` is not a literal value`);
|
|
115
|
+
if (!ENV_NAMES.has(m[1]))
|
|
116
|
+
return NOT(`the environment variable \`${m[1]}\` can alter what a program does`);
|
|
117
|
+
}
|
|
118
|
+
const run = seg.argv.slice(k);
|
|
119
|
+
if (run.length === 0)
|
|
120
|
+
return NOT("a bare assignment runs no program");
|
|
121
|
+
const words = peelBuiltinPrefix(run.map((w) => w.text));
|
|
122
|
+
const program = words[0];
|
|
123
|
+
if (run.some((w) => expansionKind(w) === "glob"))
|
|
124
|
+
return GLOB_PROGRAMS.has(program) ? READ_ONLY : NOT(`\`${program}\` with a glob argument — the glob may name anything`);
|
|
125
|
+
const structural = structuralReading(words);
|
|
126
|
+
if (structural !== null)
|
|
127
|
+
return structural ? READ_ONLY : NOT(`\`${program}\` with these arguments is not a read-only form`);
|
|
128
|
+
return tableReading(words, run);
|
|
129
|
+
}
|
|
130
|
+
function structuralReading(words) {
|
|
131
|
+
const program = words[0];
|
|
132
|
+
if (BARE_ONLY.has(program))
|
|
133
|
+
return words.length === 1;
|
|
134
|
+
for (const form of READ_ONLY_EXACT_FORMS)
|
|
135
|
+
if (words.length === form.length && words.every((w, i) => w === form[i]))
|
|
136
|
+
return true;
|
|
137
|
+
if (BARE_PROGRAMS.has(program))
|
|
138
|
+
return true;
|
|
139
|
+
for (const form of BARE_MULTIWORD)
|
|
140
|
+
if (words.length >= form.length && form.every((w, i) => words[i] === w))
|
|
141
|
+
return form[0] !== "docker" || !dockerRetargetDanger(words);
|
|
142
|
+
if (program === "echo")
|
|
143
|
+
return true;
|
|
144
|
+
if (program === "printf")
|
|
145
|
+
return printfReading(words);
|
|
146
|
+
if (program === "ls")
|
|
147
|
+
return true;
|
|
148
|
+
if (program === "cd")
|
|
149
|
+
return words.length <= 2;
|
|
150
|
+
if (program === "find") {
|
|
151
|
+
for (let i = 1; i < words.length; i++) {
|
|
152
|
+
const w = words[i];
|
|
153
|
+
if (FIND_ACTIONS.has(w))
|
|
154
|
+
return false;
|
|
155
|
+
if (FIND_VALUES.has(w) || FIND_NEWER_PRIMARY.test(w)) {
|
|
156
|
+
i++;
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return true;
|
|
161
|
+
}
|
|
162
|
+
if (program === "history")
|
|
163
|
+
return words.length === 1 || (words.length === 2 && /^\d+$/.test(words[1]));
|
|
164
|
+
if (program === "arch")
|
|
165
|
+
return words.length === 1 || (words.length === 2 && (words[1] === "-h" || words[1] === "--help"));
|
|
166
|
+
if (program === "ifconfig")
|
|
167
|
+
return words.length === 1 || (words.length === 2 && /^[a-zA-Z]/.test(words[1]));
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
function printfReading(words) {
|
|
171
|
+
if (words[1]?.startsWith("-") === true && words[1] !== "--")
|
|
172
|
+
return false;
|
|
173
|
+
const at = words[1] === "--" ? 2 : 1;
|
|
174
|
+
const format = words[at] ?? "";
|
|
175
|
+
if (format.includes("$"))
|
|
176
|
+
return false;
|
|
177
|
+
const stripped = format.replace(/%%/g, "");
|
|
178
|
+
if (PRINTF_ESCAPE.test(stripped) || /\\[uU]/.test(stripped))
|
|
179
|
+
return false;
|
|
180
|
+
if (PRINTF_NUMERIC.test(stripped) || /%[^%a-zA-Z]*\*/.test(stripped)) {
|
|
181
|
+
for (let i = at + 1; i < words.length; i++) {
|
|
182
|
+
const operand = words[i];
|
|
183
|
+
if (operand.includes("[") || operand.includes("`") || operand.includes("$(") || !NUMBER_LITERAL.test(operand))
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return true;
|
|
188
|
+
}
|
|
189
|
+
function tableReading(words, run) {
|
|
190
|
+
const program = words[0];
|
|
191
|
+
const hit = TABLE_ROWS.find((r) => words.length >= r.words.length && r.words.every((w, i) => words[i] === w));
|
|
192
|
+
if (hit !== undefined) {
|
|
193
|
+
const args = words.slice(hit.words.length);
|
|
194
|
+
for (const a of args) {
|
|
195
|
+
if (a.includes("$"))
|
|
196
|
+
return NOT(`\`${program}\`: an argument spells \`$\``);
|
|
197
|
+
if (a.includes("{") && (a.includes(",") || a.includes("..")))
|
|
198
|
+
return NOT(`\`${program}\`: a brace pattern argument`);
|
|
199
|
+
}
|
|
200
|
+
if (!flagWalk(args, hit.row, program))
|
|
201
|
+
return NOT(`\`${program}\`: an option outside its read-only set (or a malformed option value)`);
|
|
202
|
+
if (hit.row.wordsShape !== undefined ? !hit.row.wordsShape(args) : run.some((w) => w.raw.includes("`")))
|
|
203
|
+
return NOT(`\`${program}\`: arguments outside its read-only form`);
|
|
204
|
+
if (hit.row.wordsShape === undefined && (program === "rg" || program === "grep" || program === "egrep" || program === "fgrep") && run.some((w) => /[\n\r]/.test(w.raw)))
|
|
205
|
+
return NOT(`\`${program}\`: a line break inside an argument`);
|
|
206
|
+
if (hit.row.dangerous?.(args) === true)
|
|
207
|
+
return NOT(`\`${program}\`: an argument form upstream marks as writing, executing or resolving`);
|
|
208
|
+
return READ_ONLY;
|
|
209
|
+
}
|
|
210
|
+
if (program === "uniq" && words.slice(1).every((w, i, all) => /^-[a-zA-Z]+$/.test(w) || /^--[a-zA-Z-]+(=\S+)?$/.test(w) || (i > 0 && /^-[fsw]$/.test(all[i - 1]) && /^\d+$/.test(w))))
|
|
211
|
+
return gitOptionGuard(words);
|
|
212
|
+
if (program === "jq" && jqReading(run.slice(run.length - words.length + 1)))
|
|
213
|
+
return gitOptionGuard(words);
|
|
214
|
+
return NOT(`\`${program}\` is not a read-only program (or not in a read-only form)`);
|
|
215
|
+
}
|
|
216
|
+
function gitOptionGuard(words) {
|
|
217
|
+
if (words.includes("git") && words.some((w) => /^-c(=|$)|^--exec-path(=|$)|^--config-env(=|$)/.test(w)))
|
|
218
|
+
return NOT("a git configuration override on the line");
|
|
219
|
+
return READ_ONLY;
|
|
220
|
+
}
|
|
221
|
+
function jqReading(args) {
|
|
222
|
+
if (args.some((w) => JQ_DANGER_FLAG.test(w.raw) || JQ_DANGER_TEXT.test(w.raw)))
|
|
223
|
+
return false;
|
|
224
|
+
let i = 0;
|
|
225
|
+
while (i < args.length && (/^-[a-zA-Z]+$/.test(args[i].raw) || /^--[a-zA-Z-]+(=\S+)?$/.test(args[i].raw)))
|
|
226
|
+
i++;
|
|
227
|
+
if (i >= args.length)
|
|
228
|
+
return false;
|
|
229
|
+
for (; i < args.length; i++) {
|
|
230
|
+
const w = args[i];
|
|
231
|
+
const quoted = /^'[^'`]*'$|^"[^"`]*"$/.test(w.raw);
|
|
232
|
+
if (!quoted && (/^[-'"\s]/.test(w.raw) || /['"]/.test(w.raw)))
|
|
233
|
+
return false;
|
|
234
|
+
}
|
|
235
|
+
return true;
|
|
236
|
+
}
|
|
237
|
+
function flagWalk(args, row, program) {
|
|
238
|
+
let i = 0;
|
|
239
|
+
while (i < args.length) {
|
|
240
|
+
let word = args[i];
|
|
241
|
+
if (word === "") {
|
|
242
|
+
i++;
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
if (program === "xargs" && (!word.startsWith("-") || word === "--")) {
|
|
246
|
+
if (word === "--" && i + 1 < args.length) {
|
|
247
|
+
i++;
|
|
248
|
+
word = args[i];
|
|
249
|
+
}
|
|
250
|
+
return XARGS_TARGETS.includes(word);
|
|
251
|
+
}
|
|
252
|
+
if (word === "--") {
|
|
253
|
+
if (row.respectsDoubleDash !== false)
|
|
254
|
+
break;
|
|
255
|
+
i++;
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
if (FLAG_WORD.test(word)) {
|
|
259
|
+
const eq = word.indexOf("=");
|
|
260
|
+
const flag = eq === -1 ? word : word.slice(0, eq);
|
|
261
|
+
const attached = eq === -1 ? undefined : word.slice(eq + 1);
|
|
262
|
+
const arity = row.safeFlags[flag];
|
|
263
|
+
if (arity === undefined) {
|
|
264
|
+
if (program === "git" && /^-\d+$/.test(flag)) {
|
|
265
|
+
i++;
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
if ((program === "grep" || program === "egrep" || program === "fgrep" || program === "rg") && !flag.startsWith("--") && flag.length > 2) {
|
|
269
|
+
const base = flag.slice(0, 2);
|
|
270
|
+
const rest = flag.slice(2);
|
|
271
|
+
const baseArity = row.safeFlags[base];
|
|
272
|
+
if (baseArity !== undefined && /^\d+$/.test(rest) && (baseArity === "number" || baseArity === "string")) {
|
|
273
|
+
if (FLAG_VALUE_ACCEPTS[baseArity](rest)) {
|
|
274
|
+
i++;
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
return false;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
if (!flag.startsWith("--") && flag.length > 2) {
|
|
281
|
+
for (let c = 1; c < flag.length; c++)
|
|
282
|
+
if (row.safeFlags[`-${flag[c]}`] !== "none")
|
|
283
|
+
return false;
|
|
284
|
+
i++;
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
return false;
|
|
288
|
+
}
|
|
289
|
+
if (arity === "none") {
|
|
290
|
+
if (attached !== undefined)
|
|
291
|
+
return false;
|
|
292
|
+
i++;
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
let value;
|
|
296
|
+
if (attached !== undefined) {
|
|
297
|
+
value = attached;
|
|
298
|
+
i++;
|
|
299
|
+
}
|
|
300
|
+
else {
|
|
301
|
+
const next = args[i + 1];
|
|
302
|
+
if (next === undefined || FLAG_WORD.test(next))
|
|
303
|
+
return false;
|
|
304
|
+
value = next;
|
|
305
|
+
i += 2;
|
|
306
|
+
}
|
|
307
|
+
if (arity === "string" && value.startsWith("-") && !(flag === "--sort" && program === "git" && /^-[a-zA-Z]/.test(value)))
|
|
308
|
+
return false;
|
|
309
|
+
if (!FLAG_VALUE_ACCEPTS[arity](value))
|
|
310
|
+
return false;
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
i++;
|
|
314
|
+
}
|
|
315
|
+
return true;
|
|
316
|
+
}
|
package/dist/core/roles.d.ts
CHANGED
|
@@ -104,8 +104,9 @@ export interface ResolvedRole {
|
|
|
104
104
|
* `modelRole` and NO run model: it runs on the deployment's roles alone — the boot default at the end of
|
|
105
105
|
* its chain — and never follows the model a run was switched to. Only a run's CHILDREN follow the parent:
|
|
106
106
|
* the delegation seat (`childModelSeat`) passes the parent's `Model` as `spec.model` with the role kept
|
|
107
|
-
* on `modelRole
|
|
108
|
-
*
|
|
107
|
+
* on `modelRole` — and keeps that role on an EXPLICIT-model seat too, so the explicit branch below reads
|
|
108
|
+
* the child's presets off the subagent role, not the default one. A side channel that must follow a run
|
|
109
|
+
* passes that run's model explicitly; nothing here infers it.
|
|
109
110
|
*/
|
|
110
111
|
export declare function resolveTaskModel(spec: {
|
|
111
112
|
model?: ModelRef;
|
|
@@ -218,6 +218,11 @@ export interface GatePass {
|
|
|
218
218
|
* touch. Recorded by the gate rather than read off `decisionReason` because that member is a policy's to
|
|
219
219
|
* compose (a policy stamping `"safety"` on its own ask must read as `policy`). Writer: the fold. */
|
|
220
220
|
tightenedBy: AskOriginFacts["tightened"];
|
|
221
|
+
/** borrowed-mutable — the reversibility probe was consulted on this pass and DID NOT ANSWER (it threw or
|
|
222
|
+
* timed out). The out-of-root mandate has no other source than the probe's answer, so an unanswered probe
|
|
223
|
+
* leaves the call's boundary UNKNOWN: the allow layer (a stored allow rule, the read-only reader) must
|
|
224
|
+
* not clear the surviving ask — a person confirms it. Writer: the fold. Reader: the lanes' mandate. */
|
|
225
|
+
probeUnanswered?: true;
|
|
221
226
|
/** borrowed-mutable — the rewrite remembered BEFORE a hook-ask promotion / tighten, re-applied on the
|
|
222
227
|
* approved path (a redaction is not cancelled because a person confirmed). Writer: the fold. Readers: the
|
|
223
228
|
* fold's probe and write-protection judges, the lanes, the allow exit. */
|
|
@@ -7,7 +7,7 @@ import { isSelfOrchestrationActive } from "../../orchestration/workflow-script-r
|
|
|
7
7
|
import { resolveWorkflowSizeGuideline } from "../../orchestration/workflow-size-guideline.js";
|
|
8
8
|
import { thinkingOffExpressible } from "../../brain/reasoning.js";
|
|
9
9
|
import { autoModeArmingRecipeOf } from "../auto-mode-arming.js";
|
|
10
|
-
import { AUTO_MODE_CLASSIFIER_MAX_TOKENS } from "../auto-mode-defaults.js";
|
|
10
|
+
import { AUTO_MODE_CLASSIFIER_MAX_TOKENS, AUTO_MODE_CLASSIFIER_THINKING_ALLOWANCE_TOKENS } from "../auto-mode-defaults.js";
|
|
11
11
|
import { buildAutoModePrompt, renderAutoModeAction, renderAutoModeWindow } from "../auto-mode-prompt.js";
|
|
12
12
|
import { createAutoModeDecider, createAutoModeDenialTracker } from "../auto-mode.js";
|
|
13
13
|
import { resolveCheckpointStore } from "../checkpoint-store.js";
|
|
@@ -17,9 +17,20 @@ import { resolveTaskModel } from "../roles.js";
|
|
|
17
17
|
import { brainToRuntime } from "../runtime.js";
|
|
18
18
|
import { defaultTaskRegistry } from "../task-registry.js";
|
|
19
19
|
import { emitTrace } from "../trace.js";
|
|
20
|
-
import { defineTool, isDefineToolProduct } from "../tools.js";
|
|
20
|
+
import { defineTool, isDefineToolProduct, rebindDefineToolCtx } from "../tools.js";
|
|
21
21
|
import { derivedRouteFallsBack } from "./derived-route-fallback.js";
|
|
22
22
|
import { REPORT_FINDINGS_TOOL_NAME, createReportBlockedTool, createReportFindingsTool } from "./synthetic-tools.js";
|
|
23
|
+
function classifierReplyText(reply) {
|
|
24
|
+
return reply.content
|
|
25
|
+
.filter((c) => c.type === "text")
|
|
26
|
+
.map((c) => c.text)
|
|
27
|
+
.join("");
|
|
28
|
+
}
|
|
29
|
+
function classifierReplyEmptyAtCap(reply) {
|
|
30
|
+
if (classifierReplyText(reply) !== "")
|
|
31
|
+
return false;
|
|
32
|
+
return reply.stopReason === "length" || (reply.stopReason === "error" && reply.errorKind === "length_empty");
|
|
33
|
+
}
|
|
23
34
|
import { RosterBuilder, callerMountSource } from "../tool-roster.js";
|
|
24
35
|
function assembleParentCaptureState(o, i, ctl, ancestors) {
|
|
25
36
|
const build = (optedOut, indeterminate) => ({
|
|
@@ -72,9 +83,8 @@ export async function prepareCapsAndWorkflow(input) {
|
|
|
72
83
|
roster.mount(mounted, { source: callerMountSource(mounted), mountedBy: "scenario" });
|
|
73
84
|
}
|
|
74
85
|
function mountCallerTool(t) {
|
|
75
|
-
if (isDefineToolProduct(t))
|
|
76
|
-
return maybeOffload(t, t);
|
|
77
|
-
}
|
|
86
|
+
if (isDefineToolProduct(t))
|
|
87
|
+
return maybeOffload(rebindDefineToolCtx(t, enrichSpecToolCtx), t);
|
|
78
88
|
return maybeOffload(defineTool({
|
|
79
89
|
...t,
|
|
80
90
|
execute: (args, ctx) => t.execute(args, enrichSpecToolCtx(ctx)),
|
|
@@ -191,17 +201,18 @@ export async function prepareCapsAndWorkflow(input) {
|
|
|
191
201
|
(m.role === "user" || m.role === "assistant" || m.role === "toolResult"));
|
|
192
202
|
const userPrompt = renderAutoModeWindow(known, am.window) + renderAutoModeAction(input);
|
|
193
203
|
const classifierAuth = await spec.getApiKeyAndHeaders?.(classifierModel);
|
|
194
|
-
const
|
|
204
|
+
const request = (cap) => classifierRuntime.completeSimple(classifierModel, { systemPrompt: classifierSystemPrompt, messages: [{ role: "user", content: userPrompt, timestamp: Date.now() }] }, {
|
|
195
205
|
signal,
|
|
196
206
|
reasoning: "off",
|
|
197
|
-
...
|
|
207
|
+
...cap,
|
|
198
208
|
...(classifierAuth?.apiKey !== undefined ? { apiKey: classifierAuth.apiKey } : {}),
|
|
199
209
|
...(classifierAuth?.headers !== undefined ? { headers: classifierAuth.headers } : {}),
|
|
200
210
|
});
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
211
|
+
let response = await request(classifierCap);
|
|
212
|
+
if (classifierCap.maxTokens !== undefined && classifierReplyEmptyAtCap(response)) {
|
|
213
|
+
response = await request({ maxTokens: classifierCap.maxTokens + AUTO_MODE_CLASSIFIER_THINKING_ALLOWANCE_TOKENS });
|
|
214
|
+
}
|
|
215
|
+
return classifierReplyText(response);
|
|
205
216
|
},
|
|
206
217
|
});
|
|
207
218
|
if (am.persistArming === true)
|
|
@@ -139,6 +139,15 @@ export function prepareGateStations(input) {
|
|
|
139
139
|
},
|
|
140
140
|
}
|
|
141
141
|
: {}),
|
|
142
|
+
onReadOnlyAllowed: (info) => emitTrace(deps.tracer, () => ({
|
|
143
|
+
kind: "permission.read_only_allowed",
|
|
144
|
+
version: 1,
|
|
145
|
+
taskId: hostTaskId,
|
|
146
|
+
toolName: info.toolName,
|
|
147
|
+
toolCallId: info.toolCallId,
|
|
148
|
+
command: info.command,
|
|
149
|
+
ts: Date.now(),
|
|
150
|
+
})),
|
|
142
151
|
...(permissionRuleOrgLane
|
|
143
152
|
? {
|
|
144
153
|
orgRules: {
|
|
@@ -634,7 +634,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
|
|
|
634
634
|
session,
|
|
635
635
|
tools: [...harnessTools],
|
|
636
636
|
model,
|
|
637
|
-
thinkingLevel: thinking
|
|
637
|
+
thinkingLevel: thinking,
|
|
638
638
|
systemPrompt: systemPromptSeat.current,
|
|
639
639
|
...(systemBlocks ? { systemBlocks } : {}),
|
|
640
640
|
getApiKeyAndHeaders: spec.getApiKeyAndHeaders,
|
|
@@ -26,7 +26,7 @@ export function prepareTurnWiring(input) {
|
|
|
26
26
|
layoutGroups: (systemBlocks ?? [{ groupId: "body", cacheControlBoundary: true }]).map((b) => ({ groupId: b.groupId, cacheControlBoundary: b.cacheControlBoundary })),
|
|
27
27
|
stableSystemText: systemPromptSeat.current,
|
|
28
28
|
toolWire: toolsToFingerprintInputs(harnessTools),
|
|
29
|
-
thinkingLevel:
|
|
29
|
+
thinkingLevel: thinking ?? null,
|
|
30
30
|
maxTokens: model.maxTokens,
|
|
31
31
|
hasOutputSchema: spec.outputSchema !== undefined,
|
|
32
32
|
sessionId,
|
|
@@ -9,10 +9,20 @@ export interface ShellWord {
|
|
|
9
9
|
* yield zero, one or several words. */
|
|
10
10
|
readonly expands: false | "one" | "many";
|
|
11
11
|
}
|
|
12
|
+
/** One redirection of a segment, as read: the operator and its operand word (a heredoc's operand is its
|
|
13
|
+
* delimiter; the IO number or `{name}` before the operator is folded into the operator's meaning and not
|
|
14
|
+
* kept). The tightening reader never reads these — a rule speaks about program runs; the read-only
|
|
15
|
+
* reader does (an output redirection is a write). */
|
|
16
|
+
export interface ShellRedirection {
|
|
17
|
+
readonly op: string;
|
|
18
|
+
readonly target: ShellWord;
|
|
19
|
+
}
|
|
12
20
|
/** One program run the lexer found, after keyword stripping. */
|
|
13
21
|
export interface ShellSegment {
|
|
14
22
|
/** The run as SPELLED (a leading keyword removed): the first candidate a rule is compared against. */
|
|
15
23
|
readonly argv: readonly ShellWord[];
|
|
24
|
+
/** The segment's redirections in source order (see {@link ShellRedirection}); empty when it has none. */
|
|
25
|
+
readonly redirections: readonly ShellRedirection[];
|
|
16
26
|
/** The deeper candidates, one per peeled layer (leading assignments, then each wrapper of the table):
|
|
17
27
|
* `sudo -u root rm -r x` ⇒ `[[rm, -r, x]]`. Empty when nothing peeled. */
|
|
18
28
|
readonly peeled: readonly (readonly ShellWord[])[];
|
|
@@ -34,6 +44,14 @@ export interface ShellCommandShape {
|
|
|
34
44
|
/** A subshell, group, control-structure keyword or dropped empty piece bounded the segments: the
|
|
35
45
|
* connector list is not one flat chain a compound rule could spell. */
|
|
36
46
|
readonly grouped: boolean;
|
|
47
|
+
/** A `&` connector anywhere — between two segments or after the last one (`ls &`): some segment runs in the
|
|
48
|
+
* background, past any approval-time reading of the line. The connector list alone cannot say so for a
|
|
49
|
+
* trailing `&` (a connector rides BETWEEN segments). */
|
|
50
|
+
readonly backgrounded: boolean;
|
|
51
|
+
/** A redirection on a piece that runs NO program (`> x` alone, `[ -f a ] > x`, a keyword's own redirection):
|
|
52
|
+
* such a piece is dropped from `segments` (the tightening reader asks about program runs, and it runs
|
|
53
|
+
* none) — but the shell still opens the target, so a reader that asks about EFFECTS must refuse the line. */
|
|
54
|
+
readonly strayRedirection: boolean;
|
|
37
55
|
}
|
|
38
56
|
export { SHELL_WRAPPER_TABLE, type ShellWrapperName } from "./shell-wrapper-table.js";
|
|
39
57
|
/** Longer than this and the command is not read at all (one unreadable segment): every pass is linear,
|
package/dist/core/shell-lexer.js
CHANGED
|
@@ -13,11 +13,13 @@ const ALL_OPS = [...CONNECTOR_OPS, ...REDIRECT_OPS];
|
|
|
13
13
|
export const MAX_SHELL_READ_CHARS = 10_000;
|
|
14
14
|
export function readShellCommand(source) {
|
|
15
15
|
if (source.length > MAX_SHELL_READ_CHARS)
|
|
16
|
-
return { segments: [{ argv: [], peeled: [], unreadable: `the command is longer than ${MAX_SHELL_READ_CHARS} characters — not read` }], connectors: [], grouped: false };
|
|
16
|
+
return { segments: [{ argv: [], redirections: [], peeled: [], unreadable: `the command is longer than ${MAX_SHELL_READ_CHARS} characters — not read` }], connectors: [], grouped: false, backgrounded: false, strayRedirection: false };
|
|
17
17
|
const segments = [];
|
|
18
18
|
const connectors = [];
|
|
19
19
|
let grouped = false;
|
|
20
|
-
let
|
|
20
|
+
let backgrounded = false;
|
|
21
|
+
let strayRedirection = false;
|
|
22
|
+
let seg = { words: [], redirections: [] };
|
|
21
23
|
let word = freshWord();
|
|
22
24
|
let pendingRedirect;
|
|
23
25
|
let heredocs = [];
|
|
@@ -40,6 +42,7 @@ export function readShellCommand(source) {
|
|
|
40
42
|
badDelimiter = true;
|
|
41
43
|
heredocs.push({ delimiter: word.text, quoted: word.raw !== word.text, dash: pendingRedirect === "<<-" });
|
|
42
44
|
}
|
|
45
|
+
seg.redirections.push({ op: pendingRedirect, target: publicWord(word) });
|
|
43
46
|
pendingRedirect = undefined;
|
|
44
47
|
}
|
|
45
48
|
else {
|
|
@@ -56,17 +59,19 @@ export function readShellCommand(source) {
|
|
|
56
59
|
let built = buildSegment(seg);
|
|
57
60
|
if (built === "keyworded")
|
|
58
61
|
grouped = true;
|
|
62
|
+
if ((built === "empty" || built === "keyworded") && seg.redirections.length > 0)
|
|
63
|
+
strayRedirection = true;
|
|
59
64
|
if (built === "empty" || built === "keyworded") {
|
|
60
65
|
const continuation = connector === "\n" && lastConnector !== undefined && lastConnector !== ";" && lastConnector !== "&" && lastConnector !== "\n";
|
|
61
66
|
const legal = built === "keyworded" || afterBoundary || connector === undefined || connector === "\n" || lastConnector === "\n";
|
|
62
67
|
if (!legal)
|
|
63
|
-
built = { argv: [], peeled: [], unreadable: `an empty command before \`${connector}\` — a syntax error` };
|
|
68
|
+
built = { argv: [], redirections: seg.redirections, peeled: [], unreadable: `an empty command before \`${connector}\` — a syntax error` };
|
|
64
69
|
else {
|
|
65
70
|
if (connector !== undefined && connector !== "\n" && segments.length > 0)
|
|
66
71
|
grouped = true;
|
|
67
72
|
if (connector !== undefined && !continuation)
|
|
68
73
|
lastConnector = connector;
|
|
69
|
-
seg = { words: [] };
|
|
74
|
+
seg = { words: [], redirections: [] };
|
|
70
75
|
afterBoundary = false;
|
|
71
76
|
return;
|
|
72
77
|
}
|
|
@@ -76,7 +81,7 @@ export function readShellCommand(source) {
|
|
|
76
81
|
segments.push(built);
|
|
77
82
|
if (connector !== undefined)
|
|
78
83
|
lastConnector = connector;
|
|
79
|
-
seg = { words: [] };
|
|
84
|
+
seg = { words: [], redirections: [] };
|
|
80
85
|
afterBoundary = false;
|
|
81
86
|
};
|
|
82
87
|
const boundary = () => { finishSegment(undefined); grouped = true; afterBoundary = true; lastConnector = undefined; };
|
|
@@ -187,6 +192,8 @@ export function readShellCommand(source) {
|
|
|
187
192
|
i += op.length;
|
|
188
193
|
continue;
|
|
189
194
|
}
|
|
195
|
+
if (op === "&")
|
|
196
|
+
backgrounded = true;
|
|
190
197
|
finishSegment(op);
|
|
191
198
|
i += op.length;
|
|
192
199
|
continue;
|
|
@@ -271,7 +278,7 @@ export function readShellCommand(source) {
|
|
|
271
278
|
seg.unreadable ??= `the connector \`${lastConnector}\` has no command after it — a syntax error`;
|
|
272
279
|
finishSegment(undefined);
|
|
273
280
|
}
|
|
274
|
-
return { segments, connectors, grouped };
|
|
281
|
+
return { segments, connectors, grouped, backgrounded, strayRedirection };
|
|
275
282
|
}
|
|
276
283
|
const publicWord = ({ text, raw, expands }) => ({ text, raw, expands });
|
|
277
284
|
const SUBSTITUTION_UNREAD = "a command substitution runs a program this reader does not read";
|
|
@@ -281,8 +288,8 @@ export function isFullyReadable(shape) {
|
|
|
281
288
|
}
|
|
282
289
|
function buildSegment(seg) {
|
|
283
290
|
if (seg.unreadable !== undefined)
|
|
284
|
-
return { argv: seg.words.map(publicWord), peeled: [], unreadable: seg.unreadable };
|
|
285
|
-
const carried = () => (seg.substitution === true ? { argv: [], peeled: [], peelUnreadable: SUBSTITUTION_UNREAD } : keyworded ? "keyworded" : "empty");
|
|
291
|
+
return { argv: seg.words.map(publicWord), redirections: seg.redirections, peeled: [], unreadable: seg.unreadable };
|
|
292
|
+
const carried = () => (seg.substitution === true ? { argv: [], redirections: seg.redirections, peeled: [], peelUnreadable: SUBSTITUTION_UNREAD } : keyworded ? "keyworded" : "empty");
|
|
286
293
|
let keyworded = false;
|
|
287
294
|
let k0 = 0;
|
|
288
295
|
while (k0 < seg.words.length && seg.words[k0].expands === false && (PREFIX_KEYWORDS.has(seg.words[k0].text) || CLOSING_KEYWORDS.has(seg.words[k0].text))) {
|
|
@@ -298,7 +305,7 @@ function buildSegment(seg) {
|
|
|
298
305
|
if (head.expands === false && (head.text === "[[" || head.text === "["))
|
|
299
306
|
return carried();
|
|
300
307
|
if (head.expands === false && OPAQUE_KEYWORDS.has(head.text))
|
|
301
|
-
return { argv: words.map(publicWord), peeled: [], unreadable: `a \`${head.text}\` control structure — its commands need a syntax tree` };
|
|
308
|
+
return { argv: words.map(publicWord), redirections: seg.redirections, peeled: [], unreadable: `a \`${head.text}\` control structure — its commands need a syntax tree` };
|
|
302
309
|
const argv = words.map(publicWord);
|
|
303
310
|
const peeled = [];
|
|
304
311
|
let peelUnreadable;
|
|
@@ -338,7 +345,7 @@ function buildSegment(seg) {
|
|
|
338
345
|
}
|
|
339
346
|
if (seg.substitution === true || [argv, ...peeled].some((c) => c[0]?.expands === false && evaluatesOperand(c)))
|
|
340
347
|
peelUnreadable ??= SUBSTITUTION_UNREAD;
|
|
341
|
-
return { argv, peeled, ...(peelUnreadable !== undefined ? { peelUnreadable } : {}) };
|
|
348
|
+
return { argv, redirections: seg.redirections, peeled, ...(peelUnreadable !== undefined ? { peelUnreadable } : {}) };
|
|
342
349
|
}
|
|
343
350
|
function unwrap(words) {
|
|
344
351
|
const head = words[0];
|
|
@@ -50,9 +50,12 @@ export function evaluatesOperand(run) {
|
|
|
50
50
|
return run.some((w) => namesArrayElement(w.text) || namesArrayElement(w.raw) || (integer && /^[A-Za-z_][A-Za-z0-9_]*\+?=.*[A-Za-z_$]/.test(w.text)));
|
|
51
51
|
}
|
|
52
52
|
function namesArrayElement(word) {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
53
|
+
for (let open = word.indexOf("["); open > 0; open = word.indexOf("[", open + 1)) {
|
|
54
|
+
if (!/[A-Za-z0-9_]/.test(word[open - 1]))
|
|
55
|
+
continue;
|
|
56
|
+
const close = word.indexOf("]", open);
|
|
57
|
+
if (close > 0 && /[A-Za-z_$]/.test(word.slice(open + 1, close)))
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
return false;
|
|
58
61
|
}
|
|
@@ -99,7 +99,10 @@ export interface ToolCallRequest {
|
|
|
99
99
|
* - `"org_unavailable"` — an org-governed deployment could not adjudicate against a snapshot, so the
|
|
100
100
|
* whole decision boundary failed closed (see `ORG_UNAVAILABLE_DECISION_REASON`, the single
|
|
101
101
|
* spelling this word is minted from). */
|
|
102
|
-
|
|
102
|
+
/** `read_only` (#619): the shell tool's command was PROVABLY read-only by the engine's transcribed upstream
|
|
103
|
+
* tables (`readOnlyShellVerdict`) and the allow layer let it run without a question — after every
|
|
104
|
+
* deny/ask lane and the person's own allow rules, never over a mandated ask. */
|
|
105
|
+
declare const DECISION_REASONS: readonly ["rule", "mode", "hook", "safety", "classifier", "persisted_rule", "sandbox", "org_rule", "org_unavailable", "read_only"];
|
|
103
106
|
export type DecisionReason = (typeof DECISION_REASONS)[number];
|
|
104
107
|
/** Read the engine-attested settlement off a funneled decision FOR the named call (the gate's exit is the
|
|
105
108
|
* one consumer): an attestation bound to a different call is a replayed object and answers absence.
|
package/dist/core/tool-policy.js
CHANGED
|
@@ -10,7 +10,7 @@ import { delimitUntrusted, inlineUntrusted, REVIEWER_NOTE_MAX_BODY } from "./unt
|
|
|
10
10
|
import { isNamespacedCoveringRuleName, namespacedRuleNameCovers, parsePermissionRule } from "./permission-rules.js";
|
|
11
11
|
import { protocolOf } from "./protocol-table.js";
|
|
12
12
|
import { isAbsolutePathForm, isWinFormPath, writeTargetPath } from "../tools/fs/safety.js";
|
|
13
|
-
const DECISION_REASONS = ["rule", "mode", "hook", "safety", "classifier", "persisted_rule", "sandbox", "org_rule", "org_unavailable"];
|
|
13
|
+
const DECISION_REASONS = ["rule", "mode", "hook", "safety", "classifier", "persisted_rule", "sandbox", "org_rule", "org_unavailable", "read_only"];
|
|
14
14
|
const DECISION_REASON_SET = new Set(DECISION_REASONS);
|
|
15
15
|
function settledByNobody(kind) {
|
|
16
16
|
return { kind, who: { party: "none" }, when: Date.now() };
|