@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.
Files changed (49) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/dist/agents/child-model-seat.d.ts +45 -18
  3. package/dist/agents/child-model-seat.js +12 -8
  4. package/dist/agents/subagent.js +6 -5
  5. package/dist/agents/teacher.js +2 -2
  6. package/dist/core/auto-mode-defaults.d.ts +15 -3
  7. package/dist/core/auto-mode-defaults.js +1 -0
  8. package/dist/core/auto-mode.d.ts +24 -20
  9. package/dist/core/auto-mode.js +12 -12
  10. package/dist/core/gate-fold.js +1 -0
  11. package/dist/core/gate-lanes.d.ts +6 -1
  12. package/dist/core/gate-lanes.js +45 -18
  13. package/dist/core/hooks.d.ts +13 -0
  14. package/dist/core/permission-rule-model.d.ts +5 -3
  15. package/dist/core/permission-rule-model.js +7 -3
  16. package/dist/core/persisted-rule-arms.js +4 -3
  17. package/dist/core/read-only-shell-table.d.ts +87 -0
  18. package/dist/core/read-only-shell-table.js +485 -0
  19. package/dist/core/read-only-shell.d.ts +42 -0
  20. package/dist/core/read-only-shell.js +316 -0
  21. package/dist/core/roles.d.ts +3 -2
  22. package/dist/core/runner/gate-exit.d.ts +5 -0
  23. package/dist/core/runner/prepare-caps-and-workflow.js +22 -11
  24. package/dist/core/runner/prepare-gate-stations.js +9 -0
  25. package/dist/core/runner/prepare-task.js +1 -1
  26. package/dist/core/runner/prepare-turn-wiring.js +1 -1
  27. package/dist/core/shell-lexer.d.ts +18 -0
  28. package/dist/core/shell-lexer.js +17 -10
  29. package/dist/core/shell-wrapper-table.js +8 -5
  30. package/dist/core/tool-policy.d.ts +4 -1
  31. package/dist/core/tool-policy.js +1 -1
  32. package/dist/core/tools.d.ts +28 -7
  33. package/dist/core/tools.js +44 -4
  34. package/dist/core/trace.d.ts +15 -0
  35. package/dist/engine/harness/agent-harness.d.ts +3 -1
  36. package/dist/engine/harness/agent-harness.js +1 -1
  37. package/dist/engine/harness/types.d.ts +4 -2
  38. package/dist/index.d.ts +3 -1
  39. package/dist/index.js +2 -0
  40. package/dist/orchestration/run-workflow-tool.d.ts +5 -2
  41. package/dist/orchestration/run-workflow-tool.js +2 -1
  42. package/dist/orchestration/workflow-governance.d.ts +3 -2
  43. package/dist/orchestration/workflow-primitives.d.ts +4 -1
  44. package/dist/orchestration/workflow-primitives.js +1 -6
  45. package/dist/orchestration/workflow.d.ts +12 -4
  46. package/dist/orchestration/workflow.js +24 -7
  47. package/dist/prompt-assembly/turn-snapshot.d.ts +4 -2
  48. package/package.json +1 -1
  49. 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
+ }
@@ -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`, so the role's presets still apply. A side channel that must follow a run passes that
108
- * run's model explicitly; nothing here infers it.
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 response = await classifierRuntime.completeSimple(classifierModel, { systemPrompt: classifierSystemPrompt, messages: [{ role: "user", content: userPrompt, timestamp: Date.now() }] }, {
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
- ...classifierCap,
207
+ ...cap,
198
208
  ...(classifierAuth?.apiKey !== undefined ? { apiKey: classifierAuth.apiKey } : {}),
199
209
  ...(classifierAuth?.headers !== undefined ? { headers: classifierAuth.headers } : {}),
200
210
  });
201
- return response.content
202
- .filter((c) => c.type === "text")
203
- .map((c) => c.text)
204
- .join("");
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 ?? "off",
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: (thinking ?? "off"),
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,
@@ -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 seg = { words: [] };
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
- const open = word.indexOf("[");
54
- if (open <= 0 || !/[A-Za-z0-9_]/.test(word[open - 1]))
55
- return false;
56
- const close = word.indexOf("]", open);
57
- return close > 0 && /[A-Za-z_$]/.test(word.slice(open + 1, close));
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
- declare const DECISION_REASONS: readonly ["rule", "mode", "hook", "safety", "classifier", "persisted_rule", "sandbox", "org_rule", "org_unavailable"];
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.
@@ -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() };