@sema-agent/core 7.1.0 → 7.3.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 (64) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/dist/agents/cross-session-envelope.d.ts +145 -0
  3. package/dist/agents/cross-session-envelope.js +195 -0
  4. package/dist/agents/cross-session-judge.d.ts +119 -0
  5. package/dist/agents/cross-session-judge.js +184 -0
  6. package/dist/agents/cross-session-ref.d.ts +52 -0
  7. package/dist/agents/cross-session-ref.js +64 -0
  8. package/dist/agents/list-agents-tool.d.ts +55 -0
  9. package/dist/agents/list-agents-tool.js +94 -0
  10. package/dist/agents/peer-admission.d.ts +17 -1
  11. package/dist/agents/peer-admission.js +19 -2
  12. package/dist/agents/peer-directory.d.ts +208 -0
  13. package/dist/agents/peer-directory.js +272 -0
  14. package/dist/agents/peer-session-drain.d.ts +159 -0
  15. package/dist/agents/peer-session-drain.js +245 -0
  16. package/dist/agents/send-message-tool.d.ts +44 -0
  17. package/dist/agents/send-message-tool.js +181 -16
  18. package/dist/agents/subagent-steps.d.ts +11 -0
  19. package/dist/agents/subagent-steps.js +27 -4
  20. package/dist/core/auto-mode-arming.d.ts +11 -0
  21. package/dist/core/auto-mode-arming.js +7 -1
  22. package/dist/core/auto-mode-prompt.d.ts +5 -0
  23. package/dist/core/auto-mode-prompt.js +2 -1
  24. package/dist/core/auto-mode-rebuild.d.ts +2 -1
  25. package/dist/core/auto-mode-rebuild.js +2 -0
  26. package/dist/core/checkpoint-store.d.ts +203 -3
  27. package/dist/core/checkpoint-store.js +60 -19
  28. package/dist/core/governance-codes.d.ts +1 -1
  29. package/dist/core/governance-codes.js +6 -0
  30. package/dist/core/hooks.d.ts +15 -8
  31. package/dist/core/hooks.js +6 -3
  32. package/dist/core/mailbox-store.d.ts +89 -2
  33. package/dist/core/mailbox-store.js +77 -2
  34. package/dist/core/permission-rule-consent.d.ts +72 -23
  35. package/dist/core/permission-rule-consent.js +115 -26
  36. package/dist/core/permission-rule-model.d.ts +254 -51
  37. package/dist/core/permission-rule-model.js +316 -55
  38. package/dist/core/permission-rule-org.js +13 -6
  39. package/dist/core/remote-env.d.ts +8 -1
  40. package/dist/core/runner/assemble-result.js +2 -1
  41. package/dist/core/runner/prepare-task.d.ts +59 -1
  42. package/dist/core/runner/prepare-task.js +414 -149
  43. package/dist/core/runner/prepare-workspace-restore.d.ts +6 -1
  44. package/dist/core/runner/prepare-workspace-restore.js +2 -1
  45. package/dist/core/runner/runtask.js +16 -5
  46. package/dist/core/runner/tool-output-projection.js +1 -0
  47. package/dist/core/store-contracts/mailbox-store-contract.d.ts +23 -0
  48. package/dist/core/store-contracts/mailbox-store-contract.js +157 -1
  49. package/dist/core/task-notification.d.ts +93 -5
  50. package/dist/core/task-notification.js +31 -4
  51. package/dist/core/tool-policy.d.ts +11 -0
  52. package/dist/core/types.d.ts +155 -21
  53. package/dist/core/untrusted-text.js +17 -1
  54. package/dist/core/wiring-manifest.d.ts +21 -0
  55. package/dist/core/wiring-manifest.js +1 -0
  56. package/dist/index.d.ts +14 -5
  57. package/dist/index.js +13 -4
  58. package/dist/stores/cc/mailbox-store.d.ts +1 -1
  59. package/dist/stores/cc/mailbox-store.js +13 -0
  60. package/dist/stores/file/adoption/marker.d.ts +1 -1
  61. package/dist/stores/file/mailbox-store.d.ts +57 -0
  62. package/dist/stores/file/mailbox-store.js +369 -18
  63. package/package.json +1 -1
  64. package/test/export-surface.snapshot.json +233 -1
@@ -1,4 +1,6 @@
1
+ import { homedir } from "node:os";
1
2
  import { parsePermissionRule } from "./permission-rules.js";
3
+ import { compileReadDeny } from "../tools/fs/read-deny.js";
2
4
  import { carriesShellRedirection, parseLeadingCommandName, splitShellCompoundSegments } from "../tools/fs/bash-readonly-classifier.js";
3
5
  import { inlineUntrusted } from "./untrusted-text.js";
4
6
  export const MAX_RULE_TEXT_CHARS = 512;
@@ -169,6 +171,9 @@ export function escapeForDisclosure(value) {
169
171
  return escaped.length <= DISCLOSED_RULE_TEXT_MAX_CHARS ? escaped : `${escaped.slice(0, DISCLOSED_RULE_TEXT_MAX_CHARS)}…`;
170
172
  }
