@sema-agent/core 7.7.0 → 7.9.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 (186) hide show
  1. package/CHANGELOG.md +88 -0
  2. package/dist/agents/agent-transcript-tool.js +2 -2
  3. package/dist/agents/list-agents-tool.js +2 -3
  4. package/dist/agents/observer.js +2 -2
  5. package/dist/agents/send-message-tool.js +2 -1
  6. package/dist/agents/subagent-steps.js +5 -4
  7. package/dist/agents/subagent.d.ts +1 -1
  8. package/dist/agents/subagent.js +2 -3
  9. package/dist/core/ask-origin.d.ts +12 -1
  10. package/dist/core/ask-origin.js +5 -1
  11. package/dist/core/ask-question.js +2 -2
  12. package/dist/core/checkpoint-store.d.ts +64 -24
  13. package/dist/core/context-edit.d.ts +0 -22
  14. package/dist/core/context-edit.js +2 -11
  15. package/dist/core/engine-notice.d.ts +6 -0
  16. package/dist/core/fs-write-gate-policy.d.ts +7 -1
  17. package/dist/core/fs-write-gate-policy.js +7 -7
  18. package/dist/core/gate-fold.js +2 -2
  19. package/dist/core/gate-lanes.js +53 -26
  20. package/dist/core/gate-outcome.d.ts +8 -4
  21. package/dist/core/gate-outcome.js +2 -1
  22. package/dist/core/governance-codes.d.ts +1 -1
  23. package/dist/core/governance-codes.js +6 -0
  24. package/dist/core/hooks.d.ts +64 -33
  25. package/dist/core/hooks.js +1 -1
  26. package/dist/core/lsp.js +2 -2
  27. package/dist/core/mcp-server-spec.d.ts +17 -0
  28. package/dist/core/mcp.js +3 -1
  29. package/dist/core/memory-engine/tools.js +4 -12
  30. package/dist/core/permission-rule-consent.d.ts +29 -36
  31. package/dist/core/permission-rule-consent.js +110 -60
  32. package/dist/core/permission-rule-model.d.ts +254 -55
  33. package/dist/core/permission-rule-model.js +323 -43
  34. package/dist/core/permission-rule-org.d.ts +9 -8
  35. package/dist/core/permission-rule-org.js +6 -15
  36. package/dist/core/permission-rule-provider.d.ts +10 -6
  37. package/dist/core/permission-rule-provider.js +12 -8
  38. package/dist/core/permission-rule-session.d.ts +7 -6
  39. package/dist/core/permission-rule-session.js +29 -10
  40. package/dist/core/permission-rule-store.d.ts +40 -18
  41. package/dist/core/permission-rule-store.js +68 -42
  42. package/dist/core/permission-rule-sync.d.ts +9 -3
  43. package/dist/core/permission-rule-sync.js +29 -25
  44. package/dist/core/permission-rule-syntax.d.ts +30 -0
  45. package/dist/core/permission-rule-syntax.js +44 -0
  46. package/dist/core/permission-rules.d.ts +85 -33
  47. package/dist/core/permission-rules.js +104 -88
  48. package/dist/core/persisted-rule-arms.d.ts +56 -0
  49. package/dist/core/persisted-rule-arms.js +48 -0
  50. package/dist/core/present-plan-tool.js +3 -4
  51. package/dist/core/runner/active-skill-scope.js +7 -4
  52. package/dist/core/runner/compaction-call-options.d.ts +1 -1
  53. package/dist/core/runner/contracts.d.ts +46 -3
  54. package/dist/core/runner/permission-rule-lanes.d.ts +33 -26
  55. package/dist/core/runner/permission-rule-lanes.js +27 -21
  56. package/dist/core/runner/prepare-ask-lane.d.ts +1 -1
  57. package/dist/core/runner/prepare-caps-and-workflow.d.ts +5 -4
  58. package/dist/core/runner/prepare-caps-and-workflow.js +16 -11
  59. package/dist/core/runner/prepare-defer-classify.d.ts +5 -26
  60. package/dist/core/runner/prepare-defer-classify.js +10 -10
  61. package/dist/core/runner/prepare-delegation-surface.d.ts +5 -4
  62. package/dist/core/runner/prepare-delegation-surface.js +15 -17
  63. package/dist/core/runner/prepare-gate-stations.d.ts +4 -1
  64. package/dist/core/runner/prepare-gate-stations.js +4 -2
  65. package/dist/core/runner/prepare-hands-readface.d.ts +3 -2
  66. package/dist/core/runner/prepare-hands-readface.js +3 -2
  67. package/dist/core/runner/prepare-inherited-gate.d.ts +1 -1
  68. package/dist/core/runner/prepare-inherited-gate.js +1 -0
  69. package/dist/core/runner/prepare-lsp.d.ts +4 -3
  70. package/dist/core/runner/prepare-lsp.js +3 -2
  71. package/dist/core/runner/prepare-memory-engine-session.d.ts +4 -4
  72. package/dist/core/runner/prepare-memory-engine-session.js +4 -2
  73. package/dist/core/runner/prepare-offload-wrappers.js +7 -5
  74. package/dist/core/runner/prepare-park-ask.d.ts +4 -0
  75. package/dist/core/runner/prepare-park-ask.js +4 -1
  76. package/dist/core/runner/prepare-project-context.d.ts +5 -23
  77. package/dist/core/runner/prepare-project-context.js +7 -5
  78. package/dist/core/runner/prepare-prompt-assembly.d.ts +1 -1
  79. package/dist/core/runner/prepare-protocol-tools.d.ts +5 -1
  80. package/dist/core/runner/prepare-protocol-tools.js +38 -21
  81. package/dist/core/runner/prepare-question-face.d.ts +4 -3
  82. package/dist/core/runner/prepare-question-face.js +5 -3
  83. package/dist/core/runner/prepare-run-refs.d.ts +16 -0
  84. package/dist/core/runner/prepare-run-refs.js +6 -0
  85. package/dist/core/runner/prepare-safety-scan.js +48 -2
  86. package/dist/core/runner/prepare-task.js +37 -19
  87. package/dist/core/runner/prepare-tool-disclosure-mount.d.ts +5 -2
  88. package/dist/core/runner/prepare-tool-disclosure-mount.js +3 -1
  89. package/dist/core/runner/prepare-wiring-manifest.d.ts +4 -0
  90. package/dist/core/runner/prepare-wiring-manifest.js +3 -8
  91. package/dist/core/runner/resume-admission.d.ts +53 -0
  92. package/dist/core/runner/resume-admission.js +83 -0
  93. package/dist/core/runner/resume-apply.d.ts +50 -0
  94. package/dist/core/runner/resume-apply.js +184 -0
  95. package/dist/core/runner/resume-checkpoint-screen.d.ts +18 -0
  96. package/dist/core/runner/resume-checkpoint-screen.js +108 -0
  97. package/dist/core/runner/resume-claim.d.ts +32 -0
  98. package/dist/core/runner/resume-claim.js +27 -0
  99. package/dist/core/runner/resume-internals-and-config.d.ts +33 -0
  100. package/dist/core/runner/resume-internals-and-config.js +50 -0
  101. package/dist/core/runner/resume-policy-outcome.d.ts +31 -0
  102. package/dist/core/runner/resume-policy-outcome.js +127 -0
  103. package/dist/core/runner/resume-preflight.d.ts +40 -0
  104. package/dist/core/runner/resume-preflight.js +122 -0
  105. package/dist/core/runner/resume-review-outcome.d.ts +30 -0
  106. package/dist/core/runner/resume-review-outcome.js +88 -0
  107. package/dist/core/runner/run-harness-handlers.d.ts +46 -0
  108. package/dist/core/runner/run-harness-handlers.js +345 -0
  109. package/dist/core/runner/run-turn-boundary.d.ts +69 -0
  110. package/dist/core/runner/run-turn-boundary.js +694 -0
  111. package/dist/core/runner/runtask.d.ts +20 -0
  112. package/dist/core/runner/runtask.js +129 -1774
  113. package/dist/core/runner/session-rule-policy.js +9 -4
  114. package/dist/core/runner/synthetic-tools.js +5 -8
  115. package/dist/core/runner/tool-disclosure.d.ts +4 -0
  116. package/dist/core/runner/tool-disclosure.js +9 -5
  117. package/dist/core/runner/tool-end-body.d.ts +12 -5
  118. package/dist/core/runner/tool-end-body.js +8 -7
  119. package/dist/core/runner/tool-face-overlay.d.ts +40 -0
  120. package/dist/core/runner/tool-face-overlay.js +125 -0
  121. package/dist/core/runner/tool-output-projection.d.ts +14 -1
  122. package/dist/core/runner/tool-output-projection.js +24 -18
  123. package/dist/core/runner/turn-attachments.d.ts +2 -2
  124. package/dist/core/sensitive-path-policy.d.ts +3 -0
  125. package/dist/core/sensitive-path-policy.js +6 -5
  126. package/dist/core/shared-memory/tools.js +3 -6
  127. package/dist/core/store-contracts/checkpoint-store-contract.js +23 -0
  128. package/dist/core/store-contracts/permission-rule-sync-contract.js +29 -10
  129. package/dist/core/task-event.d.ts +10 -0
  130. package/dist/core/task-registry.js +4 -5
  131. package/dist/core/task-spec.d.ts +8 -0
  132. package/dist/core/tool-catalog-entries.d.ts +48 -0
  133. package/dist/core/tool-catalog-entries.js +174 -0
  134. package/dist/core/tool-conformance.d.ts +22 -0
  135. package/dist/core/tool-conformance.js +37 -0
  136. package/dist/core/tool-face.d.ts +171 -0
  137. package/dist/core/tool-face.js +25 -0
  138. package/dist/core/tool-policy.d.ts +24 -0
  139. package/dist/core/tool-policy.js +4 -2
  140. package/dist/core/tool-registry.d.ts +114 -0
  141. package/dist/core/tool-registry.js +114 -0
  142. package/dist/core/tool-result-budget.d.ts +0 -3
  143. package/dist/core/tool-result-budget.js +4 -3
  144. package/dist/core/tool-result-store.d.ts +0 -18
  145. package/dist/core/tool-result-store.js +2 -18
  146. package/dist/core/tool-roster.d.ts +459 -0
  147. package/dist/core/tool-roster.js +507 -0
  148. package/dist/core/tool-spec.d.ts +54 -3
  149. package/dist/core/tools.js +7 -0
  150. package/dist/core/wiring-manifest.d.ts +13 -0
  151. package/dist/core/wiring-manifest.js +2 -1
  152. package/dist/core/write-protect.d.ts +2 -1
  153. package/dist/core/write-protect.js +5 -4
  154. package/dist/engine/compaction/utils.d.ts +6 -0
  155. package/dist/engine/compaction/utils.js +19 -19
  156. package/dist/engine/loop/types.d.ts +6 -0
  157. package/dist/index.d.ts +17 -6
  158. package/dist/index.js +17 -6
  159. package/dist/orchestration/goal.js +2 -1
  160. package/dist/orchestration/run-spec.js +3 -5
  161. package/dist/orchestration/run-workflow-tool.js +2 -2
  162. package/dist/prompt-assembly/event-registry.js +3 -3
  163. package/dist/prompt-assembly/tool-catalog.d.ts +2 -2
  164. package/dist/stores/file/checkpoint-store.js +1 -1
  165. package/dist/stores/file/permission-rule-store.d.ts +28 -23
  166. package/dist/stores/file/permission-rule-store.js +64 -16
  167. package/dist/tools/fs/bash-readonly-classifier.d.ts +1 -1
  168. package/dist/tools/fs/fs-bash.d.ts +1 -1
  169. package/dist/tools/fs/fs-bash.js +7 -11
  170. package/dist/tools/fs/fs-pdf.d.ts +1 -1
  171. package/dist/tools/fs/fs-read.js +2 -2
  172. package/dist/tools/fs/fs-search-tools.d.ts +0 -7
  173. package/dist/tools/fs/fs-search-tools.js +3 -16
  174. package/dist/tools/fs/fs-write.js +4 -6
  175. package/dist/tools/fs/notebook.d.ts +1 -1
  176. package/dist/tools/fs/repo-map.js +2 -2
  177. package/dist/tools/fs/safety.d.ts +3 -2
  178. package/dist/tools/fs/safety.js +6 -5
  179. package/dist/tools/monitor.js +2 -2
  180. package/dist/tools/scheduler-tools.js +5 -8
  181. package/dist/tools/task-list.js +5 -8
  182. package/dist/tools/todo.js +2 -2
  183. package/dist/tools/web.js +3 -6
  184. package/dist/tools/worktree.js +3 -4
  185. package/package.json +4 -1
  186. package/test/export-surface.snapshot.json +189 -17
