@sema-agent/core 7.0.2 → 7.2.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 (66) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/dist/agents/cross-session-envelope.d.ts +138 -0
  3. package/dist/agents/cross-session-envelope.js +191 -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/repair-loop.d.ts +8 -7
  9. package/dist/agents/roster-store.d.ts +7 -2
  10. package/dist/agents/send-message-tool.d.ts +13 -0
  11. package/dist/agents/send-message-tool.js +36 -12
  12. package/dist/brain/errors.d.ts +18 -0
  13. package/dist/brain/errors.js +3 -0
  14. package/dist/brain/stream-engine.js +6 -4
  15. package/dist/core/checkpoint-store.d.ts +189 -3
  16. package/dist/core/checkpoint-store.js +56 -16
  17. package/dist/core/context-edit.d.ts +3 -0
  18. package/dist/core/governance-codes.d.ts +1 -1
  19. package/dist/core/governance-codes.js +4 -0
  20. package/dist/core/hooks.d.ts +34 -7
  21. package/dist/core/hooks.js +14 -8
  22. package/dist/core/image-downsample.d.ts +4 -3
  23. package/dist/core/permission-rule-consent.d.ts +72 -23
  24. package/dist/core/permission-rule-consent.js +115 -26
  25. package/dist/core/permission-rule-model.d.ts +245 -51
  26. package/dist/core/permission-rule-model.js +312 -54
  27. package/dist/core/permission-rule-org.js +13 -6
  28. package/dist/core/remote-env.d.ts +8 -1
  29. package/dist/core/roles.d.ts +30 -8
  30. package/dist/core/roles.js +12 -8
  31. package/dist/core/runner/assemble-result.js +2 -1
  32. package/dist/core/runner/prepare-task.d.ts +41 -2
  33. package/dist/core/runner/prepare-task.js +353 -152
  34. package/dist/core/runner/prepare-workspace-restore.d.ts +6 -1
  35. package/dist/core/runner/prepare-workspace-restore.js +2 -1
  36. package/dist/core/runner/runtask.d.ts +12 -3
  37. package/dist/core/runner/runtask.js +45 -7
  38. package/dist/core/safety-axis-vocab.d.ts +1 -1
  39. package/dist/core/strategy-store.d.ts +4 -1
  40. package/dist/core/task-notification.d.ts +64 -5
  41. package/dist/core/task-notification.js +25 -4
  42. package/dist/core/task-registry-shared.d.ts +7 -3
  43. package/dist/core/tool-errors.d.ts +1 -1
  44. package/dist/core/tool-policy.d.ts +51 -7
  45. package/dist/core/tool-policy.js +63 -9
  46. package/dist/core/types.d.ts +110 -9
  47. package/dist/core/untrusted-text.js +17 -1
  48. package/dist/engine/compaction/compaction.js +6 -2
  49. package/dist/engine/harness/agent-harness.d.ts +28 -6
  50. package/dist/engine/harness/agent-harness.js +34 -2
  51. package/dist/engine/harness/messages.js +4 -0
  52. package/dist/engine/harness/types.d.ts +37 -0
  53. package/dist/engine/harness/types.js +5 -0
  54. package/dist/engine/session/session.js +3 -2
  55. package/dist/index.d.ts +6 -3
  56. package/dist/index.js +5 -2
  57. package/dist/internal/harness.d.ts +1 -0
  58. package/dist/internal/harness.js +1 -0
  59. package/dist/orchestration/builtin-workflows.d.ts +17 -9
  60. package/dist/orchestration/run-workflow-tool.js +7 -2
  61. package/dist/orchestration/workflow-governance.js +1 -1
  62. package/dist/orchestration/workflow-types.d.ts +1 -0
  63. package/dist/orchestration/workflow.js +1 -1
  64. package/dist/stores/file/mailbox-store.d.ts +2 -1
  65. package/package.json +1 -1
  66. package/test/export-surface.snapshot.json +125 -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;
