@sema-agent/core 7.8.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 (73) hide show
  1. package/CHANGELOG.md +52 -1
  2. package/dist/agents/subagent.d.ts +1 -1
  3. package/dist/core/ask-origin.d.ts +12 -1
  4. package/dist/core/ask-origin.js +5 -1
  5. package/dist/core/checkpoint-store.d.ts +34 -28
  6. package/dist/core/gate-lanes.js +49 -23
  7. package/dist/core/gate-outcome.d.ts +8 -4
  8. package/dist/core/gate-outcome.js +2 -1
  9. package/dist/core/hooks.d.ts +56 -31
  10. package/dist/core/permission-rule-consent.d.ts +29 -36
  11. package/dist/core/permission-rule-consent.js +110 -60
  12. package/dist/core/permission-rule-model.d.ts +254 -55
  13. package/dist/core/permission-rule-model.js +323 -43
  14. package/dist/core/permission-rule-org.d.ts +9 -8
  15. package/dist/core/permission-rule-org.js +6 -15
  16. package/dist/core/permission-rule-provider.d.ts +10 -6
  17. package/dist/core/permission-rule-provider.js +12 -8
  18. package/dist/core/permission-rule-session.d.ts +7 -6
  19. package/dist/core/permission-rule-session.js +29 -10
  20. package/dist/core/permission-rule-store.d.ts +40 -18
  21. package/dist/core/permission-rule-store.js +68 -42
  22. package/dist/core/permission-rule-sync.d.ts +9 -3
  23. package/dist/core/permission-rule-sync.js +29 -25
  24. package/dist/core/permission-rule-syntax.d.ts +30 -0
  25. package/dist/core/permission-rule-syntax.js +44 -0
  26. package/dist/core/permission-rules.d.ts +55 -33
  27. package/dist/core/permission-rules.js +65 -55
  28. package/dist/core/persisted-rule-arms.d.ts +56 -0
  29. package/dist/core/persisted-rule-arms.js +48 -0
  30. package/dist/core/runner/contracts.d.ts +21 -2
  31. package/dist/core/runner/permission-rule-lanes.d.ts +33 -26
  32. package/dist/core/runner/permission-rule-lanes.js +27 -21
  33. package/dist/core/runner/prepare-gate-stations.js +1 -1
  34. package/dist/core/runner/prepare-safety-scan.js +8 -2
  35. package/dist/core/runner/prepare-task.js +1 -1
  36. package/dist/core/runner/resume-admission.d.ts +53 -0
  37. package/dist/core/runner/resume-admission.js +83 -0
  38. package/dist/core/runner/resume-apply.d.ts +50 -0
  39. package/dist/core/runner/resume-apply.js +184 -0
  40. package/dist/core/runner/resume-checkpoint-screen.d.ts +18 -0
  41. package/dist/core/runner/resume-checkpoint-screen.js +108 -0
  42. package/dist/core/runner/resume-claim.d.ts +32 -0
  43. package/dist/core/runner/resume-claim.js +27 -0
  44. package/dist/core/runner/resume-internals-and-config.d.ts +33 -0
  45. package/dist/core/runner/resume-internals-and-config.js +50 -0
  46. package/dist/core/runner/resume-policy-outcome.d.ts +31 -0
  47. package/dist/core/runner/resume-policy-outcome.js +127 -0
  48. package/dist/core/runner/resume-preflight.d.ts +40 -0
  49. package/dist/core/runner/resume-preflight.js +122 -0
  50. package/dist/core/runner/resume-review-outcome.d.ts +30 -0
  51. package/dist/core/runner/resume-review-outcome.js +88 -0
  52. package/dist/core/runner/run-harness-handlers.js +1 -1
  53. package/dist/core/runner/runtask.d.ts +20 -0
  54. package/dist/core/runner/runtask.js +85 -734
  55. package/dist/core/runner/tool-end-body.d.ts +12 -5
  56. package/dist/core/runner/tool-end-body.js +5 -5
  57. package/dist/core/runner/tool-output-projection.d.ts +9 -6
  58. package/dist/core/runner/tool-output-projection.js +1 -18
  59. package/dist/core/runner/turn-attachments.d.ts +2 -2
  60. package/dist/core/store-contracts/permission-rule-sync-contract.js +29 -10
  61. package/dist/core/tool-roster.d.ts +9 -0
  62. package/dist/core/tool-roster.js +15 -5
  63. package/dist/index.d.ts +2 -2
  64. package/dist/index.js +2 -2
  65. package/dist/stores/file/checkpoint-store.js +1 -1
  66. package/dist/stores/file/permission-rule-store.d.ts +28 -23
  67. package/dist/stores/file/permission-rule-store.js +64 -16
  68. package/dist/tools/fs/bash-readonly-classifier.d.ts +1 -1
  69. package/dist/tools/fs/fs-pdf.d.ts +1 -1
  70. package/dist/tools/fs/notebook.d.ts +1 -1
  71. package/dist/tools/fs/safety.d.ts +1 -1
  72. package/package.json +1 -1
  73. package/test/export-surface.snapshot.json +43 -11