@@ -9,7 +9,33 @@
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
+ * (`ruleReachesProgramRun`: 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
+ * It sits in the field-level tier of the evaluation order — right after the bare deny (resp. ask) — which
22
+ * is upstream's placement (`bs()` whole-tool deny → `AN(…,"deny")` field-level deny → `ph()` whole-tool
23
+ * ask → `AN(…,"ask")` field-level ask → allow). A content-form ALLOW is refused (`unsupported.bash_prefix`):
24
+ * a standing approval is a CONSENT record and lives in the persisted-rule store (the import door carries
25
+ * a settings allow list there); inside a policy fold an allow is "no objection" and would clear nothing.
26
+ *
27
+ * The PATH lane is the same shape for every tool the catalog declares a path target for: a
28
+ * `Edit(~/.sema/settings.json)` / `Write(src/**)` / `Read(//etc/**)` deny or ask compiles through the same
29
+ * validator (the model's path grammar — the `//abs-dir/**` directory form and upstream's pattern form:
30
+ * `//` absolute, `~/` home, `/` project root, otherwise working-directory relative; `*` within a segment,
31
+ * `**` across) and is judged with `pathRuleReaches` on the call's PROTECTIVE path target (design/388: the
32
+ * object face's declaration, the catalog's for the name as the floor). The bases a pattern resolves
33
+ * against are the deployment's (`pathBases`); a pattern whose base the policy was not given is refused at
34
+ * compile time (`unsupported.path_base`) rather than left to reach nothing at run time — a deny that
35
+ * silently matches nothing is the failure form this module's header calls the most dangerous one. A
36
+ * path-form ALLOW stays refused (`unsupported.file_glob`): the Read directory allow is the consent store's.
37
+ *
38
+ * 🔴 与 CC 的有意差(design/127 r2):
13
39
  * - CC 参数级 generic 道无 allow lane(gnn 只有 deny/ask)—— 我们同样:`allow` + 参数级 content =
