@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
@@ -9,7 +9,34 @@
9
9
  * **一个** policy(不要按层各编译再组合:combinePolicies 的 fold 里 ask 胜 allow,跨 policy 的
10
10
  * 显式 allow 规则会被另一 policy 的 ask 吞掉)。
11
11
  *
12
- * 🔴 CC 的三处有意差(design/127 r2):
12
+ * ## The CONTENT-FORM command lane (the shell tool's own grammar)
13
+ *
14
+ * A `Bash(<command>)` / `Bash(<prefix>:*)` / `Bash(<body> *)` rule is not a `param:pattern` rule — its
15
+ * content is a COMMAND SPELLING in the persisted-rule model's grammar (`permission-rule-model.ts`), and
16
+ * this compiler now compiles it AS SUCH for the two tightening behaviors: the text goes through the ONE
17
+ * validator (`parseRuleText(text, behavior)` — the tightening floor, so `Bash(node:*)` as a deny is a
18
+ * legitimately wide tightening) and the call is judged with the ONE tightening reach
19
+ * (`programRunReachOf`: the whole command line or any program run in a connector chain — upstream's
20
+ * per-subcommand deny, the same instrument the org partition and the personal store's deny/ask rows use;
21
+ * a call the lexer cannot read against a rule is a fail-closed ask, never silence).
22
+ * It sits in the field-level tier of the evaluation order — right after the bare deny (resp. ask) — which
23
+ * is upstream's placement (`bs()` whole-tool deny → `AN(…,"deny")` field-level deny → `ph()` whole-tool
24
+ * ask → `AN(…,"ask")` field-level ask → allow). A content-form ALLOW is refused (`unsupported.bash_prefix`):
25
+ * a standing approval is a CONSENT record and lives in the persisted-rule store (the import door carries
26
+ * a settings allow list there); inside a policy fold an allow is "no objection" and would clear nothing.
27
+ *
28
+ * The PATH lane is the same shape for every tool the catalog declares a path target for: a
29
+ * `Edit(~/.sema/settings.json)` / `Write(src/**)` / `Read(//etc/**)` deny or ask compiles through the same
30
+ * validator (the model's path grammar — the `//abs-dir/**` directory form and upstream's pattern form:
31
+ * `//` absolute, `~/` home, `/` project root, otherwise working-directory relative; `*` within a segment,
32
+ * `**` across) and is judged with `pathRuleReaches` on the call's PROTECTIVE path target (design/388: the
33
+ * object face's declaration, the catalog's for the name as the floor). The bases a pattern resolves
34
+ * against are the deployment's (`pathBases`); a pattern whose base the policy was not given is refused at
35
+ * compile time (`unsupported.path_base`) rather than left to reach nothing at run time — a deny that
36
+ * silently matches nothing is the failure form this module's header calls the most dangerous one. A
37
+ * path-form ALLOW stays refused (`unsupported.file_glob`): the Read directory allow is the consent store's.
38
+ *
39
+ * 🔴 与 CC 的有意差(design/127 r2):
13
40
  * - CC 参数级 generic 道无 allow lane(gnn 只有 deny/ask)—— 我们同样:`allow` + 参数级 content =
14
41
  * 无效规则;auto-allow 只来自裸工具规则。
15
42
  * - CC 对尾缀垃圾静默降级为裸名(Lh `n !== len-1`)—— `parsePermissionRule` 保持 CC parity,但
@@ -19,6 +46,9 @@
19
46
  * 「以为挡住了」比没挡更危险。`primaryFieldGeneric:"allow-lexical"` 显式解锁。
20
47
  */
21
48
  import type { NamedToolPolicy } from "./tool-policy.js";
49
+ import { parsePermissionRule, type ParsedPermissionRule } from "./permission-rule-syntax.js";
50
+ import { type PathRuleBases } from "./permission-rule-model.js";
51
+ export { parsePermissionRule, type ParsedPermissionRule };
22
52
  /** [K-PLATFORM-SWEEP] MINOR#11: Bash's REAL non-primary arg names, accepted as generic param rules
23
53
  * (`Bash(timeout:...)`). Exported SOLELY so a lockstep test can assert it against the live Bash tool
24
54
  * schema — a schema param added without updating this set would compile to silently-never-matching
@@ -32,25 +62,29 @@ export interface PermissionRule {
32
62
  * 优先级/tighten-only 语义在 Settings-loader,不在这里。 */
33
63
  source?: string;
34
64
  }