@@ -193,15 +195,53 @@ export function parseAllowRuleText(text, opts) {
193
195
  if (parsed.ruleContent === undefined) {
194
196
  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
197
  }
198
+ if (parsed.toolName === "Read") {
199
+ const content = parsed.ruleContent;
200
+ const m = /^\/\/(.+)\/\*\*$/.exec(content);
201
+ if (m === null) {
202
+ 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`);
203
+ }
204
+ const bodyR = m[1];
205
+ if (bodyR.includes("*")) {
206
+ return reject("unsupported.wildcard", `a \`*\` inside the directory body matches anything at that position, which has no rule form ("${text}")`);
207
+ }
208
+ const segments = bodyR.split("/");
209
+ if (segments.some((s) => s === "" || s.trim() === "")) {
210
+ 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`);
211
+ }
212
+ if (segments.some((s) => s === "." || s === "..")) {
213
+ 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`);
214
+ }
215
+ const dir = "/" + bodyR;
216
+ return { rule: { rule: formatAllowRuleText(dir, "subpath"), tool: "Read", match: "subpath", command: dir } };
217
+ }
196
218
  if (parsed.toolName !== "Bash") {
197
- return reject("unsupported.tool", `only Bash rules are supported in this version (got "${parsed.toolName}")`);
219
+ return reject("unsupported.tool", `only Bash and Read rules are supported in this version (got "${parsed.toolName}")`);
198
220
  }
199
221
  const content = parsed.ruleContent;
200
222
  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}")`);
223
+ let match;
224
+ let body;
225
+ if (prefixBody !== undefined) {
226
+ if (prefixBody.includes("*")) {
227
+ return reject("unsupported.wildcard", `a \`*\` inside a prefix body matches anything at that position, which has no rule form in this version ("${text}")`);
228
+ }
229
+ match = "prefix";
230
+ body = prefixBody;
231
+ }
232
+ else {
233
+ const wildcardBody = /^(.+) \*$/.exec(content)?.[1];
234
+ if (wildcardBody !== undefined && !hasUnescapedStar(wildcardBody)) {
235
+ match = "wildcard";
236
+ body = wildcardBody;
237
+ }
238
+ else if (hasUnescapedStar(content)) {
239
+ 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}")`);
240
+ }
241
+ else {
242
+ match = "exact";
243
+ body = content;
244
+ }
205
245
  }
206
246
  const shape = ruleLaneShapeOf(body, MATCH_READING);
207
247
  if ("reject" in shape) {
@@ -209,8 +249,11 @@ export function parseAllowRuleText(text, opts) {
209
249
  ? reject("invalid.empty_command", `rule "${text}" names no command`)
210
250
  : reject("invalid.not_simple_command", `rule "${text}" is not a command this lane can name (${shape.reject})`);
211
251
  }
252
+ if (match === "wildcard" && shape.segments.length > 1) {
253
+ 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}")`);
254
+ }
212
255
  const head = commandBasename(shape.names[shape.names.length - 1] ?? "");
