@sema-agent/core 7.41.0 → 7.42.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 (79) hide show
  1. package/CHANGELOG.md +90 -0
  2. package/dist/agents/subagent.js +2 -0
  3. package/dist/core/agent-definition.d.ts +1 -1
  4. package/dist/core/allow-entry.d.ts +241 -0
  5. package/dist/core/allow-entry.js +340 -0
  6. package/dist/core/allow-rule-shadow.d.ts +18 -0
  7. package/dist/core/allow-rule-shadow.js +1 -0
  8. package/dist/core/ask-origin.d.ts +5 -5
  9. package/dist/core/ask-origin.js +1 -1
  10. package/dist/core/auto-mode.d.ts +3 -2
  11. package/dist/core/checkpoint-store.d.ts +11 -19
  12. package/dist/core/checkpoint-store.js +1 -1
  13. package/dist/core/decision-receipt.d.ts +4 -2
  14. package/dist/core/decision-receipt.js +1 -1
  15. package/dist/core/engine-notice.d.ts +8 -5
  16. package/dist/core/gate-fold.js +1 -1
  17. package/dist/core/gate-lanes.d.ts +1 -49
  18. package/dist/core/gate-lanes.js +85 -223
  19. package/dist/core/governance-codes.d.ts +3 -3
  20. package/dist/core/governance-codes.js +3 -3
  21. package/dist/core/hooks.d.ts +13 -10
  22. package/dist/core/hooks.js +5 -3
  23. package/dist/core/mode-policy.d.ts +3 -3
  24. package/dist/core/mode-policy.js +2 -2
  25. package/dist/core/permission-rules.d.ts +24 -23
  26. package/dist/core/permission-rules.js +37 -33
  27. package/dist/core/reminder-disclosure.d.ts +53 -2
  28. package/dist/core/reminder-disclosure.js +49 -36
  29. package/dist/core/rule-lane-reads.d.ts +84 -0
  30. package/dist/core/rule-lane-reads.js +164 -0
  31. package/dist/core/runner/contracts.d.ts +4 -2
  32. package/dist/core/runner/gate-exit.d.ts +3 -3
  33. package/dist/core/runner/image.js +4 -3
  34. package/dist/core/runner/inline-image.d.ts +14 -0
  35. package/dist/core/runner/inline-image.js +26 -0
  36. package/dist/core/runner/permission-rule-lanes.d.ts +3 -1
  37. package/dist/core/runner/permission-rule-lanes.js +1 -1
  38. package/dist/core/runner/prepare-ask-lane.d.ts +0 -19
  39. package/dist/core/runner/prepare-ask-lane.js +2 -2
  40. package/dist/core/runner/prepare-inherited-gate.d.ts +2 -0
  41. package/dist/core/runner/prepare-inherited-gate.js +1 -0
  42. package/dist/core/runner/prepare-park-ask.js +3 -3
  43. package/dist/core/runner/prepare-policy-chain.d.ts +12 -0
  44. package/dist/core/runner/prepare-policy-chain.js +10 -9
  45. package/dist/core/runner/prepare-project-context.d.ts +13 -3
  46. package/dist/core/runner/prepare-project-context.js +5 -4
  47. package/dist/core/runner/prepare-task.js +8 -5
  48. package/dist/core/runner/prepare-turn-wiring.d.ts +16 -3
  49. package/dist/core/runner/prepare-turn-wiring.js +19 -18
  50. package/dist/core/runner/run-leg.js +1 -1
  51. package/dist/core/runner/runtask.js +2 -1
  52. package/dist/core/runner-deps.d.ts +11 -0
  53. package/dist/core/skill-grant.d.ts +8 -47
  54. package/dist/core/skill-grant.js +15 -136
  55. package/dist/core/synthetic-read-verdict.d.ts +108 -0
  56. package/dist/core/synthetic-read-verdict.js +111 -0
  57. package/dist/core/task-spec.d.ts +18 -0
  58. package/dist/core/tool-name-readings.d.ts +1 -1
  59. package/dist/core/tool-name-readings.js +1 -1
  60. package/dist/core/tool-policy.d.ts +25 -24
  61. package/dist/core/tool-policy.js +26 -17
  62. package/dist/core/tool-spec.d.ts +7 -0
  63. package/dist/core/trace.d.ts +16 -0
  64. package/dist/index.d.ts +5 -1
  65. package/dist/index.js +4 -0
  66. package/dist/internal/harness-types.d.ts +1 -1
  67. package/dist/tools/fs/dir-entries.d.ts +69 -0
  68. package/dist/tools/fs/dir-entries.js +60 -0
  69. package/dist/tools/fs/fs-pdf.d.ts +7 -11
  70. package/dist/tools/fs/fs-pdf.js +5 -14
  71. package/dist/tools/fs/fs-read.d.ts +173 -2
  72. package/dist/tools/fs/fs-read.js +399 -286
  73. package/dist/tools/fs/image-admission.d.ts +51 -0
  74. package/dist/tools/fs/image-admission.js +36 -0
  75. package/dist/tools/fs/search.js +3 -7
  76. package/package.json +1 -1
  77. package/test/export-surface.snapshot.json +29 -1
  78. package/wire-changes/core@7.41.1.json +159 -0
  79. package/wire-changes/core@7.42.0.json +1107 -0