35
- /** Parse result — CC `Lh` parity (INCLUDING the silent bare-name downgrade on malformed parens; the
36
- * factory validator is strict on top of this, see module doc). */
37
- export interface ParsedPermissionRule {
38
- toolName: string;
39
- ruleContent?: string;
40
- }
41
65
  export interface PermissionRuleIssue {
42
66
  rule: string;
43
67
  /** `invalid.*` = 语法/语义错;`unsupported.*` = CC 合法但 v1 不支持的 lane(独立
44
68
  * 分类,绝不静默 no-op —— `Bash(npm run:*)` 被当 generic 参数规则接受后永不匹配是最危险的失败形态)。 */
45
- code: "invalid.empty_tool" | "invalid.paren" | "invalid.param_split" | "invalid.allow_param_rule" | "invalid.primary_field" | "invalid.cap_exceeded" | "unsupported.bash_prefix"
69
+ code: "invalid.empty_tool" | "invalid.paren" | "invalid.param_split" | "invalid.allow_param_rule" | "invalid.primary_field" | "invalid.cap_exceeded"
70
+ /** A content-form Bash ALLOW rule (`Bash(npm run:*)` in an allow list): a standing approval is a consent
71
+ * record and belongs to the persisted-rule store, not to a policy fold. Deny/ask content rules COMPILE. */
72
+ | "unsupported.bash_prefix"
73
+ /** A content-form Bash deny/ask rule the ONE rule validator refuses (a redirection, a substitution, a
74
+ * mid-pattern star, control characters…) — the message carries the validator's own code and sentence. */
75
+ | "invalid.command_rule"
46
76
  /** design/388 B15 — the closed rule-lint set over a tool's RULE FACE (derived scalar vocabulary; a
47
77
  * declaration only narrows it): the named parameter is not one the tool carries; it is carried but
48
78
  * is an object/array (a rule on it can never match); the tool is unknown to the resolver a leg
49
79
  * supplied (absent resolver ⇒ the catalog alone judges and an unknown tool stays permissive). */
50
80
  | "unsupported.param_unknown" | "unsupported.param_non_scalar" | "unsupported.tool_unknown"
51
- /** design/179 D7: a Bash prefix rule under `bashPrefixLane:"rules-store"` reported so a migration
52
- * report can tell "route this through the rule store" apart from "this is a mistake". */
53
- | "unsupported.bash_prefix_rules_store" | "unsupported.file_glob" | "unsupported.mcp_paren"
81
+ /** A path-form ALLOW rule (`Edit(src/**)` / `Read(//x/**)` in an allow list): a standing approval by
82
+ * path is the consent store's (the Read directory form imports there); deny/ask path rules COMPILE. */
83
+ | "unsupported.file_glob"
84
+ /** A path-form deny/ask whose spelling needs a base this policy was not given (`/x` needs
85
+ * `pathBases.root`; a relative pattern needs `pathBases.cwd` or `root`) — refused rather than compiled
86
+ * into a rule that reaches nothing. */
87
+ | "unsupported.path_base" | "unsupported.mcp_paren"
54
88
  /** A parenthesised rule whose NAME is a covering spelling in a non-MCP protocol namespace
55
89
  * (`a2a__<peer>__*(x:y)`, `a2a__<peer>(x:y)`). The param DSL is keyed by EXACT tool name, and a
56
90
  * covering spelling is not one — no mounted tool can ever bear it, so the rule compiled clean and
@@ -89,19 +123,6 @@ export interface PermissionRulePolicyOptions {
89
123
  /** 已知主字段参数规则(`Bash(command:…)` 等)的处置。默认 `"reject"`(整串 lexical ≠ 前缀/AST,
90
124
  * 假安全感);`"allow-lexical"` 显式接受并按 generic 语义匹配。 */
91
125
  primaryFieldGeneric?: "reject" | "allow-lexical";
92
- /**
93
- * design/179 D7 — where a Bash PREFIX rule (`Bash(npm run:*)`) belongs.
94
- *
95
- * This DSL is a pure function with no access to a deployment's wiring, so it cannot discover on its own
96
- * that a persisted-rule store exists; the choice has to be said out loud. `"reject"` is the default and
97
- * keeps this file's behavior byte-identical to what it was: a prefix rule is an `unsupported.bash_prefix`
98
- * issue, because a generic parameter matcher would compile it into a rule that never matches anything.
99
- * `"rules-store"` says the deployment routes that form through the persisted allow-rule lane instead —
100
- * the issue is then re-coded to {@link PermissionRuleIssue.code} `unsupported.bash_prefix_rules_store`,
101
- * which reads as "handled elsewhere" rather than "you wrote something wrong". Either way the rule does
102
- * NOT enter this policy: the two lanes stay separate, and only the report changes.
103
- */
104
- bashPrefixLane?: "reject" | "rules-store";
105
126
  caps?: Partial<PermissionRuleCaps>;
106
127
  /**
107
128
  * design/388 B15 — the leg's RULE-FACE resolver (typically `ruleFacesFromRoster(prepared.toolRoster)`): with it
@@ -110,13 +131,14 @@ export interface PermissionRulePolicyOptions {
110
131
  * carry stays permissive (a rule for it compiles unjudged).
111
132
  */
112
133
  ruleFaces?: (toolName: string) => RuleFaceView | undefined;
134
+ /**
135
+ * The bases a path-form deny/ask pattern resolves against at match time: `root` for `/…` (the task's
136
+ * project root), `cwd` for a relative pattern (absent ⇒ `root`), `home` for `~/…` (absent ⇒ the process
137
+ * owner's home). Facts of the DEPLOYMENT and the task, never of the rule — a pattern is stored as
138
+ * spelled. A rule whose base is absent here is refused at compile time (`unsupported.path_base`).
139
+ */
140
+ pathBases?: PathRuleBases;
113
141
  }
