@sema-agent/core 7.8.0 → 7.9.1

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 (103) hide show
  1. package/CHANGELOG.md +75 -1
  2. package/dist/agents/subagent.d.ts +1 -1
  3. package/dist/core/ask-origin.d.ts +16 -1
  4. package/dist/core/ask-origin.js +5 -1
  5. package/dist/core/checkpoint-store.d.ts +36 -35
  6. package/dist/core/effective-path-target.d.ts +43 -0
  7. package/dist/core/effective-path-target.js +56 -0
  8. package/dist/core/engine-notice.d.ts +8 -0
  9. package/dist/core/fs-write-gate-policy.js +2 -1
  10. package/dist/core/gate-lanes.d.ts +1 -0
  11. package/dist/core/gate-lanes.js +60 -28
  12. package/dist/core/gate-outcome.d.ts +8 -4
  13. package/dist/core/gate-outcome.js +2 -1
  14. package/dist/core/governance-codes.d.ts +1 -1
  15. package/dist/core/governance-codes.js +2 -0
  16. package/dist/core/hooks.d.ts +66 -32
  17. package/dist/core/mcp-injection-drop.d.ts +74 -0
  18. package/dist/core/mcp-injection-drop.js +27 -0
  19. package/dist/core/permission-rule-consent.d.ts +29 -36
  20. package/dist/core/permission-rule-consent.js +110 -60
  21. package/dist/core/permission-rule-model.d.ts +311 -96
  22. package/dist/core/permission-rule-model.js +309 -50
  23. package/dist/core/permission-rule-org.d.ts +19 -12
  24. package/dist/core/permission-rule-org.js +13 -16
  25. package/dist/core/permission-rule-provider.d.ts +10 -6
  26. package/dist/core/permission-rule-provider.js +12 -8
  27. package/dist/core/permission-rule-session.d.ts +7 -6
  28. package/dist/core/permission-rule-session.js +29 -10
  29. package/dist/core/permission-rule-store.d.ts +40 -18
  30. package/dist/core/permission-rule-store.js +68 -42
  31. package/dist/core/permission-rule-sync.d.ts +9 -3
  32. package/dist/core/permission-rule-sync.js +29 -25
  33. package/dist/core/permission-rule-syntax.d.ts +30 -0
  34. package/dist/core/permission-rule-syntax.js +44 -0
  35. package/dist/core/permission-rules.d.ts +56 -33
  36. package/dist/core/permission-rules.js +88 -55
  37. package/dist/core/persisted-rule-arms.d.ts +61 -0
  38. package/dist/core/persisted-rule-arms.js +50 -0
  39. package/dist/core/runner/active-skill-scope.js +2 -1
  40. package/dist/core/runner/contracts.d.ts +21 -2
  41. package/dist/core/runner/permission-rule-lanes.d.ts +42 -27
  42. package/dist/core/runner/permission-rule-lanes.js +25 -21
  43. package/dist/core/runner/prepare-gate-stations.js +1 -1
  44. package/dist/core/runner/prepare-safety-scan.js +8 -2
  45. package/dist/core/runner/prepare-task.js +1 -1
  46. package/dist/core/runner/resume-admission.d.ts +53 -0
  47. package/dist/core/runner/resume-admission.js +83 -0
  48. package/dist/core/runner/resume-apply.d.ts +50 -0
  49. package/dist/core/runner/resume-apply.js +184 -0
  50. package/dist/core/runner/resume-checkpoint-screen.d.ts +18 -0
  51. package/dist/core/runner/resume-checkpoint-screen.js +108 -0
  52. package/dist/core/runner/resume-claim.d.ts +32 -0
  53. package/dist/core/runner/resume-claim.js +27 -0
  54. package/dist/core/runner/resume-internals-and-config.d.ts +33 -0
  55. package/dist/core/runner/resume-internals-and-config.js +50 -0
  56. package/dist/core/runner/resume-policy-outcome.d.ts +31 -0
  57. package/dist/core/runner/resume-policy-outcome.js +127 -0
  58. package/dist/core/runner/resume-preflight.d.ts +40 -0
  59. package/dist/core/runner/resume-preflight.js +122 -0
  60. package/dist/core/runner/resume-review-outcome.d.ts +30 -0
  61. package/dist/core/runner/resume-review-outcome.js +88 -0
  62. package/dist/core/runner/run-harness-handlers.js +1 -1
  63. package/dist/core/runner/runtask.d.ts +20 -0
  64. package/dist/core/runner/runtask.js +85 -734
  65. package/dist/core/runner/session-rule-policy.js +2 -1
  66. package/dist/core/runner/tool-end-body.d.ts +12 -5
  67. package/dist/core/runner/tool-end-body.js +5 -5
  68. package/dist/core/runner/tool-face-overlay.js +22 -3
  69. package/dist/core/runner/tool-output-projection.d.ts +9 -6
  70. package/dist/core/runner/tool-output-projection.js +1 -18
  71. package/dist/core/runner/turn-attachments.d.ts +2 -2
  72. package/dist/core/sensitive-path-policy.js +5 -3
  73. package/dist/core/shell-lexer.d.ts +47 -0
  74. package/dist/core/shell-lexer.js +478 -0
  75. package/dist/core/shell-scan.d.ts +60 -0
  76. package/dist/core/shell-scan.js +183 -0
  77. package/dist/core/shell-wrapper-table.d.ts +297 -0
  78. package/dist/core/shell-wrapper-table.js +58 -0
  79. package/dist/core/store-contracts/permission-rule-sync-contract.js +29 -10
  80. package/dist/core/tool-catalog-entries.js +6 -6
  81. package/dist/core/tool-face.d.ts +80 -4
  82. package/dist/core/tool-face.js +10 -0
  83. package/dist/core/tool-policy.d.ts +1 -6
  84. package/dist/core/tool-registry.d.ts +8 -11
  85. package/dist/core/tool-registry.js +5 -2
  86. package/dist/core/tool-roster.d.ts +20 -2
  87. package/dist/core/tool-roster.js +36 -8
  88. package/dist/index.d.ts +7 -4
  89. package/dist/index.js +6 -3
  90. package/dist/stores/file/checkpoint-store.js +1 -1
  91. package/dist/stores/file/permission-rule-store.d.ts +28 -23
  92. package/dist/stores/file/permission-rule-store.js +64 -16
  93. package/dist/tools/fs/bash-readonly-classifier.d.ts +1 -1
  94. package/dist/tools/fs/fs-pdf.d.ts +1 -1
  95. package/dist/tools/fs/fs-search-tools.d.ts +3 -2
  96. package/dist/tools/fs/fs-search-tools.js +17 -9
  97. package/dist/tools/fs/index.js +1 -1
  98. package/dist/tools/fs/notebook.d.ts +1 -1
  99. package/dist/tools/fs/safety.d.ts +1 -1
  100. package/dist/tools/fs/search.d.ts +0 -8
  101. package/dist/tools/fs/search.js +0 -23
  102. package/package.json +1 -1
  103. package/test/export-surface.snapshot.json +99 -11