213
- if (match === "prefix" && BARE_INTERPRETER_NAMES.has(head) && opts?.direction !== "tighten") {
256
+ if ((match === "prefix" || match === "wildcard") && BARE_INTERPRETER_NAMES.has(head) && opts?.direction !== "tighten") {
214
257
  return reject("invalid.bare_interpreter_prefix", shape.segments.length > 1
215
258
  ? `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
259
  : `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 +265,9 @@ export function parseAllowRuleText(text, opts) {
222
265
  return { rule: { rule: formatAllowRuleText(command, match), tool: "Bash", match, command } };
223
266
  }
224
267
  export function formatAllowRuleText(command, match) {
225
- return `Bash(${command}${match === "prefix" ? ":*" : ""})`;
268
+ if (match === "subpath")
269
+ return `Read(//${command.startsWith("/") ? command.slice(1) : command}/**)`;
270
+ return `Bash(${command}${match === "prefix" ? ":*" : match === "wildcard" ? " *" : ""})`;
226
271
  }
227
272
  function hasUnescapedStar(s) {
228
273
  for (let i = 0; i < s.length; i++) {
@@ -236,33 +281,6 @@ function hasUnescapedStar(s) {
236
281
  }
237
282
  return false;
238
283
  }
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
284
  export function ruleAdmitsCommand(rule, command) {
267
285
  return admitsUnder(rule, command, MATCH_READING);
268
286
  }
@@ -270,6 +288,8 @@ export function ruleAdmitsProgramRun(rule, command) {
270
288
  return admitsUnder(rule, command, PROGRAM_RUNS_READING);
271
289
  }
272
290
  function admitsUnder(rule, command, reading) {
291
+ if (rule.match === "subpath")
292
+ return false;
273
293
  const shape = ruleLaneShapeOf(command, reading);
274
294
  if ("reject" in shape)
275
295
  return false;
@@ -285,6 +305,30 @@ function admitsUnder(rule, command, reading) {
285
305
  return false;
286
306
  return folded === rule.command || folded.startsWith(rule.command + " ");
287
307
  }
308
+ export function ruleBreadthWarningsOf(rule) {
309
+ if (rule.match !== "prefix" && rule.match !== "wildcard")
310
+ return [];
311
+ const bodyShape = ruleLaneShapeOf(rule.command, MATCH_READING);
312
+ if ("reject" in bodyShape)
313
+ return [];
314
+ if (bodyShape.segments.length > 1) {
315
+ return [
316
+ {
317
+ code: "compound_prefix",
318
+ 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`,
319
+ },
320
+ ];
321
+ }
322
+ if (rule.command.split(/\s+/).filter((t) => t !== "").length === 1) {
323
+ return [
324
+ {
325
+ code: "broad_prefix",
326
+ message: `this rule admits every argument form of "${rule.command}" — any \`${rule.command} …\` command, not just the one on this card`,
327
+ },
328
+ ];
329
+ }
330
+ return [];
331
+ }
288
332
  export function ruleLaneSegmentsOf(command) {
289
333
  const shape = ruleLaneShapeOf(command, PROGRAM_RUNS_READING);
290
334
  return "reject" in shape ? undefined : shape.segments;
@@ -295,6 +339,134 @@ export function pathWithinRoot(path, root) {
295
339
  const base = root.endsWith("/") ? root : root + "/";
296
340
  return path.startsWith(base);
297
341
  }
342
+ export function lexicalNormalAbsolutePathOf(path) {
343
+ if (typeof path !== "string" || !path.startsWith("/"))
344
+ return undefined;
345
+ const out = [];
346
+ for (const seg of path.split("/")) {
347
+ if (seg === "" || seg === ".")
348
+ continue;
349
+ if (seg === "..") {
350
+ if (out.length === 0)
351
+ return undefined;
352
+ out.pop();
353
+ continue;
354
+ }
355
+ out.push(seg);
356
+ }
357
+ return out.length === 0 ? "/" : "/" + out.join("/");
358
+ }
359
+ function isLexicalNormalAbsoluteDir(path) {
360
+ if (typeof path !== "string" || !path.startsWith("/") || path.startsWith("//"))
361
+ return false;
362
+ const segments = path.split("/").slice(1);
363
+ return segments.length > 0 && segments.every((s) => s !== "" && s !== "." && s !== "..");
364
+ }
365
+ export function directoryRuleAdmits(rule, path) {
366
+ if (rule.tool !== "Read" || rule.match !== "subpath")
367
+ return false;
368
+ if (!isLexicalNormalAbsoluteDir(rule.command))
369
+ return false;
370
+ if (!isLexicalNormalAbsoluteDir(path))
371
+ return false;
372
+ return pathWithinRoot(path, rule.command);
373
+ }
374
+ const CD_TARGET_REJECT_CHARS = /[*?[\]{}$`\\'"<>\n\r]/;
375
+ function cdSegmentDirectoryOf(segment, opts) {
376
+ if (/[^\S \t]/.test(segment))
377
+ return undefined;
378
+ const tokens = segment.split(/[ \t]+/).filter((t) => t !== "");
379
+ if (tokens.length !== 2 || tokens[0] !== "cd")
380
+ return undefined;
381
+ const target = tokens[1];
382
+ if (CD_TARGET_REJECT_CHARS.test(target))
383
+ return undefined;
384
+ if (target.startsWith("-") || target.startsWith("~"))
385
+ return undefined;
386
+ let resolved;
387
+ if (target.startsWith("//"))
388
+ return undefined;
389
+ if (target.startsWith("/")) {
390
+ resolved = target;
391
+ }
392
+ else if (target.startsWith("./")) {
393
+ if (opts.priorCd || opts.cwd === undefined)
394
+ return undefined;
395
+ if (!opts.cwd.startsWith("/") || opts.cwd.startsWith("//"))
396
+ return undefined;
397
+ resolved = opts.cwd.replace(/\/+$/, "") + target.slice(1);
398
+ }
399
+ else {
400
+ return undefined;
401
+ }
402
+ if (!isLexicalNormalAbsoluteDir(resolved))
403
+ return undefined;
404
+ if (HOME_TOP_SHAPE.test(resolved))
405
+ return undefined;
406
+ if (HOME_ANCESTOR_SHAPE.test(resolved))
407
+ return undefined;
408
+ const processHome = userHomeTopLevel();
409
+ if (processHome !== undefined && (resolved === processHome || isStrictAncestorDir(resolved, processHome))) {
410
+ return undefined;
411
+ }
412
+ return resolved;
413
+ }
414
+ function isStrictAncestorDir(ancestor, path) {
415
+ return ancestor !== "/" && path.startsWith(`${ancestor}/`);
416
+ }
417
+ const HOME_TOP_SHAPE = /^(?:\/home\/[^/]+|\/Users\/[^/]+|\/root|\/var\/root)$/;
418
+ const HOME_ANCESTOR_SHAPE = /^(?:\/home|\/Users|\/var)$/;
419
+ let HOME_TOP_LEVEL = null;
420
+ function userHomeTopLevel() {
421
+ if (HOME_TOP_LEVEL === null) {
422
+ try {
423
+ const h = homedir();
424
+ HOME_TOP_LEVEL = typeof h === "string" && h.startsWith("/") && !h.startsWith("//") ? h.replace(/\/+$/, "") : undefined;
425
+ }
426
+ catch {
427
+ HOME_TOP_LEVEL = undefined;
428
+ }
429
+ }
430
+ return HOME_TOP_LEVEL === null ? undefined : HOME_TOP_LEVEL;
431
+ }
432
+ const CURSOR_MOVING_HEADS = new Set(["cd", "pushd", "popd"]);
433
+ const OPAQUE_CURSOR_MOVERS = new Set(["eval", "source", ".", "trap", "enable", "builtin", "command", "time"]);
434
+ function pushdPopdLeavesCursor(head, args) {
435
+ for (const a of args) {
436
+ if (a === "--")
437
+ return false;
438
+ if (/^-[a-zA-Z]*n[a-zA-Z]*$/.test(a))
439
+ return true;
440
+ if (head === "popd" && /^\+0*[1-9]\d*$/.test(a))
441
+ return true;
442
+ }
443
+ return false;
444
+ }
445
+ function movesShellCursor(segment) {
446
+ if (carriesShellRedirection(segment))
447
+ return true;
448
+ const words = segment
449
+ .split(/[ \t]+/)
450
+ .map((w) => w.replace(/['"]/g, ""))
451
+ .filter((t) => t !== "");
452
+ const head = words[0];
453
+ if (head === undefined)
454
+ return false;
455
+ if (OPAQUE_CURSOR_MOVERS.has(head))
456
+ return true;
457
+ if (!CURSOR_MOVING_HEADS.has(head))
458
+ return false;
459
+ return !((head === "pushd" || head === "popd") && pushdPopdLeavesCursor(head, words.slice(1)));
460
+ }
461
+ function resolveCdSegmentDirectories(segments, cwd) {
462
+ let priorCd = false;
463
+ return segments.map((segment) => {
464
+ const dir = cdSegmentDirectoryOf(segment, { cwd, priorCd });
465
+ if (!priorCd && movesShellCursor(segment))
466
+ priorCd = true;
467
+ return dir;
468
+ });
469
+ }
298
470
  export function scopeCoversCwd(scope, cwd, sessionId) {
299
471
  if (scope.kind === "global")
300
472
  return true;
@@ -327,11 +499,16 @@ export function findAdmittingRule(rules, call) {
327
499
  const whole = firstEligibleAdmittingRule(rules, call.command, call);
328
500
  if (whole !== undefined)
329
501
  return [whole];
330
- if (shape.segments.length < 2)
331
- return undefined;
502
+ const cdDirs = resolveCdSegmentDirectories(shape.segments, call.execCwd ?? call.cwd);
503
+ if (shape.segments.length < 2) {
504
+ const dir = cdDirs[0];
505
+ const lone = dir !== undefined ? firstEligibleDirectoryRule(rules, dir, call) : undefined;
506
+ return lone !== undefined ? [lone] : undefined;
507
+ }
332
508
  const covering = [];
333
- for (const segment of shape.segments) {
334
- const first = firstEligibleAdmittingRule(rules, segment, call);
509
+ for (const [i, segment] of shape.segments.entries()) {
510
+ const dir = cdDirs[i];
511
+ const first = firstEligibleAdmittingRule(rules, segment, call) ?? (dir !== undefined ? firstEligibleDirectoryRule(rules, dir, call) : undefined);
335
512
  if (first === undefined)
336
513
  return undefined;
337
514
  if (!covering.includes(first))
@@ -339,6 +516,15 @@ export function findAdmittingRule(rules, call) {
339
516
  }
340
517
  return covering;
341
518
  }
519
+ function firstEligibleDirectoryRule(rules, directory, call) {
520
+ for (const rule of rules) {
521
+ if (!eligiblePersisted(rule, { tool: "Read", cwd: call.cwd, sessionId: call.sessionId }))
522
+ continue;
523
+ if (directoryRuleAdmits(rule, directory))
524
+ return rule;
525
+ }
526
+ return undefined;
527
+ }
342
528
  export function segmentCoverageOf(command, rules, call) {
343
529
  const folded = foldSpacing(command);
344
530
  if (folded === undefined)
@@ -347,12 +533,31 @@ export function segmentCoverageOf(command, rules, call) {
347
533
  if ("reject" in shape)
348
534
  return undefined;
349
535
  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
- }));
536
+ const cdDirs = resolveCdSegmentDirectories(shape.segments, call.execCwd ?? call.cwd);
537
+ return shape.segments.map((segment, i) => {
538
+ const dir = cdDirs[i];
539
+ return {
540
+ segment: segment.trim(),
541
+ covered: firstEligibleAdmittingRule(rules.persisted, segment, call) !== undefined ||
542
+ (dir !== undefined && firstEligibleDirectoryRule(rules.persisted, dir, call) !== undefined) ||
543
+ proposed.some((p) => p.rule.tool === "Read"
544
+ ? dir !== undefined &&
545
+ eligibleContext({ tool: p.rule.tool, scope: p.scope }, { tool: "Read", cwd: call.cwd, sessionId: call.sessionId }) &&
546
+ directoryRuleAdmits(p.rule, dir)
547
+ : eligibleContext({ tool: p.rule.tool, scope: p.scope }, call) && ruleAdmitsCommand(p.rule, segment)),
548
+ };
549
+ });
355
550
  }
551
+ export const UNCOVERED_SEGMENT_REASON_BASELINE = {
552
+ 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).",
553
+ no_rule_form: "No rule form exists for this part; it can be approved per use.",
554
+ cap_overflow: "This batch does not cover this part; you will be asked again when it comes up.",
555
+ };
556
+ export const RULE_OFFERS_ABSENCE_BASELINE = {
557
+ mandated: "This approval is required by the deployment's policy each time; a rule cannot waive it.",
558
+ 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.",
559
+ 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.",
560
+ };
356
561
  export function suggestRulesForCommand(command, ctx) {
357
562
  const shape = ruleLaneShapeOf(command, OFFER_READING);
358
563
  if ("reject" in shape)
@@ -367,16 +572,44 @@ export function suggestRulesForCommand(command, ctx) {
367
572
  }
368
573
  const tokensOf = (s) => s.split(/\s+/).filter((t) => t !== "");
369
574
  const bodyOf = (tokens) => longestReviewedBody(tokens) ?? genericPrefixBody(tokens);
575
+ const directoryGateOpen = (ctx?.scope?.kind === "project" || ctx?.scope?.kind === "session") && scopeCoversCwd(ctx.scope, ctx?.cwd, ctx?.sessionId);
576
+ const deniesDirectory = ctx?.deniesDirectoryRead ?? defaultDeniesDirectoryRead;
577
+ const mintDirectoryMember = (directory, segment) => {
578
+ if (!directoryGateOpen || directory === undefined)
579
+ return undefined;
580
+ let denied;
581
+ try {
582
+ denied = deniesDirectory(directory) === true;
583
+ }
584
+ catch {
585
+ denied = true;
586
+ }
587
+ if (denied)
588
+ return undefined;
589
+ const parsed = parseAllowRuleText(formatAllowRuleText(directory, "subpath"));
590
+ if (!("rule" in parsed) || parsed.rule.match !== "subpath")
591
+ return undefined;
592
+ if (!directoryRuleAdmits(parsed.rule, directory))
593
+ return undefined;
594
+ return { kind: "directoryRead", rule: parsed.rule.rule, directory: parsed.rule.command, segment };
595
+ };
370
596
  if (shape.segments.length === 1) {
371
597
  if (offers.length === 1) {
372
598
  const body = bodyOf(tokensOf(folded));
373
599
  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 });
600
+ const parsed = parseAllowRuleText(formatAllowRuleText(body, "wildcard"));
601
+ if ("rule" in parsed && parsed.rule.match === "wildcard" && ruleAdmitsCommand(parsed.rule, command)) {
602
+ offers.push({ kind: "single", rule: parsed.rule.rule, match: "wildcard", command: parsed.rule.command });
377
603
  }
378
604
  }
379
605
  }
606
+ const lone = folded.trim();
607
+ const loneCovered = ctx?.coverage !== undefined && ctx.coverage.length === 1 && ctx.coverage[0]?.segment === lone && ctx.coverage[0]?.covered === true;
608
+ if (!loneCovered) {
609
+ const member = mintDirectoryMember(resolveCdSegmentDirectories([lone], ctx?.execCwd ?? ctx?.cwd)[0], lone);
610
+ if (member !== undefined)
611
+ offers.push({ kind: "batch", rules: [member], uncoveredSegments: 0, uncoveredDetail: [] });
612
+ }
380
613
  return offers;
381
614
  }
382
615
  const foldedShape = ruleLaneShapeOf(folded, OFFER_READING);
@@ -391,9 +624,9 @@ export function suggestRulesForCommand(command, ctx) {
391
624
  return undefined;
392
625
  const body = bodyOf(tokensOf(segment));
393
626
  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 };
627
+ const parsed = parseAllowRuleText(formatAllowRuleText(body, "wildcard"));
628
+ if ("rule" in parsed && parsed.rule.match === "wildcard" && ruleAdmitsCommand(parsed.rule, segment)) {
629
+ return { rule: parsed.rule.rule, match: "wildcard", command: parsed.rule.command };
397
630
  }
398
631
  }
399
632
  const segExact = parseAllowRuleText(formatAllowRuleText(segment, "exact"));
@@ -402,14 +635,19 @@ export function suggestRulesForCommand(command, ctx) {
402
635
  }
403
636
  return undefined;
404
637
  };
638
+ const cdDirs = resolveCdSegmentDirectories(segments, ctx?.execCwd ?? ctx?.cwd);
405
639
  const minted = [];
406
640
  for (let i = 0; i < segments.length; i++) {
407
641
  if (coveredAt(i))
408
642
  continue;
409
643
  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 });
644
+ const dirMember = mintDirectoryMember(cdDirs[i], segment);
645
+ const member = dirMember ?? (() => {
646
+ const rule = mintSegmentRule(segment);
647
+ return rule !== undefined ? { kind: "command", ...rule, segment } : undefined;
648
+ })();
649
+ if (member !== undefined && !minted.some((m) => m.rule === member.rule))
650
+ minted.push(member);
413
651
  }
414
652
  const batchRules = minted.slice(0, 5);
415
653
  if (batchRules.length > 0) {
@@ -417,8 +655,28 @@ export function suggestRulesForCommand(command, ctx) {
417
655
  const p = parseAllowRuleText(r.rule);
418
656
  return "rule" in p ? [p.rule] : [];
419
657
  });
420
- const uncoveredSegments = segments.filter((segment, i) => !coveredAt(i) && !parsedBatch.some((p) => ruleAdmitsCommand(p, segment))).length;
421
- offers.push({ kind: "batch", rules: batchRules, uncoveredSegments });
658
+ const uncoveredDetail = [];
659
+ for (let i = 0; i < segments.length; i++) {
660
+ const segment = segments[i];
661
+ if (coveredAt(i))
662
+ continue;
663
+ if (parsedBatch.some((p) => p.tool !== "Read" && ruleAdmitsCommand(p, segment)))
664
+ continue;
665
+ const dir = cdDirs[i];
666
+ if (dir !== undefined && parsedBatch.some((p) => directoryRuleAdmits(p, dir)))
667
+ continue;
668
+ const reason = carriesShellRedirection(segment) ? "redirection" : mintSegmentRule(segment) === undefined ? "no_rule_form" : "cap_overflow";
669
+ uncoveredDetail.push({ segment, reason });
670
+ }
671
+ offers.push({ kind: "batch", rules: batchRules, uncoveredSegments: uncoveredDetail.length, uncoveredDetail });
422
672
  }
423
673
  return offers;
424
674
  }
675
+ let DEFAULT_DIRECTORY_DENY;
676
+ function defaultDeniesDirectoryRead(directory) {
677
+ if (DEFAULT_DIRECTORY_DENY === undefined) {
678
+ const matcher = compileReadDeny([], "directoryRead mint floor");
679
+ DEFAULT_DIRECTORY_DENY = (d) => matcher.matchPath(d) !== null;
680
+ }
681
+ return DEFAULT_DIRECTORY_DENY(directory);
682
+ }
@@ -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
  } | {
@@ -9,6 +9,12 @@ export declare function resolveModel(ref: ModelRef, models?: Record<string, Mode
9
9
  export declare const DEFAULT_TIER_ORDER: readonly string[];
10
10
  /** CC 档位词 → sema 档位(内置固定,兼容层):fable/mythos→max · opus→pro · sonnet→flash · haiku→lite。 */
11
11
  export declare const CC_MODEL_TIER_ALIASES: Readonly<Record<string, string>>;
12
+ /** the DISPLAY face of a string model ref: a CC tier ALIAS (haiku/sonnet/…) resolves to its
13
+ * sema tier name (lite/flash/…) for labels — the alias verbatim reads as a strong claim about a
14
+ * specific vendor's model while the actual routing may land anywhere ("haiku" showing over a
15
+ * DeepSeek run, an operator field report). A non-alias string is the deployment's own model key — its
16
+ * honest name — and passes through unchanged. Display only; routing (`expandTiers`) is untouched. */
17
+ export declare function resolveModelDisplayLabel(ref: string): string;
12
18
  /**
13
19
  * Expand a deployment's tier bindings into the model catalog: every tier name (and every CC alias whose
14
20
  * tier resolves) becomes a catalog key pointing at the bound Model, so the ENTIRE resolve surface
@@ -29,12 +35,6 @@ export declare const CC_MODEL_TIER_ALIASES: Readonly<Record<string, string>>;
29
35
  * `best` wins); with no bound known-order tier (or no tiers at all) the key stays absent and
30
36
  * `resolveModel("best")` refuses like any unknown ref.
31
37
  */
32
- /** the DISPLAY face of a string model ref: a CC tier ALIAS (haiku/sonnet/…) resolves to its
33
- * sema tier name (lite/flash/…) for labels — the alias verbatim reads as a strong claim about a
34
- * specific vendor's model while the actual routing may land anywhere ("haiku" showing over a
35
- * DeepSeek run, an operator field report). A non-alias string is the deployment's own model key — its
36
- * honest name — and passes through unchanged. Display only; routing (`expandTiers`) is untouched. */
37
- export declare function resolveModelDisplayLabel(ref: string): string;
38
38
  export declare function expandTiers(models: Record<string, Model> | undefined, tiers: Record<string, ModelRef> | undefined): Record<string, Model> | undefined;
39
39
  export interface ModelMention {
40
40
  /** The matched model name — always one of `allowedNames` — or undefined if none was found. */
@@ -49,8 +49,27 @@ export interface ModelMention {
49
49
  * pick *which configured model* to use, but can never inject an arbitrary `baseUrl`/`apiKey`/Model
50
50
  * (see design/11). Matches the first allow-listed mention; the rest of the text is returned cleaned.
51
51
  *
52
- * Typical use: `const { model, cleanedText } = parseModelMention(userMsg, Object.keys(deps.models));`
52
+ * **Case-INSENSITIVE, deliberately** and deliberately UNLIKE the catalog, whose lookups
53
+ * ({@link resolveModel}) are exact. The two answer different questions and the asymmetry is the
54
+ * design, not an oversight: a chat mention is a HUMAN-typed UX surface where `@Opus` and `@opus` are
55
+ * the same intent, so intake is wide; a catalog key is a machine identifier a deployment declared, so
56
+ * lookup is exact. The join is safe because the wide half never widens the narrow one: this returns
57
+ * the allowlist's OWN canonical spelling (never the user's casing), so the value handed to
58
+ * `resolveModel` is always a key the deployment wrote. The only visible consequence is that a catalog
59
+ * carrying two keys differing ONLY in case is addressed by whichever the allowlist iterates last —
60
+ * `@x` cannot name both, and there is no spelling that disambiguates them.
61
+ *
62
+ * Typical use: `const { model, cleanedText } = parseModelMention(userMsg, Object.keys(runner.agentCatalog.models ?? {}));`
53
63
  * then `runTask({ objective: cleanedText, model })` (omit `model` to fall back to the default role).
64
+ *
65
+ * **Feed it the EXPANDED table.** The allowlist this takes is the host's own, and on a deployment that
66
+ * configured `RunnerDeps.tiers` the host's `deps.models` is NOT what the Runner resolves against: the
67
+ * constructor runs {@link expandTiers} once and keeps the expansion PRIVATE, so tier words and CC
68
+ * aliases (`pro`/`flash`/`opus`/`sonnet`/`best`/…) are catalog keys inside the Runner and absent from
69
+ * the host's table. Passing `Object.keys(deps.models)` therefore makes a user's `@opus` silently not
70
+ * match — the mention falls through as ordinary text and the task quietly runs on the default role.
71
+ * `runner.agentCatalog.models` is the expanded table (a per-read copy); a caller that has no Runner in
72
+ * hand can pre-expand its own with the exported {@link expandTiers}.
54
73
  */
55
74
  export declare function parseModelMention(text: string, allowedNames: Iterable<string>): ModelMention;
56
75
  /**
@@ -60,7 +79,10 @@ export declare function parseModelMention(text: string, allowedNames: Iterable<s
60
79
  * - `summarize` → `flash`(压缩本该便宜;flash 未绑沿降档链到 lite);
61
80
  * - `advisor` → 第一档(`ultra` 起,未绑降档到 max/pro——teacher 用最强的);
62
81
  * - `verifier` → `pro`;
63
- * - `subagent`/`team`/`synthesize` 无档位默认 = FALLBACK 继承主模型(LLM 可经 model 参数自选档)
82
+ * - `classifier` `flash`(design/143 批2b:auto-mode 权限分类腿是便宜档;flash 未绑沿降档链到 lite);
83
+ * - `subagent`/`team`/`synthesize` 无档位默认 = 走 FALLBACK 继承主模型(LLM 可经 model 参数自选档);
84
+ * - `consolidate` 同样无档位默认,但它的 FALLBACK 刻意只有 `summarize` 一站(design/376,见
85
+ * {@link FALLBACK} 的注):未配即响亮拒,不静默骑主模型。
64
86
  * 未配 tiers 的部署:catalog 无 TIERS_ACTIVE 标记 → 表整体 INERT,行为与从前逐字节一致
65
87
  * (即使 catalog 恰有裸模型名 `pro`/`flash` 也不入档位路由——INERT 靠配置成立,不靠键名运气)。
66
88
  */
@@ -18,7 +18,7 @@ export const CC_MODEL_TIER_ALIASES = {
18
18
  haiku: "lite",
19
19
  };
20
20
  export function resolveModelDisplayLabel(ref) {
21
- return CC_MODEL_TIER_ALIASES[ref] ?? ref;
21
+ return Object.hasOwn(CC_MODEL_TIER_ALIASES, ref) ? CC_MODEL_TIER_ALIASES[ref] : ref;
22
22
  }
23
23
  const TIERS_ACTIVE = Symbol.for("sema.tiersActive");
24
24
  export function expandTiers(models, tiers) {
@@ -27,7 +27,7 @@ export function expandTiers(models, tiers) {
27
27
  const out = { ...models };
28
28
  out[TIERS_ACTIVE] = true;
29
29
  const bound = (name) => {
30
- const b = tiers[name];
30
+ const b = Object.hasOwn(tiers, name) ? tiers[name] : undefined;
31
31
  return b === undefined ? undefined : resolveModel(b, models);
32
32
  };
33
33
  const resolveTier = (name) => {
@@ -44,26 +44,30 @@ export function expandTiers(models, tiers) {
44
44
  }
45
45
  return undefined;
46
46
  };
47
+ const shadowed = (key) => Object.hasOwn(out, key) && Boolean(out[key]);
48
+ const put = (key, m) => {
49
+ Object.defineProperty(out, key, { value: m, writable: true, enumerable: true, configurable: true });
50
+ };
47
51
  const tierNames = new Set([...DEFAULT_TIER_ORDER, ...Object.keys(tiers)]);
48
52
  for (const name of tierNames) {
49
- if (out[name])
53
+ if (shadowed(name))
50
54
  continue;
51
55
  const m = resolveTier(name);
52
56
  if (m)
53
- out[name] = m;
57
+ put(name, m);
54
58
  }
55
59
  for (const [alias, tier] of Object.entries(CC_MODEL_TIER_ALIASES)) {
56
- if (out[alias])
60
+ if (shadowed(alias))
57
61
  continue;
58
62
  const m = resolveTier(tier);
59
63
  if (m)
60
- out[alias] = m;
64
+ put(alias, m);
61
65
  }
62
- if (!out["best"]) {
66
+ if (!shadowed("best")) {
63
67
  for (const name of DEFAULT_TIER_ORDER) {
64
68
  const m = bound(name);
65
69
  if (m) {
66
- out["best"] = m;
70
+ put("best", m);
67
71
  break;
68
72
  }
69
73
  }