114
- /**
115
- * Parse one rule string — CC `Lh` :55214 parity, including its lenient downgrades (malformed parens /
116
- * trailing garbage ⇒ the WHOLE string becomes the bare tool name). The strict complement lives in
117
- * {@link validatePermissionRules} — use that (or the factory) to refuse the downgrades.
118
- */
119
- export declare function parsePermissionRule(rule: string): ParsedPermissionRule;
120
142
  /**
121
143
  * Linear-time `*` glob match (design/127 M6: NOT a RegExp — patterns may come from tenant-supplied
122
144
  * Settings layers; the classic two-pointer walk has zero ReDoS surface and the same semantics as CC's
@@ -168,11 +190,12 @@ export declare function catalogRuleFaceView(toolName: string): RuleFaceView | un
168
190
  export declare const fileToolParamVocabulary: (canonicalTool: string) => ReadonlySet<string> | undefined;
169
191
  /** Dry-run 校验/迁移报告:不 throw,返回全部 issues(含 `unsupported.*` 分类,
170
192
  * 供从 CC settings 迁移的部署逐条对照)。 */
171
- export declare function validatePermissionRules(rules: PermissionRule[], opts?: Pick<PermissionRulePolicyOptions, "primaryFieldGeneric" | "caps" | "bashPrefixLane" | "ruleFaces">): PermissionRuleIssue[];
193
+ export declare function validatePermissionRules(rules: PermissionRule[], opts?: Pick<PermissionRulePolicyOptions, "primaryFieldGeneric" | "caps" | "ruleFaces" | "pathBases">): PermissionRuleIssue[];
172
194
  /**
173
195
  * Compile declarative permission rules into ONE {@link ToolPolicy} (design/127). Evaluation order
174
- * inside the policy (CC `G1e` :595272 isomorph): bare deny → param deny → bare askparam ask →
175
- * bare allow → `defaultAction`. `check()` is idempotent and side-effect-free (combinePolicies 契约);
196
+ * inside the policy (CC `G1e` :595272 isomorph): bare deny → command deny → param denybare ask →
197
+ * command ask → param ask → bare allow → `defaultAction` (the content-form command tiers are the
198
+ * field-level tiers of upstream's arbiter, beside the generic param tiers). `check()` is idempotent and side-effect-free (combinePolicies 契约);
176
199
  * all parsing/validation happens HERE, the hot path never re-parses.
177
200
  *
178
201
  * ⚠️ Composition note: an `allow` from this policy is "no objection", NOT a
@@ -1,5 +1,10 @@
1
1
  import { MCP_NAMESPACE, protocolOf } from "./protocol-table.js";
2
2
  import { catalogRuleFaceOf, pathTargetOf } from "./tool-registry.js";
3
+ import { indexOfUnescaped, lastIndexOfUnescaped, parsePermissionRule } from "./permission-rule-syntax.js";
4
+ import { isUsablePathBase, parseRuleText, pathRuleReaches, programRunReachOf } from "./permission-rule-model.js";
5
+ import { effectivePathTargetOf } from "./effective-path-target.js";
6
+ import { protectivePathTargetOf } from "./tool-registry.js";
7
+ export { parsePermissionRule };
3
8
  export const BASH_GENERIC_PARAMS = new Set(catalogRuleFaceOf("Bash")?.params ?? []);
4
9
  const DEFAULT_CAPS = {
5
10
  maxRules: 256,
@@ -8,50 +13,6 @@ const DEFAULT_CAPS = {
8
13
  maxStars: 16,
9
14
  maxScalarValueChars: 16_384,
10
15
  };
11
- function indexOfUnescaped(s, ch) {
12
- for (let i = 0; i < s.length; i++) {
13
- if (s[i] !== ch)
14
- continue;
15
- let backslashes = 0;
16
- for (let j = i - 1; j >= 0 && s[j] === "\\"; j--)
17
- backslashes++;
18
- if (backslashes % 2 === 0)
19
- return i;
20
- }
21
- return -1;
22
- }
23
- function lastIndexOfUnescaped(s, ch) {
24
- for (let i = s.length - 1; i >= 0; i--) {
25
- if (s[i] !== ch)
26
- continue;
27
- let backslashes = 0;
28
- for (let j = i - 1; j >= 0 && s[j] === "\\"; j--)
29
- backslashes++;
30
- if (backslashes % 2 === 0)
31
- return i;
32
- }
33
- return -1;
34
- }
35
- function unescapeRuleContent(s) {
36
- return s.replaceAll("\\(", "(").replaceAll("\\)", ")").replaceAll("\\\\", "\\");
37
- }
38
- export function parsePermissionRule(rule) {
39
- const open = indexOfUnescaped(rule, "(");
40
- if (open === -1)
41
- return { toolName: rule.trim() };
42
- const close = lastIndexOfUnescaped(rule, ")");
43
- if (close === -1 || close <= open)
44
- return { toolName: rule.trim() };
45
- if (close !== rule.length - 1)
46
- return { toolName: rule.trim() };
47
- const name = rule.substring(0, open);
48
- const content = rule.substring(open + 1, close);
49
- if (!name)
50
- return { toolName: rule.trim() };
51
- if (content === "" || content === "*")
52
- return { toolName: name.trim() };
53
- return { toolName: name.trim(), ruleContent: unescapeRuleContent(content) };
54
- }
55
16
  export function wildcardMatch(pattern, value) {
56
17
  let p = 0;
57
18
  let v = 0;
@@ -125,7 +86,7 @@ function countStars(s) {
125
86
  n++;
126
87
  return n;
127
88
  }
128
- function compile(rules, caps, primaryFieldGeneric, bashPrefixLane = "reject", ruleFaces) {
89
+ function compile(rules, caps, primaryFieldGeneric, ruleFaces, pathBases = {}) {
129
90
  const faceOf = (toolName) => {
130
91
  if (ruleFaces !== undefined) {
131
92
  const face = ruleFaces(toolName);
@@ -178,7 +139,7 @@ function compile(rules, caps, primaryFieldGeneric, bashPrefixLane = "reject", ru
178
139
  `Write the parenthesised rule against one exact tool name, or drop the parentheses to keep the covering rule.`);
179
140
  continue;
180
141
  }
181
- const entry = byTool.get(toolName) ?? { bare: {}, param: { deny: [], ask: [] } };
142
+ const entry = byTool.get(toolName) ?? { bare: {}, param: { deny: [], ask: [] }, content: { deny: [], ask: [] } };
182
143
  if (parsed.ruleContent === undefined) {
183
144
  if (isNamespacedCoveringRuleName(toolName)) {
184
145
  namespacedCovering[r.behavior].push({ ruleName: toolName, ruleText: text, ...(r.source !== undefined ? { source: r.source } : {}) });
@@ -194,12 +155,17 @@ function compile(rules, caps, primaryFieldGeneric, bashPrefixLane = "reject", ru
194
155
  const paramPart = colon > 0 ? content.slice(0, colon).trim() : "";
195
156
  const looksLikeParamRule = colon > 0 && /^[A-Za-z_][A-Za-z0-9_]*$/.test(paramPart) && BASH_GENERIC_PARAMS.has(paramPart);
196
157
  if (!looksLikeParamRule || content.endsWith(":*")) {
197
- if (bashPrefixLane === "rules-store") {
198
- bad(text, "unsupported.bash_prefix_rules_store", "Bash prefix rules (`Bash(npm run:*)`, `Bash(git *)`) belong to the persisted allow-rule lane this deployment declared not to the generic param DSL; the rule is not compiled here");
158
+ if (r.behavior === "allow") {
159
+ bad(text, "unsupported.bash_prefix", "a Bash command ALLOW rule (`Bash(npm run:*)`, `Bash(git *)`) is a standing approval and belongs to the persisted-rule store (import it, or approve the command once and keep the rule) a policy fold compiles command DENY/ASK rules only");
160
+ continue;
199
161
  }
200
- else {
201
- bad(text, "unsupported.bash_prefix", "Bash prefix/legacy rules (`Bash(npm run:*)`, `Bash(git *)`) are the tool-specific lane — not supported by the generic param DSL (use createCoarseCommandNamePolicy or a dedicated batch)");
162
+ const parsedCommand = parseRuleText(text, r.behavior);
163
+ if ("reject" in parsedCommand) {
164
+ bad(text, "invalid.command_rule", `${parsedCommand.reject.code}: ${parsedCommand.reject.message}`);
165
+ continue;
202
166
  }
167
+ entry.content[r.behavior].push({ parsed: parsedCommand.rule, ruleText: text, ...(r.source !== undefined ? { source: r.source } : {}) });
168
+ byTool.set(toolName, entry);
203
169
  continue;
204
170
  }
205
171
  }
@@ -211,7 +177,30 @@ function compile(rules, caps, primaryFieldGeneric, bashPrefixLane = "reject", ru
211
177
  }
212
178
  const face = resolved.face;
213
179
  if (face !== undefined && face.pathTargeting === true && colon <= 0) {
214
- bad(text, "unsupported.file_glob", "file-tool glob rules (`Edit(src/**)`) are the tool-specific lane — not supported by the generic param DSL");
180
+ if (r.behavior === "allow") {
181
+ bad(text, "unsupported.file_glob", "a path-form ALLOW rule (`Edit(src/**)`, `Read(//x/**)`) is a standing approval and belongs to the persisted-rule store (the Read directory form imports there) — a policy fold compiles path DENY/ASK rules only");
182
+ continue;
183
+ }
184
+ const parsedPath = parseRuleText(text, r.behavior);
185
+ if ("reject" in parsedPath) {
186
+ bad(text, "invalid.command_rule", `${parsedPath.reject.code}: ${parsedPath.reject.message}`);
187
+ continue;
188
+ }
189
+ const pattern = parsedPath.rule.command;
190
+ if (parsedPath.rule.match === "path" && !pattern.startsWith("//")) {
191
+ const needsRoot = pattern.startsWith("/");
192
+ const base = pattern.startsWith("~/") ? (pathBases.home ?? "/") : needsRoot ? pathBases.root : (pathBases.cwd ?? pathBases.root);
193
+ if (!isUsablePathBase(base)) {
194
+ bad(text, "unsupported.path_base", pattern.startsWith("~/")
195
+ ? `"${text}" is relative to the home directory, and this policy's \`pathBases.home\` is not an absolute path — the rule would reach nothing`
196
+ : needsRoot
197
+ ? `"${text}" is relative to the project root, and this policy was given no absolute \`pathBases.root\` to resolve it against — the rule would reach nothing`
198
+ : `"${text}" is relative to the working directory, and this policy was given no absolute \`pathBases.cwd\` (or \`root\`) to resolve it against — the rule would reach nothing`);
199
+ continue;
200
+ }
201
+ }
202
+ entry.content[r.behavior].push({ parsed: parsedPath.rule, ruleText: text, ...(r.source !== undefined ? { source: r.source } : {}) });
203
+ byTool.set(toolName, entry);
215
204
  continue;
216
205
  }
217
206
  if (face?.params !== undefined && colon > 0) {
@@ -257,7 +246,7 @@ function compile(rules, caps, primaryFieldGeneric, bashPrefixLane = "reject", ru
257
246
  }
258
247
  export function validatePermissionRules(rules, opts) {
259
248
  const caps = { ...DEFAULT_CAPS, ...opts?.caps };
260
- return compile(rules, caps, opts?.primaryFieldGeneric ?? "reject", opts?.bashPrefixLane ?? "reject", opts?.ruleFaces).issues;
249
+ return compile(rules, caps, opts?.primaryFieldGeneric ?? "reject", opts?.ruleFaces, opts?.pathBases).issues;
261
250
  }
262
251
  function isPlainObjectArgs(args) {
263
252
  return typeof args === "object" && args !== null && !Array.isArray(args);
@@ -287,7 +276,8 @@ function ruleMessage(kind, ruleText, source) {
287
276
  }
288
277
  export function createPermissionRulePolicy(rules, opts) {
289
278
  const caps = { ...DEFAULT_CAPS, ...opts?.caps };
290
- const { byTool, namespacedCovering, issues } = compile(rules, caps, opts?.primaryFieldGeneric ?? "reject", opts?.bashPrefixLane ?? "reject", opts?.ruleFaces);
279
+ const pathBases = opts?.pathBases ?? {};
280
+ const { byTool, namespacedCovering, issues } = compile(rules, caps, opts?.primaryFieldGeneric ?? "reject", opts?.ruleFaces, pathBases);
291
281
  const setLevelCap = issues.some((i) => i.code === "invalid.cap_exceeded" && i.rule === "");
292
282
  if (issues.length > 0 && ((opts?.onInvalidRule ?? "throw") === "throw" || setLevelCap)) {
293
283
  const e = new Error(`createPermissionRulePolicy: ${issues.length} invalid/unsupported rule(s):\n` +
@@ -314,9 +304,9 @@ export function createPermissionRulePolicy(rules, opts) {
314
304
  const defaultAction = opts?.defaultAction ?? "allow";
315
305
  const nameSets = { deny: [], ask: [], allow: [] };
316
306
  for (const [toolName, entry] of byTool) {
317
- if (entry.bare.deny !== undefined || entry.param.deny.length > 0)
307
+ if (entry.bare.deny !== undefined || entry.param.deny.length > 0 || entry.content.deny.length > 0)
318
308
  nameSets.deny.push(toolName);
319
- if (entry.bare.ask !== undefined || entry.param.ask.length > 0)
309
+ if (entry.bare.ask !== undefined || entry.param.ask.length > 0 || entry.content.ask.length > 0)
320
310
  nameSets.ask.push(toolName);
321
311
  if (entry.bare.allow !== undefined)
322
312
  nameSets.allow.push(toolName);
@@ -327,6 +317,35 @@ export function createPermissionRulePolicy(rules, opts) {
327
317
  }
328
318
  const coveringHit = (lane, toolName) => lane.length === 0 ? undefined : lane.find((r) => namespacedRuleNameCovers(r.ruleName, toolName));
329
319
  const hasCovering = namespacedCovering.deny.length > 0 || namespacedCovering.ask.length > 0 || namespacedCovering.allow.length > 0;
320
+ const commandParam = catalogRuleFaceOf("Bash")?.primaryParams[0] ?? "command";
321
+ const contentHit = (lane, req) => {
322
+ if (lane.length === 0)
323
+ return undefined;
324
+ const target = effectivePathTargetOf(req, protectivePathTargetOf(req), pathBases);
325
+ const command = isPlainObjectArgs(req.args) ? req.args[commandParam] : undefined;
326
+ let unreadable;
327
+ for (const r of lane) {
328
+ if (r.parsed.match === "subpath" || r.parsed.match === "path") {
329
+ if (target !== undefined && pathRuleReaches(r.parsed, target, pathBases))
330
+ return { rule: r };
331
+ continue;
332
+ }
333
+ if (typeof command !== "string")
334
+ continue;
335
+ const outcome = programRunReachOf(r.parsed, command);
336
+ if (outcome.reach === "reached")
337
+ return { rule: r };
338
+ if (outcome.reach === "unreadable")
339
+ unreadable ??= { rule: r, unreadable: outcome.reason };
340
+ }
341
+ return unreadable;
342
+ };
343
+ const unreadableAsk = (hit, req) => ({
344
+ action: "ask",
345
+ message: `tool "${req.toolName}" needs approval: the command could not be read against the permission rule ${hit.rule.ruleText} (${hit.unreadable}) — a person must decide`,
346
+ matchedAskRule: hit.rule.ruleText,
347
+ requiresRealApproval: true,
348
+ });
330
349
  return {
331
350
  nameSets: [nameSets],
332
351
  check(req) {
@@ -340,11 +359,19 @@ export function createPermissionRulePolicy(rules, opts) {
340
359
  if (coveringDeny)
341
360
  return { action: "deny", message: ruleMessage("denied", coveringDeny.ruleText, coveringDeny.source) };
342
361
  }
362
+ const contentDeny = entry !== undefined ? contentHit(entry.content.deny, req) : undefined;
363
+ if (contentDeny !== undefined && contentDeny.unreadable === undefined) {
364
+ return { action: "deny", message: ruleMessage("denied", contentDeny.rule.ruleText, contentDeny.rule.source) };
365
+ }
343
366
  if (entry) {
344
367
  const paramDeny = matchParamRules(entry.param.deny, req.args, caps.maxScalarValueChars);
345
368
  if (paramDeny) {
346
369
  return { action: "deny", message: ruleMessage("denied", paramDeny.ruleText, paramDeny.source) };
347
370
  }
371
+ }
372
+ if (contentDeny?.unreadable !== undefined)
373
+ return unreadableAsk({ rule: contentDeny.rule, unreadable: contentDeny.unreadable }, req);
374
+ if (entry) {
348
375
  if (entry.bare.ask) {
349
376
  return { action: "ask", message: ruleMessage("flagged", entry.bare.ask.ruleText, entry.bare.ask.source), matchedAskRule: entry.bare.ask.ruleText };
350
377
  }
@@ -355,6 +382,12 @@ export function createPermissionRulePolicy(rules, opts) {
355
382
  return { action: "ask", message: ruleMessage("flagged", coveringAsk.ruleText, coveringAsk.source), matchedAskRule: coveringAsk.ruleText };
356
383
  }
357
384
  if (entry) {
385
+ const contentAsk = contentHit(entry.content.ask, req);
386
+ if (contentAsk !== undefined) {
387
+ if (contentAsk.unreadable !== undefined)
388
+ return unreadableAsk({ rule: contentAsk.rule, unreadable: contentAsk.unreadable }, req);
389
+ return { action: "ask", message: ruleMessage("flagged", contentAsk.rule.ruleText, contentAsk.rule.source), matchedAskRule: contentAsk.rule.ruleText };
390
+ }
358
391
  const paramAsk = matchParamRules(entry.param.ask, req.args, caps.maxScalarValueChars);
359
392
  if (paramAsk) {
360
393
  return { action: "ask", message: ruleMessage("flagged", paramAsk.ruleText, paramAsk.source), matchedAskRule: paramAsk.ruleText };
@@ -0,0 +1,61 @@
1
+ /**
2
+ * The tool gate's PERSISTED-RULE arms — the pieces of the persisted-rule lane's consumption that are pure
3
+ * functions of (the lane's answer, the standing decision), pulled out of the gate's lane station so the
4
+ * station holds only the wiring: where the read happens, and where each arm is applied.
5
+ *
6
+ * What lives here: {@link applyPersistedTightening} — the DENY and ASK arms (a person's own standing
7
+ * "never run this" and "ask me each time" rows, their settings' deny/ask lists imported into their store),
8
+ * which the station applies to EVERY decision right after the org layer, and again on an approved edit's
9
+ * bytes — and {@link disclosedRuleSet}, the display form both arms and the allow arm's disclosure use. The
10
+ * lane's bounded READ and the ALLOW arm (a standing yes clearing a classify-tier ask) stay in the station:
11
+ * the read closes over the gate input, and the allow arm is a conjunction over the gate's own state
12
+ * (mandates, the org latch, the marked-call seat) rather than a function of the answer alone.
13
+ */
14
+ import type { PermissionResult } from "./tool-policy.js";
15
+ import type { PersistedRuleHit, PersistedRuleHitRule } from "./hooks.js";
16
+ /** The lane's one read, normalized: at most one of `hit` / `unreadable` / `coverage` (the gate's reader
17
+ * produces it; the arms below read it). */
18
+ export interface PersistedRuleRead {
19
+ readonly hit?: PersistedRuleHit;
20
+ readonly unreadable?: true;
21
+ /** The lexer's reason when the CALL could not be read against the person's deny/ask rows (absent for
22
+ * a store read failure) — the message says which it was; the decision is the same. */
23
+ readonly reason?: string;
24
+ }
25
+ /** Display form of a rule set (design/375 §5.1): every rule text is a DISPLAY value on a human trust
26
+ * boundary, and the set must stay readable however large a lane makes it — each member rides
27
+ * `inlineUntrusted` (200 cap, the same cap the durable leg's `RiskDescriptor.shadowedRule` carries),
28
+ * at most FIVE are listed, and the rest are counted rather than dropped silently. */
29
+ export declare function disclosedRuleSet(rules: readonly PersistedRuleHitRule[]): string;
30
+ /**
31
+ * The persisted-rule lane's TIGHTENING arms — a person's own standing DENY and ASK rows — applied to
32
+ * `decision`. The station runs this on EVERY decision, right after the org layer and in the same
33
+ * posture: an allow from the policy fold is exactly what a deny row must be able to refuse, and a bare
34
+ * ask is exactly what an ask row must be able to mark. Precedence across the person's rows is the
35
+ * upstream arbiter's (deny > ask > allow), already resolved inside the lane's one verdict; precedence
36
+ * across AUTHORITIES is org > personal (the org layer ran first, and a personal ask never loosens an
37
+ * org outcome — it stamps a marker on an ask, or turns an allow into one).
38
+ * · DENY — the call is refused outright, with `decisionReason: "persisted_rule"` (the same word the
39
+ * lane's allow carries: one authority, one word) and the rule text disclosed through the display
40
+ * transform; the STATION attributes the refusal (`tightened: "deny"` ⇒ it writes the pass's refusing
41
+ * layer — the attribution is a station's word and lives in the station file).
42
+ * · ASK — on an allow, a fresh ask is minted carrying `matchedAskRule` (the marker an explicit `ask`
43
+ * permission rule stamps, so the ask's origin derives as `ask_rule`: the classifier never answers
44
+ * it, the persisted ALLOW arm never clears it, and the rule-offer face reports `shadowed`); on an
45
+ * ask, the marker is stamped if absent and the message names the rule — the standing question
46
+ * outranks a remembered yes across lanes, exactly as the DSL's own order says within one; a deny
47
+ * stays a deny.
48
+ * · UNREADABLE — the wired store could not be read (a read failure, a timeout), or the CALL could not be
49
+ * read against the person's standing deny/ask rows (`reason` carries the lexer's word: an expansion
50
+ * where a rule reads a word, an unterminated quote, a syntax error): the person's deny/ask rows
51
+ * cannot be enforced for this call, so it fails CLOSED the way an unreadable org snapshot does — an
52
+ * allow tightens to an ask carrying `requiresRealApproval` (no automatic lane clears it; the gate
53
+ * derives the origin `rule_store_unavailable` from its own record of the read), an ask acquires the
54
+ * bit, a deny stays a deny. Reading an unreadable store as "no rule" would erase every deny the
55
+ * person wrote the moment their file is locked or corrupt.
56
+ * An allow hit (or no hit) returns the decision untouched: the allow arm is the station's.
57
+ */
58
+ export declare function applyPersistedTightening(decision: PermissionResult, read: PersistedRuleRead | undefined): {
59
+ decision: PermissionResult;
60
+ tightened?: "deny" | "ask" | "unreadable";
61
+ };
@@ -0,0 +1,50 @@
1
+ import { inlineUntrusted } from "./untrusted-text.js";
2
+ export function disclosedRuleSet(rules) {
3
+ const shown = rules.slice(0, 5).map((r) => inlineUntrusted(r.rule, 200));
4
+ const rest = rules.length - shown.length;
5
+ return rest > 0 ? `${shown.join(", ")} and ${rest} more` : shown.join(", ");
6
+ }
7
+ export function applyPersistedTightening(decision, read) {
8
+ if (read?.unreadable === true) {
9
+ if (decision.action === "deny")
10
+ return { decision };
11
+ const message = read.reason !== undefined
12
+ ? `this call could not be read against the person's standing deny/ask rules (${read.reason}) — whether one of them forbids it is unknown, so it needs a real approval`
13
+ : "the persisted permission-rule store could not be read for this call — the person's own deny/ask rules cannot be enforced, so this call needs a real approval until the store is readable";
14
+ return {
15
+ decision: decision.action === "allow"
16
+ ? { action: "ask", message, decisionReason: "persisted_rule", requiresRealApproval: true, ...(decision.updatedInput !== undefined ? { updatedInput: decision.updatedInput } : {}) }
17
+ : { ...decision, requiresRealApproval: true, message: `${decision.message !== undefined ? `${decision.message} ` : ""}(${message})` },
18
+ tightened: "unreadable",
19
+ };
20
+ }
21
+ const hit = read?.hit;
22
+ if (hit === undefined || hit.behavior === "allow")
23
+ return { decision };
24
+ const shown = disclosedRuleSet(hit.rules);
25
+ if (hit.behavior === "deny") {
26
+ return { decision: { action: "deny", message: `a persisted deny rule (${shown}) refuses this call`, decisionReason: "persisted_rule" }, tightened: "deny" };
27
+ }
28
+ if (decision.action === "deny")
29
+ return { decision };
30
+ if (decision.action === "allow") {
31
+ return {
32
+ decision: {
33
+ action: "ask",
34
+ message: `a persisted ask rule (${shown}) requires approval for this call`,
35
+ decisionReason: "persisted_rule",
36
+ matchedAskRule: shown,
37
+ ...(decision.updatedInput !== undefined ? { updatedInput: decision.updatedInput } : {}),
38
+ },
39
+ tightened: "ask",
40
+ };
41
+ }
42
+ return {
43
+ decision: {
44
+ ...decision,
45
+ ...(decision.matchedAskRule === undefined ? { matchedAskRule: shown } : {}),
46
+ message: `${decision.message !== undefined ? `${decision.message} ` : ""}(a persisted ask rule (${shown}) also requires approval for this call)`,
47
+ },
48
+ tightened: "ask",
49
+ };
50
+ }
@@ -1,5 +1,6 @@
1
1
  import { canonicalizeTarget } from "../../tools/fs/safety.js";