@@ -1,8 +1,33 @@
1
1
  import { homedir } from "node:os";
2
- import { parsePermissionRule } from "./permission-rules.js";
2
+ import { parsePermissionRule } from "./permission-rule-syntax.js";
3
3
  import { compileReadDeny } from "../tools/fs/read-deny.js";
4
4
  import { carriesShellRedirection, parseLeadingCommandName, splitShellCompoundSegments } from "../tools/fs/bash-readonly-classifier.js";
5
5
  import { inlineUntrusted } from "./untrusted-text.js";
6
+ import { pathTargetOf } from "./tool-registry.js";
7
+ import { readShellCommand } from "./shell-lexer.js";
8
+ export const RULE_BEHAVIORS = ["deny", "ask", "allow"];
9
+ const RULE_BEHAVIOR_SET = new Set(RULE_BEHAVIORS);
10
+ export function isRuleBehavior(v) {
11
+ return RULE_BEHAVIOR_SET.has(v);
12
+ }
13
+ export const RULE_BEHAVIOR_PRECEDENCE = {
14
+ deny: 0,
15
+ ask: 1,
16
+ allow: 2,
17
+ };
18
+ export const RULE_BEHAVIORS_BY_PRECEDENCE = [...RULE_BEHAVIORS].sort((a, b) => RULE_BEHAVIOR_PRECEDENCE[a] - RULE_BEHAVIOR_PRECEDENCE[b]);
19
+ export function readRowBehavior(v) {
20
+ if (v === undefined)
21
+ return "allow";
22
+ return isRuleBehavior(v) ? v : undefined;
23
+ }
24
+ export const COMMAND_RULE_TOOL = "Bash";
25
+ export const READ_RULE_TOOL = "Read";
26
+ export function ruleToolGrammarOf(toolName) {
27
+ if (toolName === COMMAND_RULE_TOOL)
28
+ return "command";
29
+ return pathTargetOf(toolName) !== undefined ? "path" : undefined;
30
+ }
6
31
  export const MAX_RULE_TEXT_CHARS = 512;