@@ -78,9 +78,9 @@ export interface ModeGateSeats {
78
78
  * `readOnlyHint` ({@link AgentTool.mcpReadOnlyHint})? The mode gate's `readOnlyDeclared` seat over the leg's live tools. */
79
79
  export declare function mountedToolDeclaresReadOnly(tools: readonly AgentTool[], toolName: string): boolean;
80
80
  /** The ONE rule of the unstated question, read at the fold's last station and at the approved-edit round: an allow nobody
81
- * stated (no `decisionReason`, and no screening face's own `allow` on these arguments) on a tool that states no permission
82
- * of its own, under a mode whose `unstated` answer is `ask`. */
83
- export declare function asksUnstated(d: PermissionResult, seat: "passthrough" | undefined, mode: PermissionMode | undefined, hookStated: boolean): boolean;
81
+ * stated (no `decisionReason`) on a tool that states no permission of its own, under a mode whose `unstated` answer is
82
+ * `ask`. Who may answer it is the allow layer's business (a hook's own `allow` is one of its members), not this rule's. */
83
+ export declare function asksUnstated(d: PermissionResult, seat: "passthrough" | undefined, mode: PermissionMode | undefined): boolean;
84
84
  /** The unstated question's sentence (the reference implementation's passthrough sentence, without its product name). */
85
85
  export declare const unstatedAskMessage: (toolName: string) => string;
86
86
  /** One call's permission seat: `passthrough` (an allow nobody stated meets the mode's unstated answer) and the question
@@ -42,8 +42,8 @@ export function modeAcceptsFileCommand(mode, toolName, args) {
42
42
  export function mountedToolDeclaresReadOnly(tools, toolName) {
43
43
  return tools.findLast((t) => t.name === toolName)?.mcpReadOnlyHint === true;
44
44
  }
45
- export function asksUnstated(d, seat, mode, hookStated) {
46
- return d.action === "allow" && d.decisionReason === undefined && !hookStated && seat === "passthrough" && mode !== undefined && modePolicyOf(mode).unstated === "ask";
45
+ export function asksUnstated(d, seat, mode) {
46
+ return d.action === "allow" && d.decisionReason === undefined && seat === "passthrough" && mode !== undefined && modePolicyOf(mode).unstated === "ask";
47
47
  }
48
48
  export const unstatedAskMessage = (toolName) => `The agent requested permission to use ${toolName}, but it has not been granted yet.`;
49
49
  export function permissionSeatAt(input, args) {
@@ -6,8 +6,11 @@
6
6
  * 架构边界(design/127 §0,重判过的「core 不长 rule interpreter」):这是纯函数式、opt-in、零
7
7
  * run-loop 介入的「声明式数据→ToolPolicy」工厂(`createCoarseCommandNamePolicy` 同形态先例)。
8
8
  * core 不读设置文件、不管 source 层级合并 —— Settings-loader 把各层合并成**单一规则表**后编译
9
- * **一个** policy(不要按层各编译再组合:combinePolicies 的 fold 里 ask 胜 allow,跨 policy 的
10
- * 显式 allow 规则会被另一 policy 的 ask 吞掉)。
9
+ * **一个** policy。
10
+ *
11
+ * ## Deny and ask only
12
+ * A policy fold says `deny` / `ask`. An allow rule is refused (`config.invalid_permission_rules`, one `unsupported.allow`
13
+ * issue per rule): "this does not need asking" lives on the allow layer (`TaskSpec.allowedTools`, `RunnerDeps.allowedTools`).
11
14
  *
12
15
  * ## The CONTENT-FORM command lane (the shell tool's own grammar)
13
16
  *
@@ -21,9 +24,7 @@
21
24
  * a call the lexer cannot read against a rule is a fail-closed ask, never silence).
22
25
  * It sits in the field-level tier of the evaluation order — right after the bare deny (resp. ask) — which
23
26
  * is upstream's placement (`bs()` whole-tool deny → `AN(…,"deny")` field-level deny → `ph()` whole-tool
24
- * ask → `AN(…,"ask")` field-level ask → allow). A content-form ALLOW is refused (`unsupported.bash_prefix`):
25
- * a standing approval is a CONSENT record and lives in the persisted-rule store (the import door carries
26
- * a settings allow list there); inside a policy fold an allow is "no objection" and would clear nothing.
27
+ * ask → `AN(…,"ask")` field-level ask).
27
28
  *
28
29
  * The PATH lane is the same shape for every tool the catalog declares a path target for: a
29
30
  * `Edit(~/.sema/settings.json)` / `Write(src/**)` / `Read(//etc/**)` deny or ask compiles through the same
@@ -33,14 +34,12 @@
33
34
  * object face's declaration, the catalog's for the name as the floor). The bases a pattern resolves
34
35
  * against are the deployment's (`pathBases`); a pattern whose base the policy was not given is refused at
35
36
  * compile time (`unsupported.path_base`) rather than left to reach nothing at run time — a deny that
36
- * silently matches nothing is the failure form this module's header calls the most dangerous one. A
37
- * path-form ALLOW stays refused (`unsupported.file_glob`): the Read directory allow is the consent store's.
37
+ * silently matches nothing is the failure form this module's header calls the most dangerous one.
38
38
  * A `Read(...)` / `Edit(...)` deny or ask reaches the calls on its behavior's FACES (`PATH_RULE_FACES`), not only
39
39
  * its own tool: a `Read` ask asks for a Grep / Glob rooted in its path, judged on that call's own target.
40
40
  *
41
41
  * 🔴 与 CC 的有意差(design/127 r2):
42
- * - CC 参数级 generic 道无 allow lane(gnn 只有 deny/ask)—— 我们同样:`allow` + 参数级 content =
43
- * 无效规则;auto-allow 只来自裸工具规则。
42
+ * - CC 参数级 generic 道无 allow lane(gnn 只有 deny/ask)—— 我们同样,且整个 policy 无 allow 臂。
44
43
  * - CC 对尾缀垃圾静默降级为裸名(Lh `n !== len-1`)—— `parsePermissionRule` 保持 CC parity,但
45
44
  * **工厂 validator strict**:降级即 invalid(fail-fast 世界观,写错的规则不能静默变宽)。
46
45
  * - 已知主字段参数规则(如 `Bash(command:…)`)默认 **reject**:通配是 trim 后**整串 lexical**
@@ -60,7 +59,7 @@ export declare const BASH_GENERIC_PARAMS: ReadonlySet<string>;
60
59
  export interface PermissionRule {
61
60
  /** CC 语法:`"Tool"` 或 `"Tool(param:pattern)"`。`Tool()`/`Tool(*)` 归一为裸规则;括号可 `\(`/`\)` 转义。 */