@@ -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,25 +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"
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"
46
75
  /** design/388 B15 — the closed rule-lint set over a tool's RULE FACE (derived scalar vocabulary; a
47
76
  * declaration only narrows it): the named parameter is not one the tool carries; it is carried but
48
77
  * is an object/array (a rule on it can never match); the tool is unknown to the resolver a leg
49
78
  * supplied (absent resolver ⇒ the catalog alone judges and an unknown tool stays permissive). */
50
79
  | "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"
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"
54
87
  /** A parenthesised rule whose NAME is a covering spelling in a non-MCP protocol namespace
55
88
  * (`a2a__<peer>__*(x:y)`, `a2a__<peer>(x:y)`). The param DSL is keyed by EXACT tool name, and a
56
89
  * covering spelling is not one — no mounted tool can ever bear it, so the rule compiled clean and
@@ -89,19 +122,6 @@ export interface PermissionRulePolicyOptions {
89
122
  /** 已知主字段参数规则(`Bash(command:…)` 等)的处置。默认 `"reject"`(整串 lexical ≠ 前缀/AST,
90
123
  * 假安全感);`"allow-lexical"` 显式接受并按 generic 语义匹配。 */
91
124
  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
125
  caps?: Partial<PermissionRuleCaps>;
106
126
  /**
107
127
  * design/388 B15 — the leg's RULE-FACE resolver (typically `ruleFacesFromRoster(prepared.toolRoster)`): with it
@@ -110,13 +130,14 @@ export interface PermissionRulePolicyOptions {
110
130
  * carry stays permissive (a rule for it compiles unjudged).
111
131
  */
112
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;
113
140
  }
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
141
  /**
121
142
  * Linear-time `*` glob match (design/127 M6: NOT a RegExp — patterns may come from tenant-supplied
122
143
  * Settings layers; the classic two-pointer walk has zero ReDoS surface and the same semantics as CC's
@@ -168,11 +189,12 @@ export declare function catalogRuleFaceView(toolName: string): RuleFaceView | un
168
189
  export declare const fileToolParamVocabulary: (canonicalTool: string) => ReadonlySet<string> | undefined;
169
190
  /** Dry-run 校验/迁移报告:不 throw,返回全部 issues(含 `unsupported.*` 分类,
170
191
  * 供从 CC settings 迁移的部署逐条对照)。 */