14
40
  * 无效规则;auto-allow 只来自裸工具规则。
15
41
  * - CC 对尾缀垃圾静默降级为裸名(Lh `n !== len-1`)—— `parsePermissionRule` 保持 CC parity,但
@@ -19,6 +45,9 @@
19
45
  * 「以为挡住了」比没挡更危险。`primaryFieldGeneric:"allow-lexical"` 显式解锁。
20
46
  */
21
47
  import type { NamedToolPolicy } from "./tool-policy.js";
48
+ import { parsePermissionRule, type ParsedPermissionRule } from "./permission-rule-syntax.js";
49
+ import { type PathRuleBases } from "./permission-rule-model.js";
50
+ export { parsePermissionRule, type ParsedPermissionRule };
22
51
  /** [K-PLATFORM-SWEEP] MINOR#11: Bash's REAL non-primary arg names, accepted as generic param rules
23
52
  * (`Bash(timeout:...)`). Exported SOLELY so a lockstep test can assert it against the live Bash tool
24
53
  * schema — a schema param added without updating this set would compile to silently-never-matching
@@ -32,20 +61,29 @@ export interface PermissionRule {
32
61
  * 优先级/tighten-only 语义在 Settings-loader,不在这里。 */
33
62
  source?: string;
34
63
  }
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
64
  export interface PermissionRuleIssue {
42
65
  rule: string;
43
66
  /** `invalid.*` = 语法/语义错;`unsupported.*` = CC 合法但 v1 不支持的 lane(独立
44
67
  * 分类,绝不静默 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"
46
- /** design/179 D7: a Bash prefix rule under `bashPrefixLane:"rules-store"` reported so a migration
47
- * report can tell "route this through the rule store" apart from "this is a mistake". */
48
- | "unsupported.bash_prefix_rules_store" | "unsupported.file_glob" | "unsupported.mcp_paren"
68
+ code: "invalid.empty_tool" | "invalid.paren" | "invalid.param_split" | "invalid.allow_param_rule" | "invalid.primary_field" | "invalid.cap_exceeded"
69
+ /** A content-form Bash ALLOW rule (`Bash(npm run:*)` in an allow list): a standing approval is a consent
70
+ * record and belongs to the persisted-rule store, not to a policy fold. Deny/ask content rules COMPILE. */
71
+ | "unsupported.bash_prefix"
72
+ /** A content-form Bash deny/ask rule the ONE rule validator refuses (a redirection, a substitution, a
73
+ * mid-pattern star, control characters…) — the message carries the validator's own code and sentence. */
74
+ | "invalid.command_rule"
75
+ /** design/388 B15 — the closed rule-lint set over a tool's RULE FACE (derived scalar vocabulary; a
76
+ * declaration only narrows it): the named parameter is not one the tool carries; it is carried but
77
+ * is an object/array (a rule on it can never match); the tool is unknown to the resolver a leg
78
+ * supplied (absent resolver ⇒ the catalog alone judges and an unknown tool stays permissive). */
79
+ | "unsupported.param_unknown" | "unsupported.param_non_scalar" | "unsupported.tool_unknown"
80
+ /** A path-form ALLOW rule (`Edit(src/**)` / `Read(//x/**)` in an allow list): a standing approval by
81
+ * path is the consent store's (the Read directory form imports there); deny/ask path rules COMPILE. */
82
+ | "unsupported.file_glob"
83
+ /** A path-form deny/ask whose spelling needs a base this policy was not given (`/x` needs
84
+ * `pathBases.root`; a relative pattern needs `pathBases.cwd` or `root`) — refused rather than compiled
85
+ * into a rule that reaches nothing. */
86
+ | "unsupported.path_base" | "unsupported.mcp_paren"
49
87
  /** A parenthesised rule whose NAME is a covering spelling in a non-MCP protocol namespace
50
88
  * (`a2a__<peer>__*(x:y)`, `a2a__<peer>(x:y)`). The param DSL is keyed by EXACT tool name, and a
51
89
  * covering spelling is not one — no mounted tool can ever bear it, so the rule compiled clean and
@@ -84,27 +122,22 @@ export interface PermissionRulePolicyOptions {
84
122
  /** 已知主字段参数规则(`Bash(command:…)` 等)的处置。默认 `"reject"`(整串 lexical ≠ 前缀/AST,
85
123
  * 假安全感);`"allow-lexical"` 显式接受并按 generic 语义匹配。 */
86
124
  primaryFieldGeneric?: "reject" | "allow-lexical";
125
+ caps?: Partial<PermissionRuleCaps>;
87
126
  /**
88
- * design/179 D7where a Bash PREFIX rule (`Bash(npm run:*)`) belongs.
89
- *
90
- * This DSL is a pure function with no access to a deployment's wiring, so it cannot discover on its own
91
- * that a persisted-rule store exists; the choice has to be said out loud. `"reject"` is the default and
92
- * keeps this file's behavior byte-identical to what it was: a prefix rule is an `unsupported.bash_prefix`
93
- * issue, because a generic parameter matcher would compile it into a rule that never matches anything.
94
- * `"rules-store"` says the deployment routes that form through the persisted allow-rule lane instead —
95
- * the issue is then re-coded to {@link PermissionRuleIssue.code} `unsupported.bash_prefix_rules_store`,
96
- * which reads as "handled elsewhere" rather than "you wrote something wrong". Either way the rule does
97
- * NOT enter this policy: the two lanes stay separate, and only the report changes.
127
+ * design/388 B15the leg's RULE-FACE resolver (typically `ruleFacesFromRoster(prepared.toolRoster)`): with it
128
+ * every tool on the leg is judged — MCP and caller tools included — and a rule naming a tool the leg does not
129
+ * mount is `unsupported.tool_unknown`. Without it the static catalog alone judges; a tool the catalog does not
130
+ * carry stays permissive (a rule for it compiles unjudged).
98
131
  */
99
- bashPrefixLane?: "reject" | "rules-store";
100
- caps?: Partial<PermissionRuleCaps>;
132
+ ruleFaces?: (toolName: string) => RuleFaceView | undefined;
133
+ /**
134
+ * The bases a path-form deny/ask pattern resolves against at match time: `root` for `/…` (the task's
135
+ * project root), `cwd` for a relative pattern (absent ⇒ `root`), `home` for `~/…` (absent ⇒ the process
136
+ * owner's home). Facts of the DEPLOYMENT and the task, never of the rule — a pattern is stored as
137
+ * spelled. A rule whose base is absent here is refused at compile time (`unsupported.path_base`).
138
+ */
139
+ pathBases?: PathRuleBases;
101
140
  }