7
32
  export const BARE_INTERPRETER_NAMES = new Set([
8
33
  "node", "deno", "bun", "python", "python2", "python3", "perl", "ruby", "php", "osascript",
@@ -120,7 +145,6 @@ function reject(code, message) {
120
145
  }
121
146
  const RULE_LANE_FLOOR = { pathPrefixedNameIsText: true };
122
147
  const MATCH_READING = { terminator: "keep", quotedOperatorsAreText: false, redirection: "reject" };
123
- const PROGRAM_RUNS_READING = { terminator: "strip", quotedOperatorsAreText: true, redirection: "reject" };
124
148
  const OFFER_READING = { terminator: "keep", quotedOperatorsAreText: false, redirection: "tolerate" };
125
149
  function commandBasename(name) {
126
150
  return name.slice(name.lastIndexOf("/") + 1);
@@ -187,7 +211,11 @@ export function renderUntrustedCommandText(text, maxLen = DISCLOSED_RULE_TEXT_MA
187
211
  export function hasUnrenderableCharacters(text) {
188
212
  return CONTROL_CHARS_RE.test(text);
189
213
  }
190
- export function parseAllowRuleText(text, opts) {
214
+ export function parseRuleText(text, behavior) {
215
+ if (!isRuleBehavior(behavior)) {
216
+ return reject("invalid.grammar", `rule "${text}" was parsed under behavior ${escapeForDisclosure(behavior)}, which is not one of ${RULE_BEHAVIORS.join("/")}`);
217
+ }
218
+ const tighten = behavior !== "allow";
191
219
  if (text.length > MAX_RULE_TEXT_CHARS) {
192
220
  return reject("invalid.too_long", `rule text exceeds ${MAX_RULE_TEXT_CHARS} characters`);
193
221
  }
@@ -198,28 +226,11 @@ export function parseAllowRuleText(text, opts) {
198
226
  if (parsed.ruleContent === undefined) {
199
227
  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`);
200
228
  }
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}")`);
229
+ if (parsed.toolName !== COMMAND_RULE_TOOL) {
230
+ if (ruleToolGrammarOf(parsed.toolName) !== "path") {
231
+ return reject("unsupported.tool", `only Bash command rules and rules on path-targeting tools (Read, Edit, Write, …) have a rule form in this version (got "${parsed.toolName}")`);
210
232
  }
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
- }
221
- if (parsed.toolName !== "Bash") {
222
- return reject("unsupported.tool", `only Bash and Read rules are supported in this version (got "${parsed.toolName}")`);
233
+ return parsePathRuleContent(text, parsed.toolName, parsed.ruleContent, behavior);
223
234
  }
224
235
  const content = parsed.ruleContent;
225
236
  const prefixBody = /^(.+):\*$/.exec(content)?.[1];
@@ -256,7 +267,7 @@ export function parseAllowRuleText(text, opts) {
256
267
  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
268
  }
258
269
  const head = commandBasename(shape.names[shape.names.length - 1] ?? "");
259
- if ((match === "prefix" || match === "wildcard") && BARE_INTERPRETER_NAMES.has(head) && opts?.direction !== "tighten") {
270
+ if ((match === "prefix" || match === "wildcard") && BARE_INTERPRETER_NAMES.has(head) && !tighten) {
260
271
  return reject("invalid.bare_interpreter_prefix", shape.segments.length > 1
261
272
  ? `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)`
262
273
  : `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)`);
@@ -265,12 +276,132 @@ export function parseAllowRuleText(text, opts) {
265
276
  if (command === undefined) {
266
277
  return reject("invalid.unbalanced_quotes", `rule "${text}" has unbalanced quoting, so the command it names is not determinable`);
267
278
  }
268
- return { rule: { rule: formatAllowRuleText(command, match), tool: "Bash", match, command } };
279
+ return { rule: { behavior, rule: formatRuleText(command, match, COMMAND_RULE_TOOL), tool: COMMAND_RULE_TOOL, match, command } };
269
280
  }
270
- export function formatAllowRuleText(command, match) {
281
+ export function formatRuleText(command, match, tool) {
271
282
  if (match === "subpath")
272
- return `Read(//${command.startsWith("/") ? command.slice(1) : command}/**)`;
273
- return `Bash(${command}${match === "prefix" ? ":*" : match === "wildcard" ? " *" : ""})`;
283
+ return `${tool}(//${command.startsWith("/") ? command.slice(1) : command}/**)`;
284
+ if (match === "path")
285
+ return `${tool}(${command})`;
286
+ return `${tool}(${command}${match === "prefix" ? ":*" : match === "wildcard" ? " *" : ""})`;
287
+ }
288
+ function parsePathRuleContent(text, tool, content, behavior) {
289
+ const dirForm = /^\/\/(.+)\/\*\*$/.exec(content);
290
+ if (dirForm !== null) {
291
+ const bodyR = dirForm[1];
292
+ if (bodyR.includes("*")) {
293
+ return reject("unsupported.wildcard", `a \`*\` inside the directory body matches anything at that position, which has no rule form ("${text}")`);
294
+ }
295
+ const segments = bodyR.split("/");
296
+ if (segments.some((sg) => sg === "" || sg.trim() === "")) {
297
+ 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`);
298
+ }
299
+ if (segments.some((sg) => sg === "." || sg === "..")) {
300
+ 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`);
301
+ }
302
+ if (behavior === "allow" && tool !== READ_RULE_TOOL) {
303
+ return reject("unsupported.tool", `an allow rule on a path tool speaks for the Read directory form only — a standing approval of a WRITE by directory is not a rule form ("${text}"); write it as a deny or an ask, or approve the call`);
304
+ }
305
+ const dir = "/" + bodyR;
306
+ return { rule: { behavior, rule: formatRuleText(dir, "subpath", tool), tool, match: "subpath", command: dir } };
307
+ }
308
+ if (behavior === "allow") {
309
+ return tool === READ_RULE_TOOL
310
+ ? 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`)
311
+ : reject("unsupported.tool", `an allow rule on "${tool}" has no rule form — only the Read directory form is a standing approval by path ("${text}"); write it as a deny or an ask, or approve the call`);
312
+ }
313
+ if (content.trim() !== content || content === "") {
314
+ return reject("invalid.grammar", `the path pattern of "${text}" is empty or carries surrounding whitespace — the pattern is stored as spelled, so the spelling must be the one meant`);
315
+ }
316
+ if (content.startsWith("!"))
317
+ return reject("invalid.grammar", `a negated path pattern ("${text}") is not a rule — a rule says what it reaches, never what it excepts`);
318
+ if (/[?[\]{}]/.test(content)) {
319
+ return reject("unsupported.wildcard", `the path pattern of "${text}" carries a glob class (\`?\`, \`[]\`, \`{}\`) this lane does not read — only \`*\` inside a segment and \`**\` as a whole segment have a rule form`);
320
+ }
321
+ const body = content.startsWith("//") ? content.slice(2) : content.startsWith("~/") ? content.slice(2) : content.startsWith("/") ? content.slice(1) : content.startsWith("./") ? content.slice(2) : content;
322
+ const segments = body.split("/");
323
+ if (segments.some((sg) => sg === "" || sg.trim() === "")) {
324
+ return reject("invalid.grammar", `the path pattern of "${text}" carries an empty path segment — the lexical normal form is the only accepted spelling`);
325
+ }
326
+ if (segments.some((sg) => sg === "." || sg === "..")) {
327
+ return reject("invalid.path_traversal", `the path pattern of "${text}" carries a \`.\` or \`..\` segment — the lexical normal form is the only accepted spelling, and a traversal segment is not normalized away`);
328
+ }
329
+ if (segments.some((sg) => sg.includes("**") && sg !== "**")) {
330
+ return reject("unsupported.wildcard", `\`**\` in the path pattern of "${text}" must stand as a whole segment (\`a/**/b\`), never inside one`);
331
+ }
332
+ return { rule: { behavior, rule: formatRuleText(content, "path", tool), tool, match: "path", command: content } };
333
+ }
334
+ function resolvePathPattern(pattern, bases) {
335
+ if (pattern.startsWith("//"))
336
+ return "/" + pattern.slice(2);
337
+ const under = (base, rest) => {
338
+ const normal = base === undefined ? undefined : lexicalNormalAbsolutePathOf(base);
339
+ if (normal === undefined)
340
+ return undefined;
341
+ return (normal === "/" ? "" : normal) + "/" + rest;
342
+ };
343
+ if (pattern.startsWith("~/"))
344
+ return under(bases.home ?? homedir(), pattern.slice(2));
345
+ if (pattern.startsWith("/"))
346
+ return under(bases.root, pattern.slice(1));
347
+ return under(bases.cwd ?? bases.root, pattern.startsWith("./") ? pattern.slice(2) : pattern);
348
+ }
349
+ export function isUsablePathBase(base) {
350
+ return base !== undefined && lexicalNormalAbsolutePathOf(base) !== undefined;
351
+ }
352
+ function segmentGlobMatches(glob, segment) {
353
+ if (!glob.includes("*"))
354
+ return glob === segment;
355
+ const parts = glob.split("*");
356
+ let at = 0;
357
+ for (let i = 0; i < parts.length; i++) {
358
+ const part = parts[i];
359
+ if (i === 0) {
360
+ if (!segment.startsWith(part))
361
+ return false;
362
+ at = part.length;
363
+ continue;
364
+ }
365
+ if (i === parts.length - 1)
366
+ return part === "" || (segment.endsWith(part) && segment.length - part.length >= at);
367
+ const k = segment.indexOf(part, at);
368
+ if (k === -1)
369
+ return false;
370
+ at = k + part.length;
371
+ }
372
+ return true;
373
+ }
374
+ function globSegmentsReach(globs, target) {
375
+ const seen = new Set();
376
+ const width = target.length + 1;
377
+ const walk = (g, t) => {
378
+ if (g === globs.length)
379
+ return true;
380
+ const key = g * width + t;
381
+ if (seen.has(key))
382
+ return false;
383
+ seen.add(key);
384
+ if (globs[g] === "**")
385
+ return walk(g + 1, t) || (t < target.length && walk(g, t + 1));
386
+ if (t >= target.length)
387
+ return false;
388
+ return segmentGlobMatches(globs[g], target[t]) && walk(g + 1, t + 1);
389
+ };
390
+ return walk(0, 0);
391
+ }
392
+ export function pathRuleReaches(rule, target, bases) {
393
+ if (rule.match === "subpath")
394
+ return directoryRuleAdmits(rule, target);
395
+ if (rule.match !== "path")
396
+ return false;
397
+ if (!isLexicalNormalAbsoluteDir(target) && target !== "/")
398
+ return false;
399
+ const resolved = resolvePathPattern(rule.command, bases);
400
+ if (resolved === undefined)
401
+ return false;
402
+ const globs = resolved.split("/").filter((sg) => sg !== "");
403
+ const segments = target.split("/").filter((sg) => sg !== "");
404
+ return globSegmentsReach(globs, segments);
274
405
  }
275
406
  function hasUnescapedStar(s) {
276
407
  for (let i = 0; i < s.length; i++) {
@@ -287,11 +418,97 @@ function hasUnescapedStar(s) {
287
418
  export function ruleAdmitsCommand(rule, command) {
288
419
  return admitsUnder(rule, command, MATCH_READING);
289
420
  }
290
- export function ruleAdmitsProgramRun(rule, command) {
291
- return admitsUnder(rule, command, PROGRAM_RUNS_READING);
421
+ export const PROGRAM_RUN_REACHES = ["reached", "not_reached", "unreadable"];
422
+ const REACHED = { reach: "reached" };
423
+ const NOT_REACHED = { reach: "not_reached" };
424
+ export function programRunReachOf(rule, command) {
425
+ if (rule.match === "subpath" || rule.match === "path")
426
+ return NOT_REACHED;
427
+ const body = readRuleBody(rule.command);
428
+ if (body === undefined)
429
+ return NOT_REACHED;
430
+ const shape = readCommandMemo(command);
431
+ const exact = rule.match === "exact";
432
+ if (body.segments.length > 1) {
433
+ const broken = shape.segments.find((s) => s.unreadable !== undefined);
434
+ if (broken !== undefined)
435
+ return { reach: "unreadable", reason: broken.unreadable };
436
+ const hidden = shape.segments.find((s) => s.peelUnreadable !== undefined);
437
+ const shaped = !shape.grouped && shape.segments.length === body.segments.length && shape.connectors.every((c, i) => c === body.connectors[i]);
438
+ if (!shaped)
439
+ return hidden === undefined ? NOT_REACHED : { reach: "unreadable", reason: hidden.peelUnreadable };
440
+ let outcome = REACHED;
441
+ for (let i = 0; i < body.segments.length; i++) {
442
+ const r = segmentReachOf(body.segments[i].argv, shape.segments[i], exact || i < body.segments.length - 1);
443
+ if (r.reach === "not_reached")
444
+ return hidden === undefined ? NOT_REACHED : { reach: "unreadable", reason: hidden.peelUnreadable };
445
+ if (r.reach === "unreadable")
446
+ outcome = r;
447
+ }
448
+ return outcome;
449
+ }
450
+ const bodyArgv = body.segments[0].argv;
451
+ let unreadable;
452
+ for (const segment of shape.segments) {
453
+ const r = segmentReachOf(bodyArgv, segment, exact);
454
+ if (r.reach === "reached")
455
+ return REACHED;
456
+ if (r.reach === "unreadable")
457
+ unreadable ??= r;
458
+ }
459
+ return unreadable ?? NOT_REACHED;
460
+ }
461
+ export function ruleReachesProgramRun(rule, command) {
462
+ return programRunReachOf(rule, command).reach === "reached";
463
+ }
464
+ function segmentReachOf(bodyArgv, segment, exact) {
465
+ if (segment.unreadable !== undefined)
466
+ return { reach: "unreadable", reason: segment.unreadable };
467
+ let unreadable;
468
+ for (const argv of [segment.argv, ...segment.peeled]) {
469
+ const r = argvReachOf(bodyArgv, argv, exact);
470
+ if (r.reach === "reached")
471
+ return REACHED;
472
+ if (r.reach === "unreadable")
473
+ unreadable ??= r;
474
+ }
475
+ if (segment.peelUnreadable !== undefined)
476
+ unreadable ??= { reach: "unreadable", reason: segment.peelUnreadable };
477
+ return unreadable ?? NOT_REACHED;
478
+ }
479
+ function argvReachOf(bodyArgv, argv, exact) {
480
+ for (let i = 0; i < bodyArgv.length; i++) {
481
+ const word = argv[i];
482
+ const wanted = bodyArgv[i];
483
+ if (word === undefined)
484
+ return NOT_REACHED;
485
+ if (word.expands !== false) {
486
+ if (word.raw === wanted.raw)
487
+ continue;
488
+ return { reach: "unreadable", reason: i === 0 ? "the program name is an expansion" : `an expansion (${word.raw}) stands where the rule reads a word` };
489
+ }
490
+ if (word.text !== wanted.text)
491
+ return NOT_REACHED;
492
+ }
493
+ if (!exact || argv.length === bodyArgv.length)
494
+ return REACHED;
495
+ const loose = argv.slice(bodyArgv.length).find((w) => w.expands === "many");
496
+ return loose === undefined ? NOT_REACHED : { reach: "unreadable", reason: `an unquoted expansion (${loose.raw}) may expand to nothing` };
497
+ }
498
+ function readRuleBody(body) {
499
+ const shape = readShellCommand(body);
500
+ if (shape.grouped || shape.segments.length === 0)
501
+ return undefined;
502
+ return shape.segments.every((s) => s.unreadable === undefined && s.argv.length > 0) ? shape : undefined;
503
+ }
504
+ let lastRead;
505
+ function readCommandMemo(command) {
506
+ if (lastRead?.command !== command)
507
+ lastRead = { command, shape: readShellCommand(command) };
508
+ return lastRead.shape;
292
509
  }
293
510
  function admitsUnder(rule, command, reading) {
294
- if (rule.match === "subpath")
511
+ if (rule.match === "subpath" || rule.match === "path")
295
512
  return false;
296
513
  const shape = ruleLaneShapeOf(command, reading);
297
514
  if ("reject" in shape)
@@ -332,10 +549,6 @@ export function ruleBreadthWarningsOf(rule) {
332
549
  }
333
550
  return [];
334
551
  }
335
- export function ruleLaneSegmentsOf(command) {
336
- const shape = ruleLaneShapeOf(command, PROGRAM_RUNS_READING);
337
- return "reject" in shape ? undefined : shape.segments;
338
- }
339
552
  export function pathWithinRoot(path, root) {
340
553
  if (path === root)
341
554
  return true;
@@ -350,9 +563,8 @@ export function lexicalNormalAbsolutePathOf(path) {
350
563
  if (seg === "" || seg === ".")
351
564
  continue;
352
565
  if (seg === "..") {
353
- if (out.length === 0)
354
- return undefined;
355
- out.pop();
566
+ if (out.length > 0)
567
+ out.pop();
356
568
  continue;
357
569
  }
358
570
  out.push(seg);
@@ -366,7 +578,7 @@ function isLexicalNormalAbsoluteDir(path) {
366
578
  return segments.length > 0 && segments.every((s) => s !== "" && s !== "." && s !== "..");
367
579
  }
368
580
  export function directoryRuleAdmits(rule, path) {
369
- if (rule.tool !== "Read" || rule.match !== "subpath")
581
+ if (rule.match !== "subpath")
370
582
  return false;
371
583
  if (!isLexicalNormalAbsoluteDir(rule.command))
372
584
  return false;
@@ -483,12 +695,12 @@ export function isRuleLive(rule) {
483
695
  export function eligibleContext(rule, call) {
484
696
  return rule.tool === call.tool && scopeCoversCwd(rule.scope, call.cwd, call.sessionId);
485
697
  }
486
- export function eligiblePersisted(rule, call) {
487
- return isRuleLive(rule) && eligibleContext(rule, call);
698
+ export function eligiblePersisted(rule, call, behavior) {
699
+ return rule.behavior === behavior && isRuleLive(rule) && eligibleContext(rule, call);
488
700
  }
489
701
  function firstEligibleAdmittingRule(rules, commandText, call) {
490
702
  for (const rule of rules) {
491
- if (!eligiblePersisted(rule, call))
703
+ if (!eligiblePersisted(rule, call, "allow"))
492
704
  continue;
493
705
  if (ruleAdmitsCommand(rule, commandText))
494
706
  return rule;
@@ -521,13 +733,60 @@ export function findAdmittingRule(rules, call) {
521
733
  }
522
734
  function firstEligibleDirectoryRule(rules, directory, call) {
523
735
  for (const rule of rules) {
524
- if (!eligiblePersisted(rule, { tool: "Read", cwd: call.cwd, sessionId: call.sessionId }))
736
+ if (!eligiblePersisted(rule, { tool: "Read", cwd: call.cwd, sessionId: call.sessionId }, "allow"))
525
737
  continue;
526
738
  if (directoryRuleAdmits(rule, directory))
527
739
  return rule;
528
740
  }
529
741
  return undefined;
530
742
  }
743
+ export function adjudicatePersistedRules(rules, call) {
744
+ for (const behavior of RULE_BEHAVIORS_BY_PRECEDENCE) {
745
+ if (behavior === "allow") {
746
+ const covering = findAdmittingRule(rules, call);
747
+ if (covering !== undefined)
748
+ return { behavior, rules: covering };
749
+ continue;
750
+ }
751
+ let unreadable;
752
+ for (const rule of rules) {
753
+ if (!eligiblePersisted(rule, call, behavior))
754
+ continue;
755
+ const outcome = programRunReachOf(rule, call.command);
756
+ if (outcome.reach === "reached")
757
+ return { behavior, rules: [rule] };
758
+ if (outcome.reach === "unreadable")
759
+ unreadable ??= { behavior: "ask", rules: [rule], unreadable: outcome.reason };
760
+ }
761
+ if (unreadable !== undefined)
762
+ return unreadable;
763
+ }
764
+ return undefined;
765
+ }
766
+ export function adjudicatePersistedPathRules(rules, call, targets, bases) {
767
+ for (const behavior of RULE_BEHAVIORS_BY_PRECEDENCE) {
768
+ if (behavior === "allow") {
769
+ if (targets.allow === undefined || call.tool !== READ_RULE_TOOL)
770
+ continue;
771
+ for (const rule of rules) {
772
+ if (!eligiblePersisted(rule, call, behavior))
773
+ continue;
774
+ if (directoryRuleAdmits(rule, targets.allow))
775
+ return { behavior, rules: [rule] };
776
+ }
777
+ continue;
778
+ }
779
+ if (targets.tighten === undefined)
780
+ continue;
781
+ for (const rule of rules) {
782
+ if (!eligiblePersisted(rule, call, behavior))
783
+ continue;
784
+ if (pathRuleReaches(rule, targets.tighten, bases))
785
+ return { behavior, rules: [rule] };
786
+ }
787
+ }
788
+ return undefined;
789
+ }
531
790
  export function segmentCoverageOf(command, rules, call) {
532
791
  const folded = foldSpacing(command);
533
792
  if (folded === undefined)
@@ -569,7 +828,7 @@ export function suggestRulesForCommand(command, ctx) {
569
828
  if (folded === undefined)
570
829
  return [];
571
830
  const offers = [];
572
- const exact = parseAllowRuleText(formatAllowRuleText(folded, "exact"));
831
+ const exact = parseRuleText(formatRuleText(folded, "exact", COMMAND_RULE_TOOL), "allow");
573
832
  if ("rule" in exact && exact.rule.match === "exact" && ruleAdmitsCommand(exact.rule, command)) {
574
833
  offers.push({ kind: "single", rule: exact.rule.rule, match: "exact", command: exact.rule.command });
575
834
  }
@@ -589,7 +848,7 @@ export function suggestRulesForCommand(command, ctx) {
589
848
  }
590
849
  if (denied)
591
850
  return undefined;
592
- const parsed = parseAllowRuleText(formatAllowRuleText(directory, "subpath"));
851
+ const parsed = parseRuleText(formatRuleText(directory, "subpath", READ_RULE_TOOL), "allow");
593
852
  if (!("rule" in parsed) || parsed.rule.match !== "subpath")
594
853
  return undefined;
595
854
  if (!directoryRuleAdmits(parsed.rule, directory))
@@ -600,7 +859,7 @@ export function suggestRulesForCommand(command, ctx) {
600
859
  if (offers.length === 1) {
601
860
  const body = bodyOf(tokensOf(folded));
602
861
  if (body !== undefined) {
603
- const parsed = parseAllowRuleText(formatAllowRuleText(body, "wildcard"));
862
+ const parsed = parseRuleText(formatRuleText(body, "wildcard", COMMAND_RULE_TOOL), "allow");
604
863
  if ("rule" in parsed && parsed.rule.match === "wildcard" && ruleAdmitsCommand(parsed.rule, command)) {
605
864
  offers.push({ kind: "single", rule: parsed.rule.rule, match: "wildcard", command: parsed.rule.command });
606
865
  }
@@ -627,12 +886,12 @@ export function suggestRulesForCommand(command, ctx) {
627
886
  return undefined;
628
887
  const body = bodyOf(tokensOf(segment));
629
888
  if (body !== undefined) {
630
- const parsed = parseAllowRuleText(formatAllowRuleText(body, "wildcard"));
889
+ const parsed = parseRuleText(formatRuleText(body, "wildcard", COMMAND_RULE_TOOL), "allow");
631
890
  if ("rule" in parsed && parsed.rule.match === "wildcard" && ruleAdmitsCommand(parsed.rule, segment)) {
632
891
  return { rule: parsed.rule.rule, match: "wildcard", command: parsed.rule.command };
633
892
  }
634
893
  }
635
- const segExact = parseAllowRuleText(formatAllowRuleText(segment, "exact"));
894
+ const segExact = parseRuleText(formatRuleText(segment, "exact", COMMAND_RULE_TOOL), "allow");
636
895
  if ("rule" in segExact && segExact.rule.match === "exact" && ruleAdmitsCommand(segExact.rule, segment)) {
637
896
  return { rule: segExact.rule.rule, match: "exact", command: segExact.rule.command };
638
897
  }
@@ -655,7 +914,7 @@ export function suggestRulesForCommand(command, ctx) {
655
914
  const batchRules = minted.slice(0, 5);
656
915
  if (batchRules.length > 0) {
657
916
  const parsedBatch = batchRules.flatMap((r) => {
658
- const p = parseAllowRuleText(r.rule);
917
+ const p = parseRuleText(r.rule, "allow");
659
918
  return "rule" in p ? [p.rule] : [];
660
919
  });
661
920
  const uncoveredDetail = [];
@@ -6,7 +6,7 @@
6
6
  * convergent merge). So the org layer has NO CRDT, no tombstones and no join: the server is the single
7
7
  * authority, a client holds a read-only versioned snapshot, an update is a whole replacement (revision
8
8
  * strictly ordered), and a rollback is a NEW revision carrying old content. Cross-layer tombstones are
9
- * structurally impossible — an org rule never enters `PersistedAllowRule`, and an org snapshot replace
9
+ * structurally impossible — an org rule never enters `PersistedRule`, and an org snapshot replace
10
10
  * never touches a personal store's bytes. The two layers meet only at the CONSUMPTION site, where org
11
11
  * deny/ask folds strict-side: **org deny > org ask (non-dismissable) > personal allow rule > bare ask**.
12
12
  *
@@ -35,7 +35,7 @@
35
35
  * accept revision N−1 after a restart. Signature / predecessor chaining belongs to the privilege-
36
36
  * separation ticket; the high-water mark is the floor that needs no key distribution.
37
37
  */
38
- import { type PersistedAllowRule, type RuleRejectCode } from "./permission-rule-model.js";
38
+ import { type PersistedRule, type RuleRejectCode } from "./permission-rule-model.js";
39
39
  import type { PermissionRuleStore } from "./permission-rule-provider.js";
40
40
  /** One org rule. There is structurally no allow bucket (design/179 §9: the org layer only tightens). */
41
41
  export interface OrgPermissionRule {
@@ -187,8 +187,10 @@ export declare function unenforceableOrgRules(rules: readonly OrgPermissionRule[
187
187
  /**
188
188
  * design/182 §7.2 — which org rule speaks for this command, if any. Deny outranks ask; within a
189
189
  * behavior the first textual match wins (reporting order only — all denies are the same one answer).
190
- * Matching uses the SAME parser and matcher the personal lane uses (tighten direction: an
191
- * interpreter-headed prefix deny like `Bash(node:*)` is a legitimately wide tightening and matches).
190
+ * Matching uses the SAME parser and the SAME tightening reach the personal deny/ask rows use
191
+ * ({@link programRunReachOf}: an interpreter-headed prefix deny like `Bash(node:*)` is a
192
+ * legitimately wide tightening and matches; a segment hit is a hit; a call the lexer cannot read
193
+ * against a rule is an ASK with the reason — fail closed, never silence).
192
194
  *
193
195
  * The skip below is a defensive floor, and what stands behind it is worth stating precisely: rules that
194
196
  * arrive from the org partition's `resolve` ({@link createOrgRulePartition}) have passed {@link validateOrgSnapshot} on BOTH the install
@@ -201,10 +203,15 @@ export declare function unenforceableOrgRules(rules: readonly OrgPermissionRule[
201
203
  export declare function orgRuleVerdictFor(rules: readonly OrgPermissionRule[], call: {
202
204
  tool: string;
203
205
  command: string;
204
- }): {
205
- behavior: "deny" | "ask";
206
- rule: string;
207
- } | undefined;
206
+ }): OrgRuleVerdict | undefined;
207
+ /** The org layer's answer for one call: which rule spoke, with what behavior — and, when the call could
208
+ * not be READ against the org's rules, the lexer's reason beside the first rule it could not judge
209
+ * (`behavior` is then `ask`: the org published a rule that may reach this call, and nobody can say). */
210
+ export interface OrgRuleVerdict {
211
+ readonly behavior: "deny" | "ask";
212
+ readonly rule: string;
213
+ readonly unreadable?: string;
214
+ }
208
215
  /**
209
216
  * design/182 §7.3 / design/389 — the SHADOW predicate the unified store's read face applies to every
210
217
  * personal allow row: does an org DENY reach this rule's own command pattern? An org deny does not
@@ -212,8 +219,8 @@ export declare function orgRuleVerdictFor(rules: readonly OrgPermissionRule[], c
212
219
  * OVERRIDE it, not to self-declare its removal — so a shadowed rule reports `shadowed-by-org` and comes
213
220
  * back by itself when the org deny is withdrawn.
214
221
  *
215
- * The SAME reach the gate uses (`orgRuleReaches`), so the view and the decision cannot disagree about
216
- * whether a deny touches this rule at all. A personal rule may name a connector chain, and a deny on one
222
+ * The SAME reach the gate uses ({@link ruleReachesProgramRun}), so the view and the decision cannot
223
+ * disagree about whether a deny touches this rule at all. A personal rule may name a connector chain, and a deny on one
217
224
  * of its segments shadows it exactly as a deny on the whole does.
218
225
  *
219
226
  * WHAT `shadowed-by-org` DOES AND DOES NOT CLAIM: the predicate asks whether the deny reaches the
@@ -232,9 +239,9 @@ export declare function orgRuleVerdictFor(rules: readonly OrgPermissionRule[], c
232
239
  * reach it, and its `command` is a PATH that must not be fed to the shell lane's segment reader as
233
240
  * though it were a command line.
234
241
  */
235
- export declare function orgRuleShadows(orgRules: readonly OrgPermissionRule[], rule: Pick<PersistedAllowRule, "tool" | "command">): boolean;
242
+ export declare function orgRuleShadows(orgRules: readonly OrgPermissionRule[], rule: Pick<PersistedRule, "tool" | "command">): boolean;
236
243
  /** The shadow predicate with the org denies parsed ONCE — what a read over many rows uses. */
237
- export declare function compileOrgShadowPredicate(orgRules: readonly OrgPermissionRule[]): (rule: Pick<PersistedAllowRule, "tool" | "command">) => boolean;
244
+ export declare function compileOrgShadowPredicate(orgRules: readonly OrgPermissionRule[]): (rule: Pick<PersistedRule, "tool" | "command">) => boolean;
238
245
  /** Does this store carry a write face? Re-exported convenience for org-ticket integrations that need to
239
246
  * distinguish a read-only projection from a writable personal bucket without importing the writer seam. */
240
247
  export declare function isWritablePermissionRuleStore(store: PermissionRuleStore): boolean;
@@ -1,4 +1,4 @@
1
- import { escapeForDisclosure, parseAllowRuleText, ruleAdmitsProgramRun, ruleLaneSegmentsOf } from "./permission-rule-model.js";
1
+ import { escapeForDisclosure, parseRuleText, programRunReachOf, ruleReachesProgramRun } from "./permission-rule-model.js";
2
2
  import { writerOf } from "./permission-rule-store.js";
3
3
  export const ORG_UNAVAILABLE_DECISION_REASON = "org_unavailable";
4
4
  export const ORG_ADJUDICATION_TIMEOUT_MS = 15_000;
@@ -199,7 +199,7 @@ export function unenforceableOrgRules(rules) {
199
199
  for (const r of rules) {
200
200
  if (typeof r?.rule !== "string" || r.rule === "")
201
201
  continue;
202
- const parsed = parseAllowRuleText(r.rule, { direction: "tighten" });
202
+ const parsed = parseRuleText(r.rule, r.behavior);
203
203
  if ("reject" in parsed) {
204
204
  out.push({ rule: r.rule, code: parsed.reject.code });
205
205
  continue;
@@ -243,28 +243,26 @@ function validateOrgSnapshot(s, nowMs) {
243
243
  return undefined;
244
244
  }
245
245
  export function orgRuleVerdictFor(rules, call) {
246
- const segments = ruleLaneSegmentsOf(call.command);
247
246
  let ask;
247
+ let unreadable;
248
248
  for (const r of rules) {
249
- const parsed = parseAllowRuleText(r.rule, { direction: "tighten" });
249
+ const parsed = parseRuleText(r.rule, r.behavior);
250
250
  if ("reject" in parsed)
251
251
  continue;
252
252
  if (parsed.rule.tool !== call.tool)
253
253
  continue;
254
- if (!orgRuleReaches(parsed.rule, call.command, segments))
254
+ const outcome = programRunReachOf(parsed.rule, call.command);
255
+ if (outcome.reach === "not_reached")
255
256
  continue;
257
+ if (outcome.reach === "unreadable") {
258
+ unreadable ??= { behavior: "ask", rule: r.rule, unreadable: outcome.reason };
259
+ continue;
260
+ }
256
261
  if (r.behavior === "deny")
257
262
  return { behavior: "deny", rule: r.rule };
258
263
  ask ??= { behavior: "ask", rule: r.rule };
259
264
  }
260
- return ask;
261
- }
262
- function orgRuleReaches(rule, command, segments) {
263
- if (ruleAdmitsProgramRun(rule, command))
264
- return true;
265
- if (segments === undefined)
266
- return false;
267
- return segments.some((segment) => ruleAdmitsProgramRun(rule, segment));
265
+ return unreadable ?? ask;
268
266
  }
269
267
  export function orgRuleShadows(orgRules, rule) {
270
268
  return compileOrgShadowPredicate(orgRules)(rule);
@@ -274,15 +272,14 @@ export function compileOrgShadowPredicate(orgRules) {
274
272
  for (const d of orgRules) {
275
273
  if (d.behavior !== "deny")
276
274
  continue;
277
- const parsed = parseAllowRuleText(d.rule, { direction: "tighten" });
275
+ const parsed = parseRuleText(d.rule, "deny");
278
276
  if (!("reject" in parsed))
279
277
  denies.push(parsed.rule);
280
278
  }
281
279
  return (rule) => {
282
280
  if (rule.tool !== "Bash" || denies.length === 0)
283
281
  return false;
284
- const segments = ruleLaneSegmentsOf(rule.command);
285
- return denies.some((d) => d.tool === rule.tool && orgRuleReaches(d, rule.command, segments));
282
+ return denies.some((d) => d.tool === rule.tool && ruleReachesProgramRun(d, rule.command));
286
283
  };
287
284
  }
288
285
  export function isWritablePermissionRuleStore(store) {