171
- export declare function validatePermissionRules(rules: PermissionRule[], opts?: Pick<PermissionRulePolicyOptions, "primaryFieldGeneric" | "caps" | "bashPrefixLane" | "ruleFaces">): PermissionRuleIssue[];
192
+ export declare function validatePermissionRules(rules: PermissionRule[], opts?: Pick<PermissionRulePolicyOptions, "primaryFieldGeneric" | "caps" | "ruleFaces" | "pathBases">): PermissionRuleIssue[];
172
193
  /**
173
194
  * 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 契约);
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 契约);
176
198
  * all parsing/validation happens HERE, the hot path never re-parses.
177
199
  *
178
200
  * ⚠️ Composition note: an `allow` from this policy is "no objection", NOT a
@@ -1,5 +1,9 @@
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, lexicalNormalAbsolutePathOf, parseRuleText, pathRuleReaches, ruleReachesProgramRun } from "./permission-rule-model.js";
5
+ import { pathTargetValue, protectivePathTargetOf } from "./tool-registry.js";
6
+ export { parsePermissionRule };
3
7
  export const BASH_GENERIC_PARAMS = new Set(catalogRuleFaceOf("Bash")?.params ?? []);
4
8
  const DEFAULT_CAPS = {
5
9
  maxRules: 256,
@@ -8,50 +12,6 @@ const DEFAULT_CAPS = {
8
12
  maxStars: 16,
9
13
  maxScalarValueChars: 16_384,
10
14
  };
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
15
  export function wildcardMatch(pattern, value) {
56
16
  let p = 0;
57
17
  let v = 0;
@@ -125,7 +85,7 @@ function countStars(s) {
125
85
  n++;
126
86
  return n;
127
87
  }
128
- function compile(rules, caps, primaryFieldGeneric, bashPrefixLane = "reject", ruleFaces) {
88
+ function compile(rules, caps, primaryFieldGeneric, ruleFaces, pathBases = {}) {
129
89
  const faceOf = (toolName) => {
130
90
  if (ruleFaces !== undefined) {
131
91
  const face = ruleFaces(toolName);
@@ -178,7 +138,7 @@ function compile(rules, caps, primaryFieldGeneric, bashPrefixLane = "reject", ru
178
138
  `Write the parenthesised rule against one exact tool name, or drop the parentheses to keep the covering rule.`);
179
139
  continue;
180
140
  }
181
- const entry = byTool.get(toolName) ?? { bare: {}, param: { deny: [], ask: [] } };
141
+ const entry = byTool.get(toolName) ?? { bare: {}, param: { deny: [], ask: [] }, content: { deny: [], ask: [] } };
182
142
  if (parsed.ruleContent === undefined) {
183
143
  if (isNamespacedCoveringRuleName(toolName)) {
184
144
  namespacedCovering[r.behavior].push({ ruleName: toolName, ruleText: text, ...(r.source !== undefined ? { source: r.source } : {}) });
@@ -194,12 +154,17 @@ function compile(rules, caps, primaryFieldGeneric, bashPrefixLane = "reject", ru
194
154
  const paramPart = colon > 0 ? content.slice(0, colon).trim() : "";
195
155
  const looksLikeParamRule = colon > 0 && /^[A-Za-z_][A-Za-z0-9_]*$/.test(paramPart) && BASH_GENERIC_PARAMS.has(paramPart);
196
156
  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");
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;
199
160
  }
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)");
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;
202
165
  }
166
+ entry.content[r.behavior].push({ parsed: parsedCommand.rule, ruleText: text, ...(r.source !== undefined ? { source: r.source } : {}) });
167
+ byTool.set(toolName, entry);
203
168
  continue;
204
169
  }
205
170
  }
@@ -211,7 +176,30 @@ function compile(rules, caps, primaryFieldGeneric, bashPrefixLane = "reject", ru
211
176
  }
212
177
  const face = resolved.face;
213
178
  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");
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);
215
203
  continue;
216
204
  }
217
205
  if (face?.params !== undefined && colon > 0) {
@@ -257,7 +245,7 @@ function compile(rules, caps, primaryFieldGeneric, bashPrefixLane = "reject", ru
257
245
  }
258
246
  export function validatePermissionRules(rules, opts) {
259
247
  const caps = { ...DEFAULT_CAPS, ...opts?.caps };
260
- return compile(rules, caps, opts?.primaryFieldGeneric ?? "reject", opts?.bashPrefixLane ?? "reject", opts?.ruleFaces).issues;
248
+ return compile(rules, caps, opts?.primaryFieldGeneric ?? "reject", opts?.ruleFaces, opts?.pathBases).issues;
261
249
  }
262
250
  function isPlainObjectArgs(args) {
263
251
  return typeof args === "object" && args !== null && !Array.isArray(args);
@@ -287,7 +275,8 @@ function ruleMessage(kind, ruleText, source) {
287
275
  }
288
276
  export function createPermissionRulePolicy(rules, opts) {
289
277
  const caps = { ...DEFAULT_CAPS, ...opts?.caps };
290
- const { byTool, namespacedCovering, issues } = compile(rules, caps, opts?.primaryFieldGeneric ?? "reject", opts?.bashPrefixLane ?? "reject", opts?.ruleFaces);
278
+ const pathBases = opts?.pathBases ?? {};
279
+ const { byTool, namespacedCovering, issues } = compile(rules, caps, opts?.primaryFieldGeneric ?? "reject", opts?.ruleFaces, pathBases);
291
280
  const setLevelCap = issues.some((i) => i.code === "invalid.cap_exceeded" && i.rule === "");
292
281
  if (issues.length > 0 && ((opts?.onInvalidRule ?? "throw") === "throw" || setLevelCap)) {
293
282
  const e = new Error(`createPermissionRulePolicy: ${issues.length} invalid/unsupported rule(s):\n` +
@@ -314,9 +303,9 @@ export function createPermissionRulePolicy(rules, opts) {
314
303
  const defaultAction = opts?.defaultAction ?? "allow";
315
304
  const nameSets = { deny: [], ask: [], allow: [] };
316
305
  for (const [toolName, entry] of byTool) {
317
- 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)
318
307
  nameSets.deny.push(toolName);
319
- 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)
320
309
  nameSets.ask.push(toolName);
321
310
  if (entry.bare.allow !== undefined)
322
311
  nameSets.allow.push(toolName);
@@ -327,6 +316,19 @@ export function createPermissionRulePolicy(rules, opts) {
327
316
  }
328
317
  const coveringHit = (lane, toolName) => lane.length === 0 ? undefined : lane.find((r) => namespacedRuleNameCovers(r.ruleName, toolName));
329
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
+ };
330
332
  return {
331
333
  nameSets: [nameSets],
332
334
  check(req) {
@@ -341,6 +343,10 @@ export function createPermissionRulePolicy(rules, opts) {
341
343
  return { action: "deny", message: ruleMessage("denied", coveringDeny.ruleText, coveringDeny.source) };
342
344
  }
343
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
+ }
344
350
  const paramDeny = matchParamRules(entry.param.deny, req.args, caps.maxScalarValueChars);
345
351
  if (paramDeny) {
346
352
  return { action: "deny", message: ruleMessage("denied", paramDeny.ruleText, paramDeny.source) };
@@ -355,6 +361,10 @@ export function createPermissionRulePolicy(rules, opts) {
355
361
  return { action: "ask", message: ruleMessage("flagged", coveringAsk.ruleText, coveringAsk.source), matchedAskRule: coveringAsk.ruleText };
356
362
  }
357
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
+ }
358
368
  const paramAsk = matchParamRules(entry.param.ask, req.args, caps.maxScalarValueChars);
359
369
  if (paramAsk) {
360
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
+ }
@@ -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;
@@ -9,13 +9,15 @@
9
9
  *
10
10
  * ## One read per adjudicated call
11
11
  *
12
- * The gate consults the org lane (`orgRules.adjudicate`) on every decision and the personal lane
13
- * (`persistedRules.admits`) only on a surviving ask, in that order. The org lane's read is therefore
12
+ * The gate consults the org lane (`orgRules.adjudicate`) and then the personal lane
13
+ * (`persistedRules.adjudicate`) on every decision, in that order (the personal lane's deny/ask rows must
14
+ * be able to tighten an allow, so it can no longer wait for a surviving ask). The org lane's read is
14
15
  * HANDED to the personal lane by `toolCallId` (taken once, then forgotten), so a governed deployment pays
15
16
  * one store query — one org-source fetch — per call, exactly as it did with two seams. An entry the
16
- * personal lane never claims (an allow, a deny) is evicted first-in-first-out past a small bound; an
17
- * evicted or never-handed entry simply costs the personal lane its own read. The handoff is a cost
18
- * device, not a semantic one: both lanes judge one snapshot, which is the honest shape for one decision.
17
+ * personal lane never claims (a call on a tool it does not speak for) is evicted first-in-first-out past
18
+ * a small bound; an evicted or never-handed entry simply costs the personal lane its own read. The
19
+ * handoff is a cost device, not a semantic one: both lanes judge one snapshot, which is the honest shape
20
+ * for one decision.
19
21
  *
20
22
  * ## Where the lanes do NOT go
21
23
  *
@@ -25,14 +27,12 @@
25
27
  */