102
- /**
103
- * Parse one rule string — CC `Lh` :55214 parity, including its lenient downgrades (malformed parens /
104
- * trailing garbage ⇒ the WHOLE string becomes the bare tool name). The strict complement lives in
105
- * {@link validatePermissionRules} — use that (or the factory) to refuse the downgrades.
106
- */
107
- export declare function parsePermissionRule(rule: string): ParsedPermissionRule;
108
141
  /**
109
142
  * Linear-time `*` glob match (design/127 M6: NOT a RegExp — patterns may come from tenant-supplied
110
143
  * Settings layers; the classic two-pointer walk has zero ReDoS surface and the same semantics as CC's
@@ -134,15 +167,34 @@ export declare function namespacedRuleNameCovers(ruleName: string, toolName: str
134
167
  * — i.e. the shapes {@link namespacedRuleNameCovers} answers and an exact name table cannot. Every
135
168
  * lane that keys rules by exact tool name needs this to know which entries it must NOT key that way. */
136
169
  export declare function isNamespacedCoveringRuleName(name: string): boolean;
137
- /** Exported for the lockstep guard only (see {@link FILE_TOOL_PARAMS}) — not part of the rule DSL. */
170
+ /**
171
+ * design/388 A-6/7/8/9 (D-3): the per-tool RULE FACE — the parameters a `Tool(param:pattern)` rule may name
172
+ * (`params`, the SCALAR top-level keys ∪ the path target's aliases), the ones the generic lexical matcher must
173
+ * not be pointed at (`primaryParams`), the carried-but-non-scalar keys a rule can never match (`nonScalar`), and
174
+ * whether the tool names a path target at all (the file-tool glob lane). Resolved through
175
+ * {@link PermissionRulePolicyOptions.ruleFaces} when a leg supplies its roster, else from the static catalog
176
+ * — the three hand-kept tables (`PRIMARY_FIELDS` / `FILE_TOOLS` / `FILE_TOOL_PARAMS`) this replaced are gone.
177
+ */
178
+ export interface RuleFaceView {
179
+ primaryParams: readonly string[];
180
+ /** The full scalar vocabulary; ABSENT when the resolver does not know it (the static catalog for a
181
+ * definition whose schema is factory-built) — then only the primary-field check applies. */
182
+ params?: readonly string[];
183
+ nonScalar?: readonly string[];
184
+ pathTargeting?: boolean;
185
+ }
186
+ /** The static catalog's answer for a wire name (undefined ⇒ the catalog does not carry it). */
187
+ export declare function catalogRuleFaceView(toolName: string): RuleFaceView | undefined;
188
+ /** Exported for the lockstep guard only (the scalar vocabulary of a catalog tool) — not part of the rule DSL. */
138
189
  export declare const fileToolParamVocabulary: (canonicalTool: string) => ReadonlySet<string> | undefined;
139
190
  /** Dry-run 校验/迁移报告:不 throw,返回全部 issues(含 `unsupported.*` 分类,
140
191
  * 供从 CC settings 迁移的部署逐条对照)。 */