171
173
  const DISPLAY_STRIP_FORMAT_RE = /\p{Cf}/gu;
174
+ export function stripFormatCharacters(text) {
175
+ return text.replace(DISPLAY_STRIP_FORMAT_RE, "");
176
+ }
172
177
  export function renderUntrustedCommandText(text, maxLen = DISCLOSED_RULE_TEXT_MAX_CHARS) {
173
178
  let raw;
174
179
  try {
@@ -177,7 +182,7 @@ export function renderUntrustedCommandText(text, maxLen = DISCLOSED_RULE_TEXT_MA
177
182
  catch {
178
183
  return "<unprintable>";
179
184
  }
180
- return inlineUntrusted(raw.replace(DISPLAY_STRIP_FORMAT_RE, ""), maxLen);
185
+ return inlineUntrusted(stripFormatCharacters(raw), maxLen);
181
186
  }
182
187
  export function hasUnrenderableCharacters(text) {
183
188
  return CONTROL_CHARS_RE.test(text);
@@ -193,15 +198,53 @@ export function parseAllowRuleText(text, opts) {
193
198
  if (parsed.ruleContent === undefined) {
194
199
  return reject("invalid.grammar", `"${text}" is not a Tool(content) rule — a bare tool name claims the whole tool and is not a command rule`);
195
200
  }
201
+ if (parsed.toolName === "Read") {
202
+ const content = parsed.ruleContent;
203
+ const m = /^\/\/(.+)\/\*\*$/.exec(content);
204
+ if (m === null) {
205
+ return reject("invalid.grammar", `only the Read(//abs-dir/**) directory form is a rule this version reads ("${text}") — relative, single-file, single-slash and no-tail spellings have no rule form`);
206
+ }
207
+ const bodyR = m[1];
208
+ if (bodyR.includes("*")) {
209
+ return reject("unsupported.wildcard", `a \`*\` inside the directory body matches anything at that position, which has no rule form ("${text}")`);
210
+ }
211
+ const segments = bodyR.split("/");
212
+ if (segments.some((s) => s === "" || s.trim() === "")) {
213
+ return reject("invalid.grammar", `the directory body of "${text}" is not in the canonical spelling (an empty or whitespace-only path segment) — the lexical normal form is the only accepted spelling`);
214
+ }
215
+ if (segments.some((s) => s === "." || s === "..")) {
216
+ return reject("invalid.path_traversal", `the directory body of "${text}" carries a \`.\` or \`..\` segment — the lexical normal form is the only accepted spelling, and a traversal segment is not normalized away`);
217
+ }
218
+ const dir = "/" + bodyR;
219
+ return { rule: { rule: formatAllowRuleText(dir, "subpath"), tool: "Read", match: "subpath", command: dir } };
220
+ }
196
221
  if (parsed.toolName !== "Bash") {
197
- return reject("unsupported.tool", `only Bash rules are supported in this version (got "${parsed.toolName}")`);
222
+ return reject("unsupported.tool", `only Bash and Read rules are supported in this version (got "${parsed.toolName}")`);
198
223
  }
199
224
  const content = parsed.ruleContent;
200
225
  const prefixBody = /^(.+):\*$/.exec(content)?.[1];
201
- const match = prefixBody !== undefined ? "prefix" : "exact";
202
- const body = prefixBody ?? content;
203
- if (body.includes("*")) {
204
- return reject("unsupported.wildcard", `wildcard rule forms are not supported in this version ("${text}")`);
226
+ let match;
227
+ let body;
228
+ if (prefixBody !== undefined) {
229
+ if (prefixBody.includes("*")) {
230
+ return reject("unsupported.wildcard", `a \`*\` inside a prefix body matches anything at that position, which has no rule form in this version ("${text}")`);
231
+ }
232
+ match = "prefix";
233
+ body = prefixBody;
234
+ }
235
+ else {
236
+ const wildcardBody = /^(.+) \*$/.exec(content)?.[1];
237
+ if (wildcardBody !== undefined && !hasUnescapedStar(wildcardBody)) {
238
+ match = "wildcard";
239
+ body = wildcardBody;
240
+ }
241
+ else if (hasUnescapedStar(content)) {
242
+ return reject("unsupported.wildcard", `a \`*\` before the end of the pattern matches anything at that position, which has no rule form in this version — only the trailing \`<command> *\` form does ("${text}")`);
243
+ }
244
+ else {
245
+ match = "exact";
246
+ body = content;
247
+ }
205
248
  }
206
249
  const shape = ruleLaneShapeOf(body, MATCH_READING);
207
250
  if ("reject" in shape) {
@@ -209,8 +252,11 @@ export function parseAllowRuleText(text, opts) {
209
252
  ? reject("invalid.empty_command", `rule "${text}" names no command`)
210
253
  : reject("invalid.not_simple_command", `rule "${text}" is not a command this lane can name (${shape.reject})`);
211
254
  }
255
+ if (match === "wildcard" && shape.segments.length > 1) {
256
+ return reject("unsupported.wildcard", `a \`<chain> *\` pattern names more than one command, and the trailing-star form has no chain reading — the compound-prefix form spells one with the \`:*\` marker ("${text}")`);
257
+ }
212
258
  const head = commandBasename(shape.names[shape.names.length - 1] ?? "");
213
- if (match === "prefix" && BARE_INTERPRETER_NAMES.has(head) && opts?.direction !== "tighten") {
259
+ if ((match === "prefix" || match === "wildcard") && BARE_INTERPRETER_NAMES.has(head) && opts?.direction !== "tighten") {
214
260
  return reject("invalid.bare_interpreter_prefix", shape.segments.length > 1
215
261
  ? `prefix rule "${text}" leaves its final segment open-ended under the interpreter "${head}" — such a rule authorizes running arbitrary programs, which one approval click cannot be read as having granted (an exact rule naming the whole command line is accepted)`
216
262
  : `prefix rule "${text}" is headed by the interpreter "${head}" — such a rule authorizes running arbitrary programs, which one approval click cannot be read as having granted (an exact rule naming the whole command line is accepted)`);
@@ -222,7 +268,9 @@ export function parseAllowRuleText(text, opts) {
222
268
  return { rule: { rule: formatAllowRuleText(command, match), tool: "Bash", match, command } };
223
269
  }
224
270
  export function formatAllowRuleText(command, match) {
225
- return `Bash(${command}${match === "prefix" ? ":*" : ""})`;
271
+ if (match === "subpath")
272
+ return `Read(//${command.startsWith("/") ? command.slice(1) : command}/**)`;
273
+ return `Bash(${command}${match === "prefix" ? ":*" : match === "wildcard" ? " *" : ""})`;
226
274
  }
227
275
  function hasUnescapedStar(s) {
228
276
  for (let i = 0; i < s.length; i++) {
@@ -236,33 +284,6 @@ function hasUnescapedStar(s) {
236
284
  }
237
285
  return false;
238
286
  }
239
- function isImportedWildcardContent(content) {
240
- return !content.endsWith(":*") && hasUnescapedStar(content);
241
- }
242
- export function translateImportedWildcardRule(text) {
243
- const parsed = parsePermissionRule(text);
244
- const content = parsed.ruleContent;
245
- if (content === undefined || parsed.toolName !== "Bash" || !isImportedWildcardContent(content))
246
- return undefined;
247
- const body = /^(.+) \*$/.exec(content)?.[1];
248
- if (body === undefined || body.trim() === "") {
249
- return {
250
- skip: "unsupported.wildcard: only the trailing `<command> *` form has a prefix-rule equivalent here — it stays in the settings file, unimported",
251
- };
252
- }
253
- if (hasUnescapedStar(body)) {
254
- return {
255
- skip: "unsupported.wildcard: a `*` before the end of the pattern matches anything at that position, which has no rule form in this version — it stays in the settings file, unimported",
256
- };
257
- }
258
- const bodyShape = ruleLaneShapeOf(body, MATCH_READING);
259
- if (!("reject" in bodyShape) && bodyShape.segments.length > 1) {
260
- return {
261
- skip: "unsupported.wildcard: a `<chain> *` pattern names more than one command, and the trailing-star form has no equivalent for a chain — it stays in the settings file, unimported",
262
- };
263
- }
264
- return { rule: formatAllowRuleText(body, "prefix") };
265
- }
266
287
  export function ruleAdmitsCommand(rule, command) {
267
288
  return admitsUnder(rule, command, MATCH_READING);
268
289
  }
@@ -270,6 +291,8 @@ export function ruleAdmitsProgramRun(rule, command) {
270
291
  return admitsUnder(rule, command, PROGRAM_RUNS_READING);
271
292
  }
272
293
  function admitsUnder(rule, command, reading) {
294
+ if (rule.match === "subpath")
295
+ return false;
273
296
  const shape = ruleLaneShapeOf(command, reading);
274
297
  if ("reject" in shape)
275
298
  return false;
@@ -285,6 +308,30 @@ function admitsUnder(rule, command, reading) {
285
308
  return false;
286
309
  return folded === rule.command || folded.startsWith(rule.command + " ");
287
310
  }
311
+ export function ruleBreadthWarningsOf(rule) {
312
+ if (rule.match !== "prefix" && rule.match !== "wildcard")
313
+ return [];
314
+ const bodyShape = ruleLaneShapeOf(rule.command, MATCH_READING);
315
+ if ("reject" in bodyShape)
316
+ return [];
317
+ if (bodyShape.segments.length > 1) {
318
+ return [
319
+ {
320
+ code: "compound_prefix",
321
+ message: `this rule's body is a command chain — it admits every run of the whole chain "${rule.command}", with any text extending its final segment`,
322
+ },
323
+ ];
324
+ }
325
+ if (rule.command.split(/\s+/).filter((t) => t !== "").length === 1) {
326
+ return [
327
+ {
328
+ code: "broad_prefix",
329
+ message: `this rule admits every argument form of "${rule.command}" — any \`${rule.command} …\` command, not just the one on this card`,
330
+ },
331
+ ];
332
+ }
333
+ return [];
334
+ }
288
335
  export function ruleLaneSegmentsOf(command) {
289
336
  const shape = ruleLaneShapeOf(command, PROGRAM_RUNS_READING);
290
337
  return "reject" in shape ? undefined : shape.segments;
@@ -295,6 +342,134 @@ export function pathWithinRoot(path, root) {
295
342
  const base = root.endsWith("/") ? root : root + "/";
296
343
  return path.startsWith(base);
297
344
  }
345
+ export function lexicalNormalAbsolutePathOf(path) {
346
+ if (typeof path !== "string" || !path.startsWith("/"))
347
+ return undefined;
348
+ const out = [];
349
+ for (const seg of path.split("/")) {
350
+ if (seg === "" || seg === ".")
351
+ continue;
352
+ if (seg === "..") {
353
+ if (out.length === 0)
354
+ return undefined;
355
+ out.pop();
356
+ continue;
357
+ }
358
+ out.push(seg);
359
+ }
360
+ return out.length === 0 ? "/" : "/" + out.join("/");
361
+ }
362
+ function isLexicalNormalAbsoluteDir(path) {
363
+ if (typeof path !== "string" || !path.startsWith("/") || path.startsWith("//"))
364
+ return false;
365
+ const segments = path.split("/").slice(1);
366
+ return segments.length > 0 && segments.every((s) => s !== "" && s !== "." && s !== "..");
367
+ }
368
+ export function directoryRuleAdmits(rule, path) {
369
+ if (rule.tool !== "Read" || rule.match !== "subpath")
370
+ return false;
371
+ if (!isLexicalNormalAbsoluteDir(rule.command))
372
+ return false;
373
+ if (!isLexicalNormalAbsoluteDir(path))
374
+ return false;
375
+ return pathWithinRoot(path, rule.command);
376
+ }
377
+ const CD_TARGET_REJECT_CHARS = /[*?[\]{}$`\\'"<>\n\r]/;
378
+ function cdSegmentDirectoryOf(segment, opts) {
379
+ if (/[^\S \t]/.test(segment))
380
+ return undefined;
381
+ const tokens = segment.split(/[ \t]+/).filter((t) => t !== "");
382
+ if (tokens.length !== 2 || tokens[0] !== "cd")
383
+ return undefined;
384
+ const target = tokens[1];
385
+ if (CD_TARGET_REJECT_CHARS.test(target))
386
+ return undefined;
387
+ if (target.startsWith("-") || target.startsWith("~"))
388
+ return undefined;
389
+ let resolved;
390
+ if (target.startsWith("//"))
391
+ return undefined;
392
+ if (target.startsWith("/")) {
393
+ resolved = target;
394
+ }
395
+ else if (target.startsWith("./")) {
396
+ if (opts.priorCd || opts.cwd === undefined)
397
+ return undefined;
398
+ if (!opts.cwd.startsWith("/") || opts.cwd.startsWith("//"))
399
+ return undefined;
400
+ resolved = opts.cwd.replace(/\/+$/, "") + target.slice(1);
401
+ }
402
+ else {
403
+ return undefined;
404
+ }
405
+ if (!isLexicalNormalAbsoluteDir(resolved))
406
+ return undefined;
407
+ if (HOME_TOP_SHAPE.test(resolved))
408
+ return undefined;
409
+ if (HOME_ANCESTOR_SHAPE.test(resolved))
410
+ return undefined;
411
+ const processHome = userHomeTopLevel();
412
+ if (processHome !== undefined && (resolved === processHome || isStrictAncestorDir(resolved, processHome))) {
413
+ return undefined;
414
+ }
415
+ return resolved;
416
+ }
417
+ function isStrictAncestorDir(ancestor, path) {
418
+ return ancestor !== "/" && path.startsWith(`${ancestor}/`);
419
+ }
420
+ const HOME_TOP_SHAPE = /^(?:\/home\/[^/]+|\/Users\/[^/]+|\/root|\/var\/root)$/;
421
+ const HOME_ANCESTOR_SHAPE = /^(?:\/home|\/Users|\/var)$/;
422
+ let HOME_TOP_LEVEL = null;
423
+ function userHomeTopLevel() {
424
+ if (HOME_TOP_LEVEL === null) {
425
+ try {
426
+ const h = homedir();
427
+ HOME_TOP_LEVEL = typeof h === "string" && h.startsWith("/") && !h.startsWith("//") ? h.replace(/\/+$/, "") : undefined;
428
+ }
429
+ catch {
430
+ HOME_TOP_LEVEL = undefined;
431
+ }
432
+ }
433
+ return HOME_TOP_LEVEL === null ? undefined : HOME_TOP_LEVEL;
434
+ }
435
+ const CURSOR_MOVING_HEADS = new Set(["cd", "pushd", "popd"]);
436
+ const OPAQUE_CURSOR_MOVERS = new Set(["eval", "source", ".", "trap", "enable", "builtin", "command", "time"]);
437
+ function pushdPopdLeavesCursor(head, args) {
438
+ for (const a of args) {
439
+ if (a === "--")
440
+ return false;
441
+ if (/^-[a-zA-Z]*n[a-zA-Z]*$/.test(a))
442
+ return true;
443
+ if (head === "popd" && /^\+0*[1-9]\d*$/.test(a))
444
+ return true;
445
+ }
446
+ return false;
447
+ }
448
+ function movesShellCursor(segment) {
449
+ if (carriesShellRedirection(segment))
450
+ return true;
451
+ const words = segment
452
+ .split(/[ \t]+/)
453
+ .map((w) => w.replace(/['"]/g, ""))
454
+ .filter((t) => t !== "");
455
+ const head = words[0];
456
+ if (head === undefined)
457
+ return false;
458
+ if (OPAQUE_CURSOR_MOVERS.has(head))
459
+ return true;
460
+ if (!CURSOR_MOVING_HEADS.has(head))
461
+ return false;
462
+ return !((head === "pushd" || head === "popd") && pushdPopdLeavesCursor(head, words.slice(1)));
463
+ }
464
+ function resolveCdSegmentDirectories(segments, cwd) {
465
+ let priorCd = false;
466
+ return segments.map((segment) => {
467
+ const dir = cdSegmentDirectoryOf(segment, { cwd, priorCd });
468
+ if (!priorCd && movesShellCursor(segment))
469
+ priorCd = true;
470
+ return dir;
471
+ });
472
+ }
298
473
  export function scopeCoversCwd(scope, cwd, sessionId) {
299
474
  if (scope.kind === "global")
300
475
  return true;
@@ -327,11 +502,16 @@ export function findAdmittingRule(rules, call) {
327
502
  const whole = firstEligibleAdmittingRule(rules, call.command, call);
328
503
  if (whole !== undefined)
329
504
  return [whole];
330
- if (shape.segments.length < 2)
331
- return undefined;
505
+ const cdDirs = resolveCdSegmentDirectories(shape.segments, call.execCwd ?? call.cwd);
506
+ if (shape.segments.length < 2) {
507
+ const dir = cdDirs[0];
508
+ const lone = dir !== undefined ? firstEligibleDirectoryRule(rules, dir, call) : undefined;
509
+ return lone !== undefined ? [lone] : undefined;
510
+ }
332
511
  const covering = [];
333
- for (const segment of shape.segments) {
334
- const first = firstEligibleAdmittingRule(rules, segment, call);
512
+ for (const [i, segment] of shape.segments.entries()) {
513
+ const dir = cdDirs[i];
514
+ const first = firstEligibleAdmittingRule(rules, segment, call) ?? (dir !== undefined ? firstEligibleDirectoryRule(rules, dir, call) : undefined);
335
515
  if (first === undefined)
336
516
  return undefined;
337
517
  if (!covering.includes(first))
@@ -339,6 +519,15 @@ export function findAdmittingRule(rules, call) {
339
519
  }
340
520
  return covering;
341
521
  }
522
+ function firstEligibleDirectoryRule(rules, directory, call) {
523
+ for (const rule of rules) {
524
+ if (!eligiblePersisted(rule, { tool: "Read", cwd: call.cwd, sessionId: call.sessionId }))
525
+ continue;
526
+ if (directoryRuleAdmits(rule, directory))
527
+ return rule;
528
+ }
529
+ return undefined;
530
+ }
342
531
  export function segmentCoverageOf(command, rules, call) {
343
532
  const folded = foldSpacing(command);
344
533
  if (folded === undefined)
@@ -347,12 +536,31 @@ export function segmentCoverageOf(command, rules, call) {
347
536
  if ("reject" in shape)
348
537
  return undefined;
349
538
  const proposed = rules.proposed ?? [];
350
- return shape.segments.map((segment) => ({
351
- segment: segment.trim(),
352
- covered: firstEligibleAdmittingRule(rules.persisted, segment, call) !== undefined ||
353
- proposed.some((p) => eligibleContext({ tool: p.rule.tool, scope: p.scope }, call) && ruleAdmitsCommand(p.rule, segment)),
354
- }));
539
+ const cdDirs = resolveCdSegmentDirectories(shape.segments, call.execCwd ?? call.cwd);
540
+ return shape.segments.map((segment, i) => {
541
+ const dir = cdDirs[i];
542
+ return {
543
+ segment: segment.trim(),
544
+ covered: firstEligibleAdmittingRule(rules.persisted, segment, call) !== undefined ||
545
+ (dir !== undefined && firstEligibleDirectoryRule(rules.persisted, dir, call) !== undefined) ||
546
+ proposed.some((p) => p.rule.tool === "Read"
547
+ ? dir !== undefined &&
548
+ eligibleContext({ tool: p.rule.tool, scope: p.scope }, { tool: "Read", cwd: call.cwd, sessionId: call.sessionId }) &&
549
+ directoryRuleAdmits(p.rule, dir)
550
+ : eligibleContext({ tool: p.rule.tool, scope: p.scope }, call) && ruleAdmitsCommand(p.rule, segment)),
551
+ };
552
+ });
355
553
  }
554
+ export const UNCOVERED_SEGMENT_REASON_BASELINE = {
555
+ redirection: "This part carries a redirection, so it is approved per use — no rule can cover this spelling. If you trust its redirection-free form, a rule can be written for that form (the card's edit row, or settings).",
556
+ no_rule_form: "No rule form exists for this part; it can be approved per use.",
557
+ cap_overflow: "This batch does not cover this part; you will be asked again when it comes up.",
558
+ };
559
+ export const RULE_OFFERS_ABSENCE_BASELINE = {
560
+ mandated: "This approval is required by the deployment's policy each time; a rule cannot waive it.",
561
+ lane_cannot_speak: "This card has no rule to offer for this exact command; it can be approved per use. A coverable form of what you trust can still get a rule — through a card drawn for that form, or settings.",
562
+ shadowed: "A rule you already have matches this call and still asks — that standing rule keeps deciding, so a new rule minted here would change nothing; review your existing rules instead.",
563
+ };
356
564
  export function suggestRulesForCommand(command, ctx) {
357
565
  const shape = ruleLaneShapeOf(command, OFFER_READING);
358
566
  if ("reject" in shape)
@@ -367,16 +575,44 @@ export function suggestRulesForCommand(command, ctx) {
367
575
  }
368
576
  const tokensOf = (s) => s.split(/\s+/).filter((t) => t !== "");
369
577
  const bodyOf = (tokens) => longestReviewedBody(tokens) ?? genericPrefixBody(tokens);
578
+ const directoryGateOpen = (ctx?.scope?.kind === "project" || ctx?.scope?.kind === "session") && scopeCoversCwd(ctx.scope, ctx?.cwd, ctx?.sessionId);
579
+ const deniesDirectory = ctx?.deniesDirectoryRead ?? defaultDeniesDirectoryRead;
580
+ const mintDirectoryMember = (directory, segment) => {
581
+ if (!directoryGateOpen || directory === undefined)
582
+ return undefined;
583
+ let denied;
584
+ try {
585
+ denied = deniesDirectory(directory) === true;
586
+ }
587
+ catch {
588
+ denied = true;
589
+ }
590
+ if (denied)
591
+ return undefined;
592
+ const parsed = parseAllowRuleText(formatAllowRuleText(directory, "subpath"));
593
+ if (!("rule" in parsed) || parsed.rule.match !== "subpath")
594
+ return undefined;
595
+ if (!directoryRuleAdmits(parsed.rule, directory))
596
+ return undefined;
597
+ return { kind: "directoryRead", rule: parsed.rule.rule, directory: parsed.rule.command, segment };
598
+ };
370
599
  if (shape.segments.length === 1) {
371
600
  if (offers.length === 1) {
372
601
  const body = bodyOf(tokensOf(folded));
373
602
  if (body !== undefined) {
374
- const parsed = parseAllowRuleText(formatAllowRuleText(body, "prefix"));
375
- if ("rule" in parsed && parsed.rule.match === "prefix" && ruleAdmitsCommand(parsed.rule, command)) {
376
- offers.push({ kind: "single", rule: parsed.rule.rule, match: "prefix", command: parsed.rule.command });
603
+ const parsed = parseAllowRuleText(formatAllowRuleText(body, "wildcard"));
604
+ if ("rule" in parsed && parsed.rule.match === "wildcard" && ruleAdmitsCommand(parsed.rule, command)) {
605
+ offers.push({ kind: "single", rule: parsed.rule.rule, match: "wildcard", command: parsed.rule.command });
377
606
  }
378
607
  }
379
608
  }
609
+ const lone = folded.trim();
610
+ const loneCovered = ctx?.coverage !== undefined && ctx.coverage.length === 1 && ctx.coverage[0]?.segment === lone && ctx.coverage[0]?.covered === true;
611
+ if (!loneCovered) {
612
+ const member = mintDirectoryMember(resolveCdSegmentDirectories([lone], ctx?.execCwd ?? ctx?.cwd)[0], lone);
613
+ if (member !== undefined)
614
+ offers.push({ kind: "batch", rules: [member], uncoveredSegments: 0, uncoveredDetail: [] });
615
+ }
380
616
  return offers;
381
617
  }
382
618
  const foldedShape = ruleLaneShapeOf(folded, OFFER_READING);
@@ -391,9 +627,9 @@ export function suggestRulesForCommand(command, ctx) {
391
627
  return undefined;
392
628
  const body = bodyOf(tokensOf(segment));
393
629
  if (body !== undefined) {
394
- const parsed = parseAllowRuleText(formatAllowRuleText(body, "prefix"));
395
- if ("rule" in parsed && parsed.rule.match === "prefix" && ruleAdmitsCommand(parsed.rule, segment)) {
396
- return { rule: parsed.rule.rule, match: "prefix", command: parsed.rule.command };
630
+ const parsed = parseAllowRuleText(formatAllowRuleText(body, "wildcard"));
631
+ if ("rule" in parsed && parsed.rule.match === "wildcard" && ruleAdmitsCommand(parsed.rule, segment)) {
632
+ return { rule: parsed.rule.rule, match: "wildcard", command: parsed.rule.command };
397
633
  }
398
634
  }
399
635
  const segExact = parseAllowRuleText(formatAllowRuleText(segment, "exact"));
@@ -402,14 +638,19 @@ export function suggestRulesForCommand(command, ctx) {
402
638
  }
403
639
  return undefined;
404
640
  };
641
+ const cdDirs = resolveCdSegmentDirectories(segments, ctx?.execCwd ?? ctx?.cwd);
405
642
  const minted = [];
406
643
  for (let i = 0; i < segments.length; i++) {
407
644
  if (coveredAt(i))
408
645
  continue;
409
646
  const segment = segments[i];
410
- const rule = mintSegmentRule(segment);
411
- if (rule !== undefined && !minted.some((m) => m.rule === rule.rule))
412
- minted.push({ ...rule, segment });
647
+ const dirMember = mintDirectoryMember(cdDirs[i], segment);
648
+ const member = dirMember ?? (() => {
649
+ const rule = mintSegmentRule(segment);
650
+ return rule !== undefined ? { kind: "command", ...rule, segment } : undefined;
651
+ })();
652
+ if (member !== undefined && !minted.some((m) => m.rule === member.rule))
653
+ minted.push(member);
413
654
  }
414
655
  const batchRules = minted.slice(0, 5);
415
656
  if (batchRules.length > 0) {
@@ -417,8 +658,28 @@ export function suggestRulesForCommand(command, ctx) {
417
658
  const p = parseAllowRuleText(r.rule);
418
659
  return "rule" in p ? [p.rule] : [];
419
660
  });
420
- const uncoveredSegments = segments.filter((segment, i) => !coveredAt(i) && !parsedBatch.some((p) => ruleAdmitsCommand(p, segment))).length;
421
- offers.push({ kind: "batch", rules: batchRules, uncoveredSegments });
661
+ const uncoveredDetail = [];
662
+ for (let i = 0; i < segments.length; i++) {
663
+ const segment = segments[i];
664
+ if (coveredAt(i))
665
+ continue;
666
+ if (parsedBatch.some((p) => p.tool !== "Read" && ruleAdmitsCommand(p, segment)))
667
+ continue;
668
+ const dir = cdDirs[i];
669
+ if (dir !== undefined && parsedBatch.some((p) => directoryRuleAdmits(p, dir)))
670
+ continue;
671
+ const reason = carriesShellRedirection(segment) ? "redirection" : mintSegmentRule(segment) === undefined ? "no_rule_form" : "cap_overflow";
672
+ uncoveredDetail.push({ segment, reason });
673
+ }
674
+ offers.push({ kind: "batch", rules: batchRules, uncoveredSegments: uncoveredDetail.length, uncoveredDetail });
422
675
  }
423
676
  return offers;
424
677
  }
678
+ let DEFAULT_DIRECTORY_DENY;
679
+ function defaultDeniesDirectoryRead(directory) {
680
+ if (DEFAULT_DIRECTORY_DENY === undefined) {
681
+ const matcher = compileReadDeny([], "directoryRead mint floor");
682
+ DEFAULT_DIRECTORY_DENY = (d) => matcher.matchPath(d) !== null;
683
+ }
684
+ return DEFAULT_DIRECTORY_DENY(directory);
685
+ }
@@ -191,8 +191,12 @@ export function unenforceableOrgRules(rules) {
191
191
  if (typeof r?.rule !== "string" || r.rule === "")
192
192
  continue;
193
193
  const parsed = parseAllowRuleText(r.rule, { direction: "tighten" });
194
- if ("reject" in parsed)
194
+ if ("reject" in parsed) {
195
195
  out.push({ rule: r.rule, code: parsed.reject.code });
196
+ continue;
197
+ }
198
+ if (parsed.rule.tool !== "Bash")
199
+ out.push({ rule: r.rule, code: "unsupported.tool" });
196
200
  }
197
201
  return out;
198
202
  }
@@ -259,11 +263,14 @@ export async function effectivePermissionRules(opts) {
259
263
  const orgDenies = (opts.orgSnapshot?.rules ?? []).filter((r) => r.behavior === "deny");
260
264
  const out = [];
261
265
  for (const r of listed.rules) {
262
- const segments = ruleLaneSegmentsOf(r.command);
263
- const shadowed = orgDenies.some((d) => {
264
- const parsed = parseAllowRuleText(d.rule, { direction: "tighten" });
265
- return !("reject" in parsed) && parsed.rule.tool === r.tool && orgRuleReaches(parsed.rule, r.command, segments);
266
- });
266
+ const shadowed = r.tool === "Bash" &&
267
+ (() => {
268
+ const segments = ruleLaneSegmentsOf(r.command);
269
+ return orgDenies.some((d) => {
270
+ const parsed = parseAllowRuleText(d.rule, { direction: "tighten" });
271
+ return !("reject" in parsed) && parsed.rule.tool === r.tool && orgRuleReaches(parsed.rule, r.command, segments);
272
+ });
273
+ })();
267
274
  out.push({ rule: r.rule, scope: r.scope, status: shadowed ? "shadowed-by-org" : "live" });
268
275
  }
269
276
  for (const t of listed.tombstones) {
@@ -383,8 +383,15 @@ export interface RemoteExecutionEnv extends ExecutionEnv {
383
383
  * 🔴 Ordering red line (design/48 §5/#6): at-rest encryption of the memory snapshot must be ensured BEFORE
384
384
  * secrets are injected — never let plaintext credentials land in an unencrypted snapshot. That encryption is a
385
385
  * service-side property of the snapshot store; this method must fail-and-clean if it cannot be guaranteed.
386
+ *
387
+ * `opts.abortSignal` (design/384 slice 2, ADDITIVE — implementations are not forced to change): an
388
+ * early-release channel for a caller whose wait on this init is bounded (the park fence's paused-VM
389
+ * compensation). Best-effort exactly like {@link suspendVM}'s — an implementation may ignore it; the
390
+ * caller bounds its own wait and treats a deaf adapter as failed.
386
391
  */
387
- postResumeInit(): Promise<{
392
+ postResumeInit(opts?: {
393
+ abortSignal?: AbortSignal;
394
+ }): Promise<{
388
395
  ok: true;
389
396
  value: void;
390
397
  } | {
@@ -180,5 +180,6 @@ export function assembleResult(spec, sessionId, final, stats, flags) {
180
180
  void _internalCompaction;
181
181
  if (flags.unpricedSpend)
182
182
  delete publicStats.costMicroUsd;
183
- return { taskId, ...(flags.runId !== undefined ? { runId: flags.runId } : {}), sessionId, status, ...(flags.model !== undefined ? { model: flags.model } : {}), result: result.trim(), salvagedOutput, blockedReason, errorMessage, errorCode, ...(apiFailure !== undefined ? { apiFailure } : {}), ...(retryAfterMs !== undefined ? { retryAfterMs } : {}), checkpointToken, ...(checkpointId !== undefined ? { checkpointId } : {}), checkpointGate, ...(workspaceRestoreMode !== undefined ? { workspaceRestoreMode } : {}), ...(flags.rewindNotes !== undefined && flags.rewindNotes.length > 0 ? { rewindNotes: flags.rewindNotes } : {}), ...(flags.haltedOnUserRejection === true ? { haltedOnUserRejection: true } : {}), ...(flags.userHalted === true ? { haltedByUser: true } : {}), ...(flags.remoteEnvFailures !== undefined && flags.remoteEnvFailures.length > 0 ? { remoteEnvFailures: flags.remoteEnvFailures } : {}), ...(flags.strandedHumanAnswers !== undefined && flags.strandedHumanAnswers.length > 0 ? { strandedHumanAnswers: flags.strandedHumanAnswers } : {}), ...(flags.effectiveReadFace !== undefined ? { effectiveReadFace: flags.effectiveReadFace } : {}), ...(flags.effectiveReadDenyPatterns !== undefined && flags.effectiveReadDenyPatterns.length > 0 ? { effectiveReadDenyPatterns: flags.effectiveReadDenyPatterns } : {}), ...(flags.effectiveMemoryScopes !== undefined ? { effectiveMemoryScopes: flags.effectiveMemoryScopes } : {}), ...(flags.effectiveReasoning !== undefined ? { effectiveReasoning: flags.effectiveReasoning } : {}), stats: publicStats };
183
+ const stampHaltedByUser = flags.userHalted === true && status !== "suspended" && status !== "needs_review";
184
+ return { taskId, ...(flags.runId !== undefined ? { runId: flags.runId } : {}), sessionId, status, ...(flags.model !== undefined ? { model: flags.model } : {}), result: result.trim(), salvagedOutput, blockedReason, errorMessage, errorCode, ...(apiFailure !== undefined ? { apiFailure } : {}), ...(retryAfterMs !== undefined ? { retryAfterMs } : {}), checkpointToken, ...(checkpointId !== undefined ? { checkpointId } : {}), checkpointGate, ...(workspaceRestoreMode !== undefined ? { workspaceRestoreMode } : {}), ...(flags.rewindNotes !== undefined && flags.rewindNotes.length > 0 ? { rewindNotes: flags.rewindNotes } : {}), ...(flags.haltedOnUserRejection === true ? { haltedOnUserRejection: true } : {}), ...(stampHaltedByUser ? { haltedByUser: true } : {}), ...(flags.remoteEnvFailures !== undefined && flags.remoteEnvFailures.length > 0 ? { remoteEnvFailures: [...flags.remoteEnvFailures] } : {}), ...(flags.strandedHumanAnswers !== undefined && flags.strandedHumanAnswers.length > 0 ? { strandedHumanAnswers: flags.strandedHumanAnswers } : {}), ...(flags.effectiveReadFace !== undefined ? { effectiveReadFace: flags.effectiveReadFace } : {}), ...(flags.effectiveReadDenyPatterns !== undefined && flags.effectiveReadDenyPatterns.length > 0 ? { effectiveReadDenyPatterns: flags.effectiveReadDenyPatterns } : {}), ...(flags.effectiveMemoryScopes !== undefined ? { effectiveMemoryScopes: flags.effectiveMemoryScopes } : {}), ...(flags.effectiveReasoning !== undefined ? { effectiveReasoning: flags.effectiveReasoning } : {}), stats: publicStats };
184
185
  }