62
61
  rule: string;
63
- behavior: "deny" | "ask" | "allow";
62
+ behavior: "deny" | "ask";
64
63
  /** Provenance:进 deny/ask 的 message 供审计(e.g. `"policySettings"`)。层级
65
64
  * 优先级/tighten-only 语义在 Settings-loader,不在这里。 */
66
65
  source?: string;
@@ -69,21 +68,25 @@ export interface PermissionRuleIssue {
69
68
  rule: string;
70
69
  /** `invalid.*` = 语法/语义错;`unsupported.*` = CC 合法但 v1 不支持的 lane(独立
71
70
  * 分类,绝不静默 no-op —— `Bash(npm run:*)` 被当 generic 参数规则接受后永不匹配是最危险的失败形态)。 */
72
- code: "invalid.empty_tool" | "invalid.paren" | "invalid.param_split" | "invalid.allow_param_rule" | "invalid.primary_field" | "invalid.cap_exceeded"
73
- /** A content-form Bash ALLOW rule (`Bash(npm run:*)` in an allow list): a standing approval is a consent
74
- * record and belongs to the persisted-rule store, not to a policy fold. Deny/ask content rules COMPILE. */
75
- | "unsupported.bash_prefix"
71
+ code: "invalid.empty_tool" | "invalid.paren" | "invalid.param_split" | "invalid.primary_field" | "invalid.cap_exceeded"
72
+ /** An ALLOW rule (any spelling): a policy fold says deny / ask only — "this does not need asking" is the allow
73
+ * layer's (`TaskSpec.allowedTools`, `RunnerDeps.allowedTools`). One issue per allow rule. */
74
+ | "unsupported.allow"
75
+ /** A `behavior` outside the closed set `deny | ask` (a JS caller's typo, an undefined member, a boxed string): refused —
76
+ * a rule that matched nothing because its verb was misspelled would be a deny that silently never fires. */
77
+ | "invalid.behavior"
76
78
  /** A content-form Bash deny/ask rule the ONE rule validator refuses (a redirection, a substitution, a
77
79
  * mid-pattern star, control characters…) — the message carries the validator's own code and sentence. */
78
80
  | "invalid.command_rule"
81
+ /** An `allowedTools` value (the request's `TaskSpec.allowedTools`, the deployment's `RunnerDeps.allowedTools`) that is
82
+ * not a list of strings: one issue for a non-list, one per element that is not a string (`rule` is empty — there is
83
+ * no rule text to name). */
84
+ | "invalid.entry_type"
79
85
  /** design/388 B15 — the closed rule-lint set over a tool's RULE FACE (derived scalar vocabulary; a
80
86
  * declaration only narrows it): the named parameter is not one the tool carries; it is carried but
81
87
  * is an object/array (a rule on it can never match); the tool is unknown to the resolver a leg
82
88
  * supplied (absent resolver ⇒ the catalog alone judges and an unknown tool stays permissive). */
83
89
  | "unsupported.param_unknown" | "unsupported.param_non_scalar" | "unsupported.tool_unknown"
84
- /** A path-form ALLOW rule (`Edit(src/**)` / `Read(//x/**)` in an allow list): a standing approval by
85
- * path is the consent store's (the Read directory form imports there); deny/ask path rules COMPILE. */
86
- | "unsupported.file_glob"
87
90
  /** A path-form deny/ask whose spelling needs a base this policy was not given (`/x` needs
88
91
  * `pathBases.root`; a relative pattern needs `pathBases.cwd` or `root`) — refused rather than compiled
89
92
  * into a rule that reaches nothing. */
@@ -119,9 +122,8 @@ export interface PermissionRulePolicyOptions {
119
122
  /** Called once per dropped rule under `"skip"` (audit surface — silent drops are how a deployment
120
123
  * discovers, months later, that its deny never existed). Default: one aggregated `console.warn`. */
121
124
  onIssue?: (issue: PermissionRuleIssue) => void;
122
- /** What the policy returns when NO rule matches. Default `"allow"` = 本 policy 无意见(组合语义)。
123
- * default-closed 部署用 `"ask"`/`"deny"` —— 必须在这 INSIDE the policy(链尾
124
- * ask policy 会在 fold 里吞掉本 policy 的显式 allow)。 */
125
+ /** What the policy returns when NO rule matches. Default `"allow"` = 本 policy 无意见(组合语义;不是同意)。
126
+ * default-closed 部署用 `"ask"`/`"deny"` —— 必须在这 INSIDE the policy(链尾 ask policy 会改写整条链的答案)。 */
125
127
  defaultAction?: "allow" | "ask" | "deny";
126
128
  /** 已知主字段参数规则(`Bash(command:…)` 等)的处置。默认 `"reject"`(整串 lexical ≠ 前缀/AST,
127
129
  * 假安全感);`"allow-lexical"` 显式接受并按 generic 语义匹配。 */
@@ -214,8 +216,7 @@ export declare function validatePermissionRules(rules: PermissionRule[], opts?:
214
216
  * field-level tiers of upstream's arbiter, beside the generic param tiers). `check()` is idempotent and side-effect-free (combinePolicies 契约);
215
217
  * all parsing/validation happens HERE, the hot path never re-parses.
216
218
  *
217
- * ⚠️ Composition note: an `allow` from this policy is "no objection", NOT a
218
- * cross-policy override — another policy's ask/deny still outranks it in the combinePolicies fold.
219
- * Express default-closed via `defaultAction`, never via a trailing always-ask policy.
219
+ * ⚠️ This policy states no allow: a call no rule reaches gets `defaultAction`, whose `"allow"` is "no objection" (states
220
+ * nothing, clears nothing). Express default-closed via `defaultAction`, never via a trailing always-ask policy.
220
221
  */
221
222
  export declare function createPermissionRulePolicy(rules: PermissionRule[], opts?: PermissionRulePolicyOptions): NamedToolPolicy;
@@ -9,6 +9,21 @@ import { ruleDenialSentence } from "./rule-denial-sentence.js";
9
9
  import { heldValues } from "./tool-name-readings.js";
10
10
  export { parsePermissionRule };
11
11
  export const BASH_GENERIC_PARAMS = new Set(catalogRuleFaceOf("Bash")?.params ?? []);
12
+ function describeBehavior(v) {
13
+ switch (typeof v) {
14
+ case "undefined": return "undefined";
15
+ case "string": {
16
+ const q = JSON.stringify(v);
17
+ return q.length > 40 ? q.slice(0, 37) + '..."' : q;
18
+ }
19
+ case "number":
20
+ case "boolean":
21
+ case "bigint": return String(v);
22
+ case "symbol": return "a symbol";
23
+ case "function": return "a function";
24
+ default: return v === null ? "null" : "an object";
25
+ }
26
+ }
12
27
  const DEFAULT_CAPS = {
13
28
  maxRules: 256,
14
29
  maxRuleLength: 512,
@@ -115,7 +130,7 @@ function compile(rules, caps, primaryFieldGeneric, ruleFaces, pathBases = {}) {
115
130
  return { face, judged: face !== undefined };
116
131
  };
117
132
  const byTool = new Map();
118
- const namespacedCovering = { deny: [], ask: [], allow: [] };
133
+ const namespacedCovering = { deny: [], ask: [] };
119
134
  const issues = [];
120
135
  const bad = (rule, code, message) => {
121
136
  issues.push({ rule, code, message });
@@ -125,6 +140,15 @@ function compile(rules, caps, primaryFieldGeneric, ruleFaces, pathBases = {}) {
125
140
  }
126
141
  for (const r of rules) {
127
142
  const text = r.rule;
143
+ const behavior = r.behavior;
144
+ if (behavior !== "deny" && behavior !== "ask") {
145
+ if (behavior === "allow")
146
+ bad(text, "unsupported.allow", "an allow rule does not belong to a policy — allow entries live on the allow layer: put the entry in `TaskSpec.allowedTools` (a request's) or `RunnerDeps.allowedTools` (the deployment's); deny / ask rules stay here");
147
+ else
148
+ bad(text, "invalid.behavior", `a rule's behavior is one of "deny" | "ask" — got ${describeBehavior(behavior)}; a misspelled verb would compile to a rule that silently never fires`);
149
+ continue;
150
+ }
151
+ const verb = behavior;
128
152
  if (text.length > caps.maxRuleLength) {
129
153
  bad(text, "invalid.cap_exceeded", `rule longer than ${caps.maxRuleLength} chars`);
130
154
  continue;
@@ -161,10 +185,10 @@ function compile(rules, caps, primaryFieldGeneric, ruleFaces, pathBases = {}) {
161
185
  const entry = byTool.get(toolName) ?? { bare: {}, param: { deny: [], ask: [] }, content: { deny: [], ask: [] } };
162
186
  if (parsed.ruleContent === undefined) {
163
187
  if (isNamespacedCoveringRuleName(toolName)) {
164
- namespacedCovering[r.behavior].push({ ruleName: toolName, ruleText: text, ...(r.source !== undefined ? { source: r.source } : {}) });
188
+ namespacedCovering[verb].push({ ruleName: toolName, ruleText: text, ...(r.source !== undefined ? { source: r.source } : {}) });
165
189
  continue;
166
190
  }
167
- entry.bare[r.behavior] = { ruleText: text, source: r.source };
191
+ entry.bare[verb] = { ruleText: text, source: r.source };
168
192
  byTool.set(toolName, entry);
169
193
  continue;
170
194
  }
@@ -174,16 +198,12 @@ function compile(rules, caps, primaryFieldGeneric, ruleFaces, pathBases = {}) {
174
198
  const paramPart = colon > 0 ? content.slice(0, colon).trim() : "";
175
199
  const looksLikeParamRule = colon > 0 && /^[A-Za-z_][A-Za-z0-9_]*$/.test(paramPart) && BASH_GENERIC_PARAMS.has(paramPart);
176
200
  if (!looksLikeParamRule || content.endsWith(":*")) {
177
- if (r.behavior === "allow") {
178
- 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");
179
- continue;
180
- }
181
- const parsedCommand = parseRuleText(text, r.behavior);
201
+ const parsedCommand = parseRuleText(text, verb);
182
202
  if ("reject" in parsedCommand) {
183
203
  bad(text, "invalid.command_rule", `${parsedCommand.reject.code}: ${parsedCommand.reject.message}`);
184
204
  continue;
185
205
  }
186
- entry.content[r.behavior].push({ parsed: parsedCommand.rule, ruleText: text, ...(r.source !== undefined ? { source: r.source } : {}) });
206
+ entry.content[verb].push({ parsed: parsedCommand.rule, ruleText: text, ...(r.source !== undefined ? { source: r.source } : {}) });
187
207
  byTool.set(toolName, entry);
188
208
  continue;
189
209
  }
@@ -196,11 +216,7 @@ function compile(rules, caps, primaryFieldGeneric, ruleFaces, pathBases = {}) {
196
216
  }
197
217
  const face = resolved.face;
198
218
  if (face !== undefined && face.pathTargeting === true && colon <= 0) {
199
- if (r.behavior === "allow") {
200
- 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");
201
- continue;
202
- }
203
- const parsedPath = parseRuleText(text, r.behavior);
219
+ const parsedPath = parseRuleText(text, verb);
204
220
  if ("reject" in parsedPath) {
205
221
  bad(text, "invalid.command_rule", `${parsedPath.reject.code}: ${parsedPath.reject.message}`);
206
222
  continue;
@@ -213,8 +229,8 @@ function compile(rules, caps, primaryFieldGeneric, ruleFaces, pathBases = {}) {
213
229
  }
214
230
  if (issues.length > 0 && issues[issues.length - 1].rule === text && issues[issues.length - 1].code === "unsupported.path_base")
215
231
  continue;
216
- const faces = PATH_RULE_FACES[toolName]?.[r.behavior];
217
- entry.content[r.behavior].push({ parsed: parsedPath.rule, ruleText: text, ...(r.source !== undefined ? { source: r.source } : {}), ...(faces !== undefined ? { faces } : {}) });
232
+ const faces = PATH_RULE_FACES[toolName]?.[verb];
233
+ entry.content[verb].push({ parsed: parsedPath.rule, ruleText: text, ...(r.source !== undefined ? { source: r.source } : {}), ...(faces !== undefined ? { faces } : {}) });
218
234
  byTool.set(toolName, entry);
219
235
  continue;
220
236
  }
@@ -242,10 +258,6 @@ function compile(rules, caps, primaryFieldGeneric, ruleFaces, pathBases = {}) {
242
258
  bad(text, "invalid.param_split", "param and pattern must both be non-empty");
243
259
  continue;
244
260
  }
245
- if (r.behavior === "allow") {
246
- bad(text, "invalid.allow_param_rule", "param-level rules cannot `allow` (CC parity: the generic lane is deny/ask only; auto-allow comes from bare-tool rules)");
247
- continue;
248
- }
249
261
  if (pattern.length > caps.maxPatternLength || countStars(pattern) > caps.maxStars) {
250
262
  bad(text, "invalid.cap_exceeded", `pattern exceeds maxPatternLength ${caps.maxPatternLength} or maxStars ${caps.maxStars}`);
251
263
  continue;
@@ -254,7 +266,7 @@ function compile(rules, caps, primaryFieldGeneric, ruleFaces, pathBases = {}) {
254
266
  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.`);
255
267
  continue;
256
268
  }
257
- entry.param[r.behavior].push({ param, pattern, behavior: r.behavior, ruleText: text, source: r.source });
269
+ entry.param[verb].push({ param, pattern, behavior: verb, ruleText: text, source: r.source });
258
270
  byTool.set(toolName, entry);
259
271
  }
260
272
  return { byTool, namespacedCovering, issues };
@@ -320,21 +332,19 @@ export function createPermissionRulePolicy(rules, opts) {
320
332
  }
321
333
  }
322
334
  const defaultAction = opts?.defaultAction ?? "allow";
323
- const nameSets = { deny: [], ask: [], allow: [] };
335
+ const nameSets = { deny: [], ask: [] };
324
336
  for (const [toolName, entry] of byTool) {
325
337
  if (entry.bare.deny !== undefined || entry.param.deny.length > 0 || entry.content.deny.length > 0)
326
338
  nameSets.deny.push(toolName);
327
339
  if (entry.bare.ask !== undefined || entry.param.ask.length > 0 || entry.content.ask.length > 0)
328
340
  nameSets.ask.push(toolName);
329
- if (entry.bare.allow !== undefined)
330
- nameSets.allow.push(toolName);
331
341
  }
332
- for (const behavior of ["deny", "ask", "allow"]) {
342
+ for (const behavior of ["deny", "ask"]) {
333
343
  for (const r of namespacedCovering[behavior])
334
344
  nameSets[behavior].push(r.ruleName);
335
345
  }
336
346
  const coveringHit = (lane, toolName) => lane.length === 0 ? undefined : lane.find((r) => namespacedRuleNameCovers(r.ruleName, toolName));
337
- const hasCovering = namespacedCovering.deny.length > 0 || namespacedCovering.ask.length > 0 || namespacedCovering.allow.length > 0;
347
+ const hasCovering = namespacedCovering.deny.length > 0 || namespacedCovering.ask.length > 0;
338
348
  const faceRules = {
339
349
  deny: [...byTool.values()].flatMap((e) => e.content.deny.filter((r) => r.faces !== undefined)),
340
350
  ask: [...byTool.values()].flatMap((e) => e.content.ask.filter((r) => r.faces !== undefined)),
@@ -387,7 +397,7 @@ export function createPermissionRulePolicy(rules, opts) {
387
397
  if (entries.length <= 1)
388
398
  return entries[0];
389
399
  const bare = {};
390
- for (const b of ["deny", "ask", "allow"]) {
400
+ for (const b of ["deny", "ask"]) {
391
401
  const hit = entries.find((e) => e.bare[b] !== undefined)?.bare[b];
392
402
  if (hit !== undefined)
393
403
  bare[b] = hit;
@@ -448,12 +458,6 @@ export function createPermissionRulePolicy(rules, opts) {
448
458
  if (paramAsk) {
449
459
  return { action: "ask", message: ruleAskMessage(paramAsk.ruleText, paramAsk.source), matchedAskRule: paramAsk.ruleText };
450
460
  }
451
- if (entry.bare.allow) {
452
- return { action: "allow", decisionReason: "rule" };
453
- }
454
- }
455
- if (covering && coveringHit(namespacedCovering.allow, req.toolName)) {
456
- return { action: "allow", decisionReason: "rule" };
457
461
  }
458
462
  if (defaultAction === "allow")
459
463
  return { action: "allow" };
@@ -150,7 +150,58 @@ export interface ReminderDisclosureOutcome {
150
150
  throttled: boolean;
151
151
  }
152
152
  /**
153
- * Run the four-step pipeline over one finalized external projection. Pure over its inputs except
154
- * the throttle window map and the counters (both deliberately mutable per-run state).
153
+ * What a planned disclosure will say, as a value: which sentences the trailer carries. The Read carrier
154
+ * renders it with {@link renderReminderDisclosureTrailer} (the bytes the pipeline has always appended);
155
+ * another carrier renders the same verdict in its own words. Undefined `reminder` and `envelopeTags` ⇒ no
156
+ * trailer.
157
+ */
158
+ export interface ReminderDisclosureVerdict {
159
+ /** The `<system-reminder>`-family sentence: the marked copy (with whether the defuse arm rewrote bytes)
160
+ * or the bare copy. */
161
+ reminder?: {
162
+ form: "marked";
163
+ defused: boolean;
164
+ } | {
165
+ form: "bare";
166
+ };
167
+ /** The envelope-family sentence, with the REGISTRY tag names it names (never matched bytes). */
168
+ envelopeTags?: readonly string[];
169
+ }
170
+ /** The state a planned disclosure changes — applied by {@link commitReminderDisclosureEffects}, never by
171
+ * the plan: the counter keys to bump (one entry per bump, in pipeline order) and the throttle windows to
172
+ * advance (key → next-allowed epoch ms). */
173
+ export interface ReminderDisclosureEffects {
174
+ counts: readonly string[];
175
+ windows: ReadonlyArray<readonly [string, number]>;
176
+ }
177
+ /** {@link planReminderDisclosure}'s answer: the outcome the pipeline reports, the verdict a carrier renders,
178
+ * and the effects a delivery commits. */
179
+ export interface ReminderDisclosurePlan {
180
+ outcome: Omit<ReminderDisclosureOutcome, "trailer">;
181
+ verdict: ReminderDisclosureVerdict;
182
+ effects: ReminderDisclosureEffects;
183
+ }
184
+ /**
185
+ * Plan the four-step pipeline over one finalized external projection WITHOUT changing anything: the
186
+ * throttle windows are read (a due bare/envelope sentence is suppressed by a live window) but never
187
+ * advanced, and the counters are not bumped — both land in `effects`, which the caller commits when the
188
+ * projection is actually delivered ({@link commitReminderDisclosureEffects}). Two plans over the same key
189
+ * before either commits both see the window open; that is the defined meaning of "effects ride the
190
+ * delivery".
191
+ */
192
+ export declare function planReminderDisclosure(input: ReminderDisclosureInput): ReminderDisclosurePlan;
193
+ /** Apply a plan's effects: bump each counter key, advance each throttle window. `windows` is the map the plan
194
+ * read (the outlet's per-closure throttle seat); either seat may be absent (library-direct mounts). */
195
+ export declare function commitReminderDisclosureEffects(effects: ReminderDisclosureEffects, seats: {
196
+ counts?: ReminderDisclosureCounts;
197
+ windows?: Map<string, number>;
198
+ }): void;
199
+ /** The trailer a verdict renders to on the tool-result carrier — a full trusted reminder block (self-marked),
200
+ * or undefined when the verdict carries no sentence. Byte-identical to the pipeline's historical trailer. */
201
+ export declare function renderReminderDisclosureTrailer(verdict: ReminderDisclosureVerdict, mark: string | undefined): string | undefined;
202
+ /**
203
+ * Run the four-step pipeline over one finalized external projection and commit its effects at once — the
204
+ * single-shot outlets (MCP, web) whose projection is delivered in the same breath. Plan + commit + render:
205
+ * the windows and counters move exactly as they always did.
155
206
  */
156
207
  export declare function discloseReminderShaped(input: ReminderDisclosureInput): ReminderDisclosureOutcome;
@@ -6,7 +6,7 @@ export function bumpReminderDisclosureCount(counts, key) {
6
6
  }
7
7
  export function observeReminderMarkEcho(input) {
8
8
  const { text, mark, outlet, counts } = input;
9
- if (mark === undefined || mark === "" || !text.includes(mark))
9
+ if (!markEchoed(text, mark))
10
10
  return false;
11
11
  bumpReminderDisclosureCount(counts, `${outlet}.mark_echo`);
12
12
  return true;
@@ -39,21 +39,20 @@ function envelopeTrailerBody(tags) {
39
39
  function composeTrailerBody(reminderPart, envelopePart) {
40
40
  return [reminderPart, envelopePart].filter((p) => p !== undefined).join(" ");
41
41
  }
42
- export function discloseReminderShaped(input) {
43
- const { mark, outlet, counts } = input;
44
- const untouched = () => ({
45
- segments: [...input.segments],
46
- trailer: undefined,
47
- form: undefined,
48
- defused: false,
49
- throttled: false,
50
- });
42
+ function markEchoed(text, mark) {
43
+ return mark !== undefined && mark !== "" && text.includes(mark);
44
+ }
45
+ export function planReminderDisclosure(input) {
46
+ const { mark, outlet } = input;
47
+ const counts = [];
48
+ const windows = [];
49
+ const effects = () => ({ counts, windows });
50
+ const untouchedOutcome = { segments: [...input.segments], form: undefined, defused: false, throttled: false };
51
51
  if (mark === undefined)
52
- return untouched();
52
+ return { outcome: untouchedOutcome, verdict: {}, effects: effects() };
53
53
  const joined = input.segments.join("");
54
54
  const scan = scanReminderShaped(joined, mark);
55
55
  const envScan = scanEnvelopeShaped(joined);
56
- const envelopePart = envScan.hit ? envelopeTrailerBody(envScan.tags) : undefined;
57
56
  let segments = [...input.segments];
58
57
  let defused = false;
59
58
  if (input.defuseExactMark) {
@@ -62,24 +61,21 @@ export function discloseReminderShaped(input) {
62
61
  defused = r.changed;
63
62
  }
64
63
  const marked = scan.hadCurrentMark || defused;
65
- if (!marked)
66
- observeReminderMarkEcho({ text: joined, mark, outlet, counts });
64
+ if (!marked && markEchoed(joined, mark))
65
+ counts.push(`${outlet}.mark_echo`);
67
66
  if (!marked && !scan.hit && !envScan.hit) {
68
- const clean = untouched();
69
- return { ...clean, segments };
67
+ return { outcome: { ...untouchedOutcome, segments }, verdict: {}, effects: effects() };
70
68
  }
71
69
  if (marked) {
72
70
  if (defused)
73
- bumpReminderDisclosureCount(counts, `${outlet}.defused`);
74
- bumpReminderDisclosureCount(counts, `${outlet}.marked`);
71
+ counts.push(`${outlet}.defused`);
72
+ counts.push(`${outlet}.marked`);
75
73
  if (envScan.hit)
76
- bumpReminderDisclosureCount(counts, `${outlet}.envelope`);
74
+ counts.push(`${outlet}.envelope`);
77
75
  return {
78
- segments,
79
- trailer: mintSystemReminder(composeTrailerBody(markedTrailerBody(defused), envelopePart), mark),
80
- form: "marked",
81
- defused,
82
- throttled: false,
76
+ outcome: { segments, form: "marked", defused, throttled: false },
77
+ verdict: { reminder: { form: "marked", defused }, ...(envScan.hit ? { envelopeTags: envScan.tags } : {}) },
78
+ effects: effects(),
83
79
  };
84
80
  }
85
81
  const t = input.throttle;
@@ -96,27 +92,44 @@ export function discloseReminderShaped(input) {
96
92
  const bare = verdict(scan.hit, throttleWindowKey("bare", t?.key ?? ""));
97
93
  const envelope = verdict(envScan.hit, throttleWindowKey("envelope", t?.key ?? ""));
98
94
  if (bare.suppressed)
99
- bumpReminderDisclosureCount(counts, `${outlet}.bare_throttled`);
95
+ counts.push(`${outlet}.bare_throttled`);
100
96
  if (envelope.suppressed)
101
- bumpReminderDisclosureCount(counts, `${outlet}.envelope_throttled`);
97
+ counts.push(`${outlet}.envelope_throttled`);
102
98
  if (!bare.emit && !envelope.emit) {
103
- return { segments, trailer: undefined, form: undefined, defused, throttled: true };
99
+ return { outcome: { segments, form: undefined, defused, throttled: true }, verdict: {}, effects: effects() };
104
100
  }
105
101
  if (t !== undefined) {
106
102
  if (bare.consume !== undefined)
107
- t.windows.set(bare.consume, now + BARE_REMINDER_DISCLOSURE_WINDOW_MS);
103
+ windows.push([bare.consume, now + BARE_REMINDER_DISCLOSURE_WINDOW_MS]);
108
104
  if (envelope.consume !== undefined)
109
- t.windows.set(envelope.consume, now + BARE_REMINDER_DISCLOSURE_WINDOW_MS);
105
+ windows.push([envelope.consume, now + BARE_REMINDER_DISCLOSURE_WINDOW_MS]);
110
106
  }
111
107
  if (bare.emit)
112
- bumpReminderDisclosureCount(counts, `${outlet}.bare`);
108
+ counts.push(`${outlet}.bare`);
113
109
  if (envelope.emit)
114
- bumpReminderDisclosureCount(counts, `${outlet}.envelope`);
110
+ counts.push(`${outlet}.envelope`);
115
111
  return {
116
- segments,
117
- trailer: mintSystemReminder(composeTrailerBody(bare.emit ? bareTrailerBody() : undefined, envelope.emit ? envelopePart : undefined), mark),
118
- form: bare.emit ? "bare" : "envelope",
119
- defused,
120
- throttled: bare.suppressed || envelope.suppressed,
112
+ outcome: { segments, form: bare.emit ? "bare" : "envelope", defused, throttled: bare.suppressed || envelope.suppressed },
113
+ verdict: { ...(bare.emit ? { reminder: { form: "bare" } } : {}), ...(envelope.emit ? { envelopeTags: envScan.tags } : {}) },
114
+ effects: effects(),
121
115
  };
122
116
  }
117
+ export function commitReminderDisclosureEffects(effects, seats) {
118
+ for (const key of effects.counts)
119
+ bumpReminderDisclosureCount(seats.counts, key);
120
+ if (seats.windows !== undefined)
121
+ for (const [key, until] of effects.windows)
122
+ seats.windows.set(key, until);
123
+ }
124
+ export function renderReminderDisclosureTrailer(verdict, mark) {
125
+ const reminderPart = verdict.reminder === undefined ? undefined : verdict.reminder.form === "marked" ? markedTrailerBody(verdict.reminder.defused) : bareTrailerBody();
126
+ const envelopePart = verdict.envelopeTags !== undefined ? envelopeTrailerBody(verdict.envelopeTags) : undefined;
127
+ if (reminderPart === undefined && envelopePart === undefined)
128
+ return undefined;
129
+ return mintSystemReminder(composeTrailerBody(reminderPart, envelopePart), mark);
130
+ }
131
+ export function discloseReminderShaped(input) {
132
+ const plan = planReminderDisclosure(input);
133
+ commitReminderDisclosureEffects(plan.effects, { counts: input.counts, ...(input.throttle !== undefined ? { windows: input.throttle.windows } : {}) });
134
+ return { ...plan.outcome, trailer: renderReminderDisclosureTrailer(plan.verdict, input.mark) };
135
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * The two rule LANES' answers — the org overlay's and the person's persisted rules' — screened and read for one
3
+ * call, shared by every consumer that judges a call against them (the live gate's lane station, the resumed
4
+ * execution boundary, the synthetic-Read judge). One normalizer and one bounded read per lane: a second copy of
5
+ * either would be a second answer to "what did the lane say", and the gate's fail-closed (org) / skip
6
+ * (persisted store) readings would drift apart between consumers.
7
+ */
8
+ import type { ToolCallRequest } from "./tool-policy.js";
9
+ import type { OrgGateVerdict, PersistedRuleAnswer, PersistedRuleHit } from "./hooks.js";
10
+ /**
11
+ * design/252 review r3 — read a foreign {@link OrgGateVerdict} the way the personal-rule answer is read:
12
+ * OWN DATA properties only, never the prototype chain, never an accessor.
13
+ *
14
+ * Unlike the personal-rule normalizer below, this one accepts ANY non-array object as the carrier —
15
+ * not only a plain record. The two seams tighten in opposite directions when a shape is refused: the
16
+ * personal lane is a LOOSENING seam, so refusing a class instance degrades toward asking; this answer
17
+ * carries the org's DENY, and folding a structurally valid `{status:"available", verdict:{behavior:
18
+ * "deny"}}` class instance into `unavailable` would LOOSEN it — from a deny nobody can approve into a
19
+ * real-approval ask a person can clear. A class instance's fields are its own data properties, so the
20
+ * own-data read below already gives the full pollution guarantee (inherited members never authorize);
21
+ * the prototype test added nothing here but the downgrade.
22
+ *
23
+ * The two seams are the same class of trust boundary and were not being read the same way. What that
24
+ * cost here is worse than on the personal lane, because this answer is GOVERNANCE:
25
+ * · an inherited `revision` (a polluted `Object.prototype`) stamped a fabricated snapshot version onto
26
+ * a human's approval request — a WRONG record, which is worse than an absent one;
27
+ * · an inherited `verdict` invented an org rule, and with it an org ask or deny the lane never gave;
28
+ * · an answer that is not a record at all read as "available, nothing to say", i.e. the governance
29
+ * fail-open the availability contract exists to prevent.
30
+ * Anything this function cannot read as a well-formed answer becomes `unavailable` — the fail-CLOSED
31
+ * word, never the empty one.
32
+ */
33
+ export declare function normalizeOrgGateVerdict(answer: unknown, unreadable: string): OrgGateVerdict;
34
+ /**
35
+ * Normalize the accepted {@link ToolGateInput.persistedRules} answers into one reading.
36
+ *
37
+ * `{}` = a clean negative (no rule admits this call). `{ unreadable: true }` = the lane could not read
38
+ * its source. `{ hit }` = a match, whose `rules` is the non-empty coverage set (design/375). An answer
39
+ * outside every accepted shape — a number, `null`, an object with neither `rules` nor `unreadable`, an
40
+ * EMPTY `rules` array, the retired pre-375 `{ rule, dots? }` single-rule object — is read as a clean
41
+ * negative rather than a match: this is a LOOSENING seam, so an answer nobody can name degrades toward
42
+ * asking, never toward an allow built on it. All-or-nothing across the set's TEXTS for the same
43
+ * reason: a set with one unreadable member is a different (smaller) claim than the lane made, and a
44
+ * decision must not stand on a claim nobody made — one bad member drops the whole answer to the clean
45
+ * negative. Per-member DOTS stay individually optional (identity lost ⇒ `"not_reported"`, exactly the
46
+ * single-rule contract).
47
+ *
48
+ * The hit's dots are COPIED, not aliased. The array travels onto an ask that may sit in front of a
49
+ * person for a long time; a lane that retains and mutates its own array would otherwise change what the
50
+ * approver is looking at, and what an audit later reads, after the evidence was stamped.
51
+ */
52
+ export declare function normalizePersistedRuleHit(hit: PersistedRuleAnswer): {
53
+ hit?: PersistedRuleHit;
54
+ unreadable?: true;
55
+ reason?: string;
56
+ coverage?: readonly import("./permission-rule-model.js").SegmentCoverage[];
57
+ };
58
+ /**
59
+ * The ORG lane's one read for one call — every consumer's (the live gate's org layer, the synthetic-Read
60
+ * judge). The CALL is inside the boundary (a synchronous throw — a non-function face, a revoked proxy —
61
+ * happens before any `.catch` on its result could be installed), it is invoked directly (a lane that reads
62
+ * its own state before its first `await` sees the same turn), it is BOUNDED by the dependency timeout and
63
+ * the signal, and everything that is not a well-formed answer — a throw, a hang, a garbage value — reads
64
+ * as `unavailable`, the fail-closed word, never as "no org rule spoke".
65
+ */
66
+ export declare function readOrgRuleLane(org: {
67
+ adjudicate: (req: ToolCallRequest) => Promise<OrgGateVerdict>;
68
+ }, req: ToolCallRequest, signal: AbortSignal | undefined, timeoutMs?: number): Promise<OrgGateVerdict>;
69
+ /**
70
+ * The persisted-rule lane's one read for one call — every consumer's (the live gate, the synthetic-Read
71
+ * judge), normalized once. A lane that throws, hangs past the dependency timeout, or answers something
72
+ * unreadable is a lane that could not answer: for the DECISION its rows take no part (the tightening arms
73
+ * do not fire — `applyPersistedTightening` reads `{ unreadable }` without a reason that way), and for
74
+ * the RECORD it is `"unavailable"`. A call the lane could not read against the rows (`reason`) fails closed
75
+ * in the arms. Absent lane ⇒ the clean negative.
76
+ */
77
+ export declare function readPersistedRuleLane(lane: {
78
+ adjudicate: (req: ToolCallRequest) => PersistedRuleAnswer | Promise<PersistedRuleAnswer>;
79
+ } | undefined, req: ToolCallRequest, signal: AbortSignal | undefined, timeoutMs?: number): Promise<{
80
+ hit?: PersistedRuleHit;
81
+ unreadable?: true;
82
+ reason?: string;
83
+ coverage?: readonly import("./permission-rule-model.js").SegmentCoverage[];
84
+ }>;