141
- export declare function validatePermissionRules(rules: PermissionRule[], opts?: Pick<PermissionRulePolicyOptions, "primaryFieldGeneric" | "caps" | "bashPrefixLane">): PermissionRuleIssue[];
192
+ export declare function validatePermissionRules(rules: PermissionRule[], opts?: Pick<PermissionRulePolicyOptions, "primaryFieldGeneric" | "caps" | "ruleFaces" | "pathBases">): PermissionRuleIssue[];
142
193
  /**
143
194
  * Compile declarative permission rules into ONE {@link ToolPolicy} (design/127). Evaluation order
144
- * inside the policy (CC `G1e` :595272 isomorph): bare deny → param deny → bare askparam ask →
145
- * bare allow → `defaultAction`. `check()` is idempotent and side-effect-free (combinePolicies 契约);
195
+ * inside the policy (CC `G1e` :595272 isomorph): bare deny → command deny → param denybare ask →
196
+ * command ask → param ask → bare allow → `defaultAction` (the content-form command tiers are the
197
+ * field-level tiers of upstream's arbiter, beside the generic param tiers). `check()` is idempotent and side-effect-free (combinePolicies 契约);
146
198
  * all parsing/validation happens HERE, the hot path never re-parses.
147
199
  *
148
200
  * ⚠️ 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
- export const BASH_GENERIC_PARAMS = new Set(["command", "timeout", "description", "run_in_background"]);
2
+ import { catalogRuleFaceOf, pathTargetOf } from "./tool-registry.js";
3
+ import { indexOfUnescaped, lastIndexOfUnescaped, parsePermissionRule } from "./permission-rule-syntax.js";
4
+ import { isUsablePathBase, lexicalNormalAbsolutePathOf, parseRuleText, pathRuleReaches, ruleReachesProgramRun } from "./permission-rule-model.js";
5
+ import { pathTargetValue, protectivePathTargetOf } from "./tool-registry.js";
6
+ export { parsePermissionRule };
7
+ export const BASH_GENERIC_PARAMS = new Set(catalogRuleFaceOf("Bash")?.params ?? []);
3
8
  const DEFAULT_CAPS = {
4
9
  maxRules: 256,
5
10
  maxRuleLength: 512,
@@ -7,50 +12,6 @@ const DEFAULT_CAPS = {
7
12
  maxStars: 16,
8
13
  maxScalarValueChars: 16_384,
9
14
  };
10
- function indexOfUnescaped(s, ch) {
11
- for (let i = 0; i < s.length; i++) {
12
- if (s[i] !== ch)
13
- continue;
14
- let backslashes = 0;
15
- for (let j = i - 1; j >= 0 && s[j] === "\\"; j--)
16
- backslashes++;
17
- if (backslashes % 2 === 0)
18
- return i;
19
- }
20
- return -1;
21
- }
22
- function lastIndexOfUnescaped(s, ch) {
23
- for (let i = s.length - 1; i >= 0; i--) {
24
- if (s[i] !== ch)
25
- continue;
26
- let backslashes = 0;
27
- for (let j = i - 1; j >= 0 && s[j] === "\\"; j--)
28
- backslashes++;
29
- if (backslashes % 2 === 0)
30
- return i;
31
- }
32
- return -1;
33
- }
34
- function unescapeRuleContent(s) {
35
- return s.replaceAll("\\(", "(").replaceAll("\\)", ")").replaceAll("\\\\", "\\");
36
- }
37
- export function parsePermissionRule(rule) {
38
- const open = indexOfUnescaped(rule, "(");
39
- if (open === -1)
40
- return { toolName: rule.trim() };
41
- const close = lastIndexOfUnescaped(rule, ")");
42
- if (close === -1 || close <= open)
43
- return { toolName: rule.trim() };
44
- if (close !== rule.length - 1)
45
- return { toolName: rule.trim() };
46
- const name = rule.substring(0, open);
47
- const content = rule.substring(open + 1, close);
48
- if (!name)
49
- return { toolName: rule.trim() };
50
- if (content === "" || content === "*")
51
- return { toolName: name.trim() };
52
- return { toolName: name.trim(), ruleContent: unescapeRuleContent(content) };
53
- }
54
15
  export function wildcardMatch(pattern, value) {
55
16
  let p = 0;
56
17
  let v = 0;
@@ -107,31 +68,16 @@ export function isNamespacedCoveringRuleName(name) {
107
68
  const parsed = parseNamespacedRuleName(name);
108
69
  return parsed !== undefined && (parsed.tool === undefined || parsed.tool.includes("*"));
109
70
  }
110
- const PRIMARY_FIELDS = {
111
- Bash: new Set(["command"]),
112
- Read: new Set(["file_path", "path"]),
113
- Edit: new Set(["file_path", "path"]),
114
- Write: new Set(["file_path", "path"]),
115
- MultiEdit: new Set(["file_path", "path"]),
116
- NotebookEdit: new Set(["notebook_path", "path"]),
117
- Grep: new Set(["path"]),
118
- Glob: new Set(["path"]),
119
- WebFetch: new Set(["url"]),
120
- WebSearch: new Set(["query"]),
121
- };
122
- const FILE_TOOLS = new Set(["Read", "Edit", "Write", "MultiEdit", "NotebookEdit", "Grep", "Glob"]);
123
- const FILE_TOOL_PARAMS = {
124
- Read: new Set(["file_path", "path", "offset", "limit", "pages"]),
125
- Edit: new Set(["file_path", "path", "old_string", "new_string", "replace_all"]),
126
- Write: new Set(["file_path", "path", "content"]),
127
- NotebookEdit: new Set(["notebook_path", "path", "cell_id", "cell_type", "edit_mode", "new_source"]),
128
- Grep: new Set([
129
- "pattern", "path", "glob", "type", "output_mode", "head_limit", "offset",
130
- "multiline", "context", "ignore_case", "-i", "-n", "-o", "-A", "-B", "-C",
131
- ]),
132
- Glob: new Set(["pattern", "path", "max_results"]),
71
+ export function catalogRuleFaceView(toolName) {
72
+ const face = catalogRuleFaceOf(toolName);
73
+ if (face === undefined)
74
+ return undefined;
75
+ return { ...face, pathTargeting: pathTargetOf(toolName) !== undefined };
76
+ }
77
+ export const fileToolParamVocabulary = (canonicalTool) => {
78
+ const face = catalogRuleFaceOf(canonicalTool);
79
+ return face?.params === undefined || pathTargetOf(canonicalTool) === undefined ? undefined : new Set(face.params);
133
80
  };
134
- export const fileToolParamVocabulary = (canonicalTool) => FILE_TOOL_PARAMS[canonicalTool];
135
81
  function countStars(s) {
136
82
  let n = 0;
137
83
  for (const c of s)
@@ -139,7 +85,15 @@ function countStars(s) {
139
85
  n++;
140
86
  return n;
141
87
  }
142
- function compile(rules, caps, primaryFieldGeneric, bashPrefixLane = "reject") {
88
+ function compile(rules, caps, primaryFieldGeneric, ruleFaces, pathBases = {}) {
89
+ const faceOf = (toolName) => {
90
+ if (ruleFaces !== undefined) {
91
+ const face = ruleFaces(toolName);
92
+ return { face, judged: true };
93
+ }
94
+ const face = catalogRuleFaceView(toolName);
95
+ return { face, judged: face !== undefined };
96
+ };
143
97
  const byTool = new Map();
144
98
  const namespacedCovering = { deny: [], ask: [], allow: [] };
145
99
  const issues = [];
@@ -184,7 +138,7 @@ function compile(rules, caps, primaryFieldGeneric, bashPrefixLane = "reject") {
184
138
  `Write the parenthesised rule against one exact tool name, or drop the parentheses to keep the covering rule.`);
185
139
  continue;
186
140
  }
187
- const entry = byTool.get(toolName) ?? { bare: {}, param: { deny: [], ask: [] } };
141
+ const entry = byTool.get(toolName) ?? { bare: {}, param: { deny: [], ask: [] }, content: { deny: [], ask: [] } };
188
142
  if (parsed.ruleContent === undefined) {
189
143
  if (isNamespacedCoveringRuleName(toolName)) {
190
144
  namespacedCovering[r.behavior].push({ ruleName: toolName, ruleText: text, ...(r.source !== undefined ? { source: r.source } : {}) });
@@ -200,27 +154,67 @@ function compile(rules, caps, primaryFieldGeneric, bashPrefixLane = "reject") {
200
154
  const paramPart = colon > 0 ? content.slice(0, colon).trim() : "";
201
155
  const looksLikeParamRule = colon > 0 && /^[A-Za-z_][A-Za-z0-9_]*$/.test(paramPart) && BASH_GENERIC_PARAMS.has(paramPart);
202
156
  if (!looksLikeParamRule || content.endsWith(":*")) {
203
- if (bashPrefixLane === "rules-store") {
204
- 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");
157
+ if (r.behavior === "allow") {
158
+ 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");
159
+ continue;
205
160
  }
206
- else {
207
- 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)");
161
+ const parsedCommand = parseRuleText(text, r.behavior);
162
+ if ("reject" in parsedCommand) {
163
+ bad(text, "invalid.command_rule", `${parsedCommand.reject.code}: ${parsedCommand.reject.message}`);
164
+ continue;
208
165
  }
166
+ entry.content[r.behavior].push({ parsed: parsedCommand.rule, ruleText: text, ...(r.source !== undefined ? { source: r.source } : {}) });
167
+ byTool.set(toolName, entry);
209
168
  continue;
210
169
  }
211
170
  }
212
- else if (FILE_TOOLS.has(toolName)) {
213
- if (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");
171
+ else {
172
+ const resolved = faceOf(toolName);
173
+ if (resolved.judged && resolved.face === undefined) {
174
+ bad(text, "unsupported.tool_unknown", `"${toolName}" is not a tool on this leg's roster — the rule would compile and never match`);
215
175
  continue;
216
176
  }
217
- const paramPart = content.slice(0, colon).trim();
218
- const vocabulary = FILE_TOOL_PARAMS[toolName];
219
- if (vocabulary !== undefined && !vocabulary.has(paramPart)) {
220
- bad(text, "unsupported.unknown_param", `"${paramPart}" is not a parameter of ${toolName} (it carries ${[...vocabulary].sort().join(", ")})the rule would compile and never match. ` +
221
- `For a path glob use the tool-specific lane (\`${toolName}(src/**)\` is not the generic param DSL); a Windows path such as \`${toolName}(C:/foo)\` parses as the param "C".`);
177
+ const face = resolved.face;
178
+ if (face !== undefined && face.pathTargeting === true && colon <= 0) {
179
+ if (r.behavior === "allow") {
180
+ 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");
181
+ continue;
182
+ }
183
+ const parsedPath = parseRuleText(text, r.behavior);
184
+ if ("reject" in parsedPath) {
185
+ bad(text, "invalid.command_rule", `${parsedPath.reject.code}: ${parsedPath.reject.message}`);
186
+ continue;
187
+ }
188
+ const pattern = parsedPath.rule.command;
189
+ if (parsedPath.rule.match === "path" && !pattern.startsWith("//")) {
190
+ const needsRoot = pattern.startsWith("/");
191
+ const base = pattern.startsWith("~/") ? (pathBases.home ?? "/") : needsRoot ? pathBases.root : (pathBases.cwd ?? pathBases.root);
192
+ if (!isUsablePathBase(base)) {
193
+ bad(text, "unsupported.path_base", pattern.startsWith("~/")
194
+ ? `"${text}" is relative to the home directory, and this policy's \`pathBases.home\` is not an absolute path — the rule would reach nothing`
195
+ : needsRoot
196
+ ? `"${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`
197
+ : `"${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`);
198
+ continue;
199
+ }
200
+ }
201
+ entry.content[r.behavior].push({ parsed: parsedPath.rule, ruleText: text, ...(r.source !== undefined ? { source: r.source } : {}) });
202
+ byTool.set(toolName, entry);
222
203
  continue;
223
204
  }
205
+ if (face?.params !== undefined && colon > 0) {
206
+ const paramPart = content.slice(0, colon).trim();
207
+ if (!face.params.includes(paramPart)) {
208
+ if (face.nonScalar?.includes(paramPart)) {
209
+ bad(text, "unsupported.param_non_scalar", `"${paramPart}" is a parameter of ${toolName} but its value is an object or an array — the generic matcher compares scalars only; the rule would compile and never match`);
210
+ }
211
+ else {
212
+ bad(text, "unsupported.param_unknown", `"${paramPart}" is not a parameter of ${toolName} (it carries ${[...face.params].sort().join(", ")}) — the rule would compile and never match. ` +
213
+ `For a path glob use the tool-specific lane (\`${toolName}(src/**)\` is not the generic param DSL); a Windows path such as \`${toolName}(C:/foo)\` parses as the param "C".`);
214
+ }
215
+ continue;
216
+ }
217
+ }
224
218
  }
225
219
  if (colon <= 0) {
226
220
  bad(text, "invalid.param_split", "param-level ruleContent must be `param:pattern`");
@@ -240,7 +234,7 @@ function compile(rules, caps, primaryFieldGeneric, bashPrefixLane = "reject") {
240
234
  bad(text, "invalid.cap_exceeded", `pattern exceeds maxPatternLength ${caps.maxPatternLength} or maxStars ${caps.maxStars}`);
241
235
  continue;
242
236
  }
243
- if (PRIMARY_FIELDS[toolName]?.has(param) && primaryFieldGeneric === "reject") {
237
+ if (faceOf(toolName).face?.primaryParams.includes(param) === true && primaryFieldGeneric === "reject") {
244
238
  bad(text, "invalid.primary_field", `"${param}" is ${toolName}'s primary field — the generic matcher is a trim+FULL-STRING lexical match (no shell/path semantics); '${toolName}(${param}:git push*)' would NOT match 'cd /x && git push'. Opt in with primaryFieldGeneric:"allow-lexical" if you understand this.`);
245
239
  continue;
246
240
  }
@@ -251,7 +245,7 @@ function compile(rules, caps, primaryFieldGeneric, bashPrefixLane = "reject") {
251
245
  }
252
246
  export function validatePermissionRules(rules, opts) {
253
247
  const caps = { ...DEFAULT_CAPS, ...opts?.caps };
254
- return compile(rules, caps, opts?.primaryFieldGeneric ?? "reject", opts?.bashPrefixLane ?? "reject").issues;
248
+ return compile(rules, caps, opts?.primaryFieldGeneric ?? "reject", opts?.ruleFaces, opts?.pathBases).issues;
255
249
  }
256
250
  function isPlainObjectArgs(args) {
257
251
  return typeof args === "object" && args !== null && !Array.isArray(args);
@@ -281,7 +275,8 @@ function ruleMessage(kind, ruleText, source) {
281
275
  }
282
276
  export function createPermissionRulePolicy(rules, opts) {
283
277
  const caps = { ...DEFAULT_CAPS, ...opts?.caps };
284
- const { byTool, namespacedCovering, issues } = compile(rules, caps, opts?.primaryFieldGeneric ?? "reject", opts?.bashPrefixLane ?? "reject");
278
+ const pathBases = opts?.pathBases ?? {};
279
+ const { byTool, namespacedCovering, issues } = compile(rules, caps, opts?.primaryFieldGeneric ?? "reject", opts?.ruleFaces, pathBases);
285
280
  const setLevelCap = issues.some((i) => i.code === "invalid.cap_exceeded" && i.rule === "");
286
281
  if (issues.length > 0 && ((opts?.onInvalidRule ?? "throw") === "throw" || setLevelCap)) {
287
282
  const e = new Error(`createPermissionRulePolicy: ${issues.length} invalid/unsupported rule(s):\n` +
@@ -308,9 +303,9 @@ export function createPermissionRulePolicy(rules, opts) {
308
303
  const defaultAction = opts?.defaultAction ?? "allow";
309
304
  const nameSets = { deny: [], ask: [], allow: [] };
310
305
  for (const [toolName, entry] of byTool) {
311
- if (entry.bare.deny !== undefined || entry.param.deny.length > 0)
306
+ if (entry.bare.deny !== undefined || entry.param.deny.length > 0 || entry.content.deny.length > 0)
312
307
  nameSets.deny.push(toolName);
313
- if (entry.bare.ask !== undefined || entry.param.ask.length > 0)
308
+ if (entry.bare.ask !== undefined || entry.param.ask.length > 0 || entry.content.ask.length > 0)
314
309
  nameSets.ask.push(toolName);
315
310
  if (entry.bare.allow !== undefined)
316
311
  nameSets.allow.push(toolName);
@@ -321,6 +316,19 @@ export function createPermissionRulePolicy(rules, opts) {
321
316
  }
322
317
  const coveringHit = (lane, toolName) => lane.length === 0 ? undefined : lane.find((r) => namespacedRuleNameCovers(r.ruleName, toolName));
323
318
  const hasCovering = namespacedCovering.deny.length > 0 || namespacedCovering.ask.length > 0 || namespacedCovering.allow.length > 0;
319
+ const commandParam = catalogRuleFaceOf("Bash")?.primaryParams[0] ?? "command";
320
+ const cwdBase = (pathBases.cwd ?? pathBases.root ?? "").replace(/\/+$/, "");
321
+ const contentHit = (lane, req) => {
322
+ if (lane.length === 0)
323
+ return undefined;
324
+ const pt = protectivePathTargetOf(req);
325
+ const spelled = pt !== undefined ? pathTargetValue(req.args, pt) : undefined;
326
+ const target = spelled === undefined ? undefined : lexicalNormalAbsolutePathOf(spelled.startsWith("/") ? spelled : `${cwdBase}/${spelled}`);
327
+ const command = isPlainObjectArgs(req.args) ? req.args[commandParam] : undefined;
328
+ return lane.find((r) => r.parsed.match === "subpath" || r.parsed.match === "path"
329
+ ? target !== undefined && pathRuleReaches(r.parsed, target, pathBases)
330
+ : typeof command === "string" && ruleReachesProgramRun(r.parsed, command));
331
+ };
324
332
  return {
325
333
  nameSets: [nameSets],
326
334
  check(req) {
@@ -335,6 +343,10 @@ export function createPermissionRulePolicy(rules, opts) {
335
343
  return { action: "deny", message: ruleMessage("denied", coveringDeny.ruleText, coveringDeny.source) };
336
344
  }
337
345
  if (entry) {
346
+ const contentDeny = contentHit(entry.content.deny, req);
347
+ if (contentDeny) {
348
+ return { action: "deny", message: ruleMessage("denied", contentDeny.ruleText, contentDeny.source) };
349
+ }
338
350
  const paramDeny = matchParamRules(entry.param.deny, req.args, caps.maxScalarValueChars);
339
351
  if (paramDeny) {
340
352
  return { action: "deny", message: ruleMessage("denied", paramDeny.ruleText, paramDeny.source) };
@@ -349,6 +361,10 @@ export function createPermissionRulePolicy(rules, opts) {
349
361
  return { action: "ask", message: ruleMessage("flagged", coveringAsk.ruleText, coveringAsk.source), matchedAskRule: coveringAsk.ruleText };
350
362
  }
351
363
  if (entry) {
364
+ const contentAsk = contentHit(entry.content.ask, req);
365
+ if (contentAsk) {
366
+ return { action: "ask", message: ruleMessage("flagged", contentAsk.ruleText, contentAsk.source), matchedAskRule: contentAsk.ruleText };
367
+ }
352
368
  const paramAsk = matchParamRules(entry.param.ask, req.args, caps.maxScalarValueChars);
353
369
  if (paramAsk) {
354
370
  return { action: "ask", message: ruleMessage("flagged", paramAsk.ruleText, paramAsk.source), matchedAskRule: paramAsk.ruleText };
@@ -0,0 +1,56 @@
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
+ }
22
+ /** Display form of a rule set (design/375 §5.1): every rule text is a DISPLAY value on a human trust
23
+ * boundary, and the set must stay readable however large a lane makes it — each member rides
24
+ * `inlineUntrusted` (200 cap, the same cap the durable leg's `RiskDescriptor.shadowedRule` carries),
25
+ * at most FIVE are listed, and the rest are counted rather than dropped silently. */
26
+ export declare function disclosedRuleSet(rules: readonly PersistedRuleHitRule[]): string;
27
+ /**
28
+ * The persisted-rule lane's TIGHTENING arms — a person's own standing DENY and ASK rows — applied to
29
+ * `decision`. The station runs this on EVERY decision, right after the org layer and in the same
30
+ * posture: an allow from the policy fold is exactly what a deny row must be able to refuse, and a bare
31
+ * ask is exactly what an ask row must be able to mark. Precedence across the person's rows is the
32
+ * upstream arbiter's (deny > ask > allow), already resolved inside the lane's one verdict; precedence
33
+ * across AUTHORITIES is org > personal (the org layer ran first, and a personal ask never loosens an
34
+ * org outcome — it stamps a marker on an ask, or turns an allow into one).
35
+ * · DENY — the call is refused outright, with `decisionReason: "persisted_rule"` (the same word the
36
+ * lane's allow carries: one authority, one word) and the rule text disclosed through the display
37
+ * transform; the STATION attributes the refusal (`tightened: "deny"` ⇒ it writes the pass's refusing
38
+ * layer — the attribution is a station's word and lives in the station file).
39
+ * · ASK — on an allow, a fresh ask is minted carrying `matchedAskRule` (the marker an explicit `ask`
40
+ * permission rule stamps, so the ask's origin derives as `ask_rule`: the classifier never answers
41
+ * it, the persisted ALLOW arm never clears it, and the rule-offer face reports `shadowed`); on an
42
+ * ask, the marker is stamped if absent and the message names the rule — the standing question
43
+ * outranks a remembered yes across lanes, exactly as the DSL's own order says within one; a deny
44
+ * stays a deny.
45
+ * · UNREADABLE — the wired store could not be read (a read failure, a timeout): the person's deny/ask
46
+ * rows cannot be enforced, so the call fails CLOSED the way an unreadable org snapshot does — an
47
+ * allow tightens to an ask carrying `requiresRealApproval` (no automatic lane clears it; the gate
48
+ * derives the origin `rule_store_unavailable` from its own record of the read), an ask acquires the
49
+ * bit, a deny stays a deny. Reading an unreadable store as "no rule" would erase every deny the
50
+ * person wrote the moment their file is locked or corrupt.
51
+ * An allow hit (or no hit) returns the decision untouched: the allow arm is the station's.
52
+ */
53
+ export declare function applyPersistedTightening(decision: PermissionResult, read: PersistedRuleRead | undefined): {
54
+ decision: PermissionResult;
55
+ tightened?: "deny" | "ask" | "unreadable";
56
+ };
@@ -0,0 +1,48 @@
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 = "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";
12
+ return {
13
+ decision: decision.action === "allow"
14
+ ? { action: "ask", message, decisionReason: "persisted_rule", requiresRealApproval: true, ...(decision.updatedInput !== undefined ? { updatedInput: decision.updatedInput } : {}) }
15
+ : { ...decision, requiresRealApproval: true, message: `${decision.message !== undefined ? `${decision.message} ` : ""}(${message})` },
16
+ tightened: "unreadable",
17
+ };
18
+ }
19
+ const hit = read?.hit;
20
+ if (hit === undefined || hit.behavior === "allow")
21
+ return { decision };
22
+ const shown = disclosedRuleSet(hit.rules);
23
+ if (hit.behavior === "deny") {
24
+ return { decision: { action: "deny", message: `a persisted deny rule (${shown}) refuses this call`, decisionReason: "persisted_rule" }, tightened: "deny" };
25
+ }
26
+ if (decision.action === "deny")
27
+ return { decision };
28
+ if (decision.action === "allow") {
29
+ return {
30
+ decision: {
31
+ action: "ask",
32
+ message: `a persisted ask rule (${shown}) requires approval for this call`,
33
+ decisionReason: "persisted_rule",
34
+ matchedAskRule: shown,
35
+ ...(decision.updatedInput !== undefined ? { updatedInput: decision.updatedInput } : {}),
36
+ },
37
+ tightened: "ask",
38
+ };
39
+ }
40
+ return {
41
+ decision: {
42
+ ...decision,
43
+ ...(decision.matchedAskRule === undefined ? { matchedAskRule: shown } : {}),
44
+ message: `${decision.message !== undefined ? `${decision.message} ` : ""}(a persisted ask rule (${shown}) also requires approval for this call)`,
45
+ },
46
+ tightened: "ask",
47
+ };
48
+ }
@@ -1,5 +1,6 @@
1
1
  import { Type } from "typebox";