26
28
  import type { AskRuleEvidence, ToolCallRequest } from "../tool-policy.js";
27
29
  import { persistedRuleMandateOf, type OrgGateVerdict, type PersistedRuleAnswer, type PersistedRuleHit } from "../hooks.js";
28
- import type { PersistedAllowRule, RuleOffer, SegmentCoverage } from "../permission-rule-model.js";
30
+ import type { PersistedRule, PersistedRuleVerdict, RuleOffer, SegmentCoverage } from "../permission-rule-model.js";
29
31
  import { type OrgRuleResolution } from "../permission-rule-org.js";
30
32
  import type { PermissionRuleStoreProvider } from "../permission-rule-provider.js";
31
- /** The one SHELL tool the persisted-rule lane speaks for. */
33
+ /** The one SHELL tool the persisted-rule lane's COMMAND grammar speaks for (the rule-offer face mints for
34
+ * it alone). The PATH grammar's tools are derived from the catalog (`ruleToolGrammarOf`). */
32
35
  export declare const PERSISTED_RULE_TOOL = "Bash";
33
- /** design/382 §2.5 — the READ tool the directory-rule family speaks for (its one other admission
34
- * point, the cd-segment notch, lives inside the model's own conjunction/coverage arms). */
35
- export declare const DIRECTORY_RULE_TOOL = "Read";
36
36
  /**
37
37
  * design/252 G-2 — the evidence an INHERITED-lane ask carries.
38
38
  *
@@ -67,28 +67,35 @@ export declare function orgRevisionEvidenceOf(resolution: OrgRuleResolution, onD
67
67
  revision?: number;
68
68
  };
69
69
  /**
70
- * design/252 G-2 / design/375 — project the admitting COVERAGE SET onto the gate's rule-lane answer:
71
- * each member's canonical text plus its own add DOTS. A single rule admitting the whole command is a
72
- * one-element set; a compound admitted by the per-segment conjunction is the set of covering rules in
73
- * segment order (the model's own reporting-order contract).
70
+ * design/252 G-2 / design/375 — project the model's verdict onto the gate's rule-lane answer: the
71
+ * behavior that spoke, and each member's canonical text plus its own add DOTS. For an allow the set is
72
+ * the admitting COVERAGE SET (a single rule admitting the whole command is a one-element set; a compound
73
+ * admitted by the per-segment conjunction is the set of covering rules in segment order the model's
74
+ * own reporting-order contract); for a deny/ask it is the one reaching rule the precedence walk chose.
74
75
  *
75
76
  * `adds` on a listed rule are the LIVE adds — the store's read already dropped tombstoned dots — so
76
- * this is the set that actually admitted the call, which is the identity a later audit reconciles a
77
+ * this is the set that actually decided the call, which is the identity a later audit reconciles a
77
78
  * shadowed ask against. The sanitized display text deliberately is not one. Copied rather than
78
79
  * aliased: the evidence must not change under a store that reuses its row objects.
79
80
  */