2
2
  import { pathTargetValue } from "../tool-registry.js";
3
+ import { pathTargetBaseOf } from "../effective-path-target.js";
3
4
  import { PATH_WRITE_TOOLS, isWithin } from "./session-rule-policy.js";
4
5
  import { parseSkillToolEntry, skillSpecifierRejection } from "../skill-tool-specifier.js";
5
6
  export class ActiveSkillScope {
@@ -99,7 +100,7 @@ export function createActiveSkillScopePolicy(opts) {
99
100
  decisionReason: "safety",
100
101
  };
101
102
  }
102
- const canon = await canonicalizeTarget(env, path, signal, req.cwd ?? rootPath);
103
+ const canon = await canonicalizeTarget(env, path, signal, pathTargetBaseOf(declaredTarget, { root: rootPath, cwd: req.cwd }));
103
104
  if (!canon.ok) {
104
105
  return {
105
106
  action: "deny",
@@ -315,6 +315,14 @@ export interface Prepared {
315
315
  /** design/99 §E6 — the DENY-NARROWING layers (session rules + skill scope, deny-only). Re-checked on RESUME
316
316
  * before an approved pending tool executes, so a rule tightened during the suspend still applies. */
317
317
  denyNarrowingPolicy?: ToolPolicy;
318
+ /** #632 — the person's own persisted-rule lane (deny/ask rows), re-adjudicated on RESUME at the same
319
+ * station as the deny-narrowing layers: a deny row added while the call was parked vetoes the execution
320
+ * (`deniedBy: "persisted_rule"`); an ask row or an unreadable store fails closed the same way (the approval
321
+ * answered a question the person has since asked to be asked again — re-issue and re-approve). Absent when
322
+ * no rule store is wired. */
323
+ persistedRuleLane?: {
324
+ adjudicate: (req: ToolCallRequest) => Promise<import("../hooks.js").PersistedRuleAnswer>;
325
+ };
318
326
  /** RB-63: the deployment's own caller policy, re-checked on a durable resume ONLY when the approver
319
327
  * rewrote the pending call's args (see the composition site for why the edit case is special). */
320
328
  basePolicyForResumeEdit?: ToolPolicy;
@@ -761,8 +769,8 @@ export interface Prepared {
761
769
  /**
762
770
  * design/388 L4 — the leg's `tool_end.structured` projector: the engine card set ∪ the caller rows' declared
763
771
  * `renderHints.resultCards`; an undeclared `details.type` is dropped and announced ONCE per leg
764
- * (`config.tool_card_undeclared`). The run loop's tool_end body projection reads it in place of the
765
- * module-level `structuredFrom` (which has no announce seat).
772
+ * (`config.tool_card_undeclared`). The ONE card port: every tool_end body projection (the live loop, the resumed
773
+ * batch, the reconcile-recovered orphan) takes it as a parameter; no module-level card set exists to fall back to.
766
774
  */
767
775
  structuredProjector: (result: unknown) => unknown;
768
776
  /** Name→effect map for every tool this task can call (design/44 §3). Used by the abort-path orphan
@@ -2355,6 +2363,17 @@ export interface HarnessHandlersDeps {
2355
2363
  * Signatures are spelled out (not `Pick<Runner, …>`), because naming the class from here would be the
2356
2364
  * very edge this seat exists to remove.
2357
2365
  */
2366
+ /**
2367
+ * The Runner's deployment deps as a LIVE read for the lanes that read them after an await (the resume ladder's
2368
+ * rungs). `Runner.deps` is not a constant: `swapModels` replaces the object, so a lane handed the object at its
2369
+ * call would read a stale catalog — and a stale anything — where the one-function ladder read `this.deps.x` at the
2370
+ * instant of the read. The seat is the Runner's own view (a getter over the private field), so `runner.deps.x`
2371
+ * in a lane is exactly `this.deps.x` in the method it came from.
2372
+ */
2373
+ export interface RunnerDepsSeat {
2374
+ /** The deps object the Runner holds RIGHT NOW — never cache it across an await. */
2375
+ readonly deps: RunnerDeps;
2376
+ }
2358
2377
  export interface RunnerSelfSeat {
2359
2378
  /** The session store this Runner was constructed over — the delegation tools acquire / release / fork through it. */
2360
2379
  readonly sessions: SessionStore;