2
2
  import { delimitUntrusted } from "./untrusted-text.js";
3
+ import { toolFace } from "./tool-catalog-entries.js";
3
4
  export const PRESENT_PLAN_TOOL_NAME = "ExitPlanMode";
4
5
  export const ENTER_PLAN_MODE_TOOL_NAME = "EnterPlanMode";
5
6
  const ENTER_PLAN_MODE_DESCRIPTION = `Enter PLAN MODE (read-only): stop making changes and switch to exploration/planning. For the rest of this run, write/edit/mutating tool calls are REJECTED while read-only tools (Read, Grep, Glob, etc.) still work. When your plan is ready, call ${PRESENT_PLAN_TOOL_NAME} with it — the run PAUSES for a human to approve it, then continues (read-only lifted) so you can execute.
@@ -31,10 +32,9 @@ BAD: "Fix the typo in the README" (trivial; nothing to align on). "Remove the le
31
32
  export function createEnterPlanModeTool(enterPlanMode) {
32
33
  return {
33
34
  name: ENTER_PLAN_MODE_TOOL_NAME,
34
- contract: { contractId: "core.enter_plan_mode@1", implementationRevision: "1" },
35
+ ...toolFace("enter-plan-mode"),
35
36
  description: ENTER_PLAN_MODE_DESCRIPTION,
36
37
  parameters: Type.Object({}),
37
- effect: "read",
38
38
  executionMode: "sequential",
39
39
  execute: async () => {
40
40
  enterPlanMode();
@@ -61,14 +61,13 @@ const PRESENT_PLAN_DESCRIPTION = [
61
61
  export function createPresentPlanTool(requestReview) {
62
62
  return {
63
63
  name: PRESENT_PLAN_TOOL_NAME,
64
- contract: { contractId: "core.exit_plan_mode@1", implementationRevision: "1" },
64
+ ...toolFace("exit-plan-mode"),
65
65
  description: PRESENT_PLAN_DESCRIPTION,
66
66
  parameters: Type.Object({
67
67
  plan: Type.String({
68
68
  description: "Your full proposed plan, in markdown. The human reviewer approves / edits / rejects it before you act.",
69
69
  }),
70
70
  }),
71
- effect: "read",
72
71
  execute: async (args) => {
73
72
  const { plan } = args;
74
73
  requestReview({ reason: "ExitPlanMode" });
@@ -1,4 +1,5 @@
1
- import { canonicalizeTarget, fileArgPath } from "../../tools/fs/safety.js";
1
+ import { canonicalizeTarget } from "../../tools/fs/safety.js";
2
+ import { pathTargetValue } from "../tool-registry.js";
2
3
  import { PATH_WRITE_TOOLS, isWithin } from "./session-rule-policy.js";
3
4
  import { parseSkillToolEntry, skillSpecifierRejection } from "../skill-tool-specifier.js";
4
5
  export class ActiveSkillScope {
@@ -74,7 +75,9 @@ export function createActiveSkillScopePolicy(opts) {
74
75
  }
75
76
  }
76
77
  const pathConstrainingActive = manifests.some((m) => m.allowPaths && m.allowPaths.length > 0);
77
- if (pathConstrainingActive && !PATH_WRITE_TOOLS.has(toolName)) {
78
+ const declaredTarget = req.face?.pathTarget;
79
+ const skillScopeEligible = declaredTarget !== undefined && declaredTarget.skillScopeEligible === true;
80
+ if (pathConstrainingActive && !skillScopeEligible) {
78
81
  const eff = toolEffects?.get(toolName) ?? "write";
79
82
  if (eff !== "read") {
80
83
  const ids = manifests.filter((m) => m.allowPaths?.length).map((m) => m.lineageId).join(", ");
@@ -85,10 +88,10 @@ export function createActiveSkillScopePolicy(opts) {
85
88
  };
86
89
  }
87
90
  }
88
- if (PATH_WRITE_TOOLS.has(toolName)) {
91
+ if (skillScopeEligible) {
89
92
  const constraining = manifests.filter((m) => m.allowPaths && m.allowPaths.length > 0);
90
93
  if (constraining.length > 0) {
91
- const path = fileArgPath(req.args);
94
+ const path = pathTargetValue(req.args, declaredTarget);
92
95
  if (typeof path !== "string" || path.length === 0) {
93
96
  return {
94
97
  action: "deny",