80
- export declare function persistedRuleHitOf(admitting: readonly PersistedAllowRule[] | undefined): PersistedRuleHit | undefined;
81
+ export declare function persistedRuleHitOf(verdict: PersistedRuleVerdict | undefined): PersistedRuleHit | undefined;
81
82
  /**
82
- * design/382 §2.5-1 — the persisted-rule lane's READ arm: the call's target path in the SAME lexical
83
- * identity the rule family is defined over — absolute as spelled, relative resolved against the LIVE
84
- * tracked working directory (the very base the Read tool itself resolves against; task root when no
85
- * tracker moved), then normalized lexically, zero IO. One admission predicate
86
- * ({@link directoryRuleAdmits}), the one eligibility predicate for scope/liveness, first table-order hit
87
- * reported. Everything this lane does NOT do is structural: it clears ASKS only the read fences
88
- * (workspace containment, the sensitive-read deny face) judge inside the tool exactly as they would with
89
- * no rule anywhere, and an unresolvable path is simply no admission (fail toward asking).
83
+ * design/382 §2.5-1, widened to every path-targeting tool — the persisted-rule lane's PATH arm: the call's
84
+ * target path in the SAME lexical identity the rule family is defined over — absolute as spelled, relative
85
+ * resolved against the LIVE tracked working directory (the very base the file tools themselves resolve
86
+ * against; task root when no tracker moved), then normalized lexically, zero IO.
87
+ *
88
+ * TWO readings of the call's path slot, one per direction (design/388 B6/B17): the tightening arms judge
89
+ * the PROTECTIVE target (the object face's declaration, the catalog's for the name as the floor — reading
90
+ * the catalog for an undeclared same-name tool can only tighten a deny); the allow arm judges the DECLARED
91
+ * target (the face's OWN declaration only — a same-name tool that declares no path target is unconfirmable
92
+ * and no grant admits it). The precedence walk, the admission predicates and the Read-only allow are the
93
+ * model's (`adjudicatePersistedPathRules`). An ALLOW here clears ASKS only — the read fences (workspace
94
+ * containment, the sensitive-read deny face) judge inside the tool exactly as they would with no rule
95
+ * anywhere — and an unresolvable path is simply no verdict (fail toward asking on the allow side, no
96
+ * refusal on the tightening side: a rule about a path speaks about no other shape).
90
97
  */
91
- export declare function directoryRuleLaneAnswer(table: readonly PersistedAllowRule[], args: unknown, ctx: {
98
+ export declare function pathRuleLaneAnswer(table: readonly PersistedRule[], req: Pick<ToolCallRequest, "toolName" | "args" | "face">, ctx: {
92
99
  root: string | undefined;
93
100
  sessionId: string | undefined;
94
101
  liveCwd: string | undefined;
@@ -97,7 +104,7 @@ export declare function directoryRuleLaneAnswer(table: readonly PersistedAllowRu
97
104
  * a deployment without one keeps a decision path byte-identical to a build without the feature. */
98
105
  export interface PermissionRuleLanes {
99
106
  personal: {
100
- admits: (req: ToolCallRequest) => Promise<PersistedRuleAnswer>;
107
+ adjudicate: (req: ToolCallRequest) => Promise<PersistedRuleAnswer>;
101
108
  } | undefined;
102
109
  org: {
103
110
  adjudicate: (req: ToolCallRequest) => Promise<OrgGateVerdict>;