@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
@@ -1,9 +1,9 @@
1
1
  import { canonicalizeTarget, writeTargetPath } from "../tools/fs/safety.js";
2
- import { PATH_CONFINABLE_WRITE_TOOLS, isWithin } from "./runner/session-rule-policy.js";
2
+ import { isWithin } from "./runner/session-rule-policy.js";
3
+ import { declaredPathTargetOf, isProtectedWrite } from "./tool-registry.js";
3
4
  const ask = (message) => ({ action: "ask", message, decisionReason: "rule" });
4
5
  export function createFsWriteGatePolicy(opts) {
5
6
  const { env, rootPath, defaultWrite } = opts;
6
- const gated = PATH_CONFINABLE_WRITE_TOOLS;
7
7
  const acceptDirs = opts.acceptDirs && opts.acceptDirs.length > 0 ? opts.acceptDirs : undefined;
8
8
  const exemptDirs = opts.exemptDirs && opts.exemptDirs.length > 0 ? opts.exemptDirs : undefined;
9
9
  const guardedCapable = env.writeFileGuarded !== undefined;
@@ -12,8 +12,7 @@ export function createFsWriteGatePolicy(opts) {
12
12
  : " (advisory adjudication: this environment provides no guarded write, so approval is judged before a separate, non-atomic write step)";
13
13
  return {
14
14
  async check(req, signal) {
15
- const canonical = req.toolName;
16
- if (!gated.has(canonical))
15
+ if (!isProtectedWrite(req))
17
16
  return { action: "allow" };
18
17
  if (opts.requireGuardedWrite === true && !guardedCapable) {
19
18
  return {
@@ -22,9 +21,10 @@ export function createFsWriteGatePolicy(opts) {
22
21
  decisionReason: "rule",
23
22
  };
24
23
  }
25
- const path = writeTargetPath(canonical, req.args);
24
+ const declared = declaredPathTargetOf(req);
25
+ const path = declared === undefined ? undefined : writeTargetPath(req, declared);
26
26
  if (path === undefined) {
27
- return ask(`write tool "${req.toolName}" requires approval: the call has no resolvable path target to confine${advisory}`);
27
+ return ask(`write tool "${req.toolName}" requires approval: the call has no resolvable path target to confine${declared === undefined ? " (the tool declares no path target)" : ""}${advisory}`);
28
28
  }
29
29
  const canon = await canonicalizeTarget(env, path, signal, req.cwd ?? rootPath);
30
30
  if (!canon.ok) {
@@ -42,7 +42,7 @@ export function createFsWriteGatePolicy(opts) {
42
42
  if (opts.isExempt) {
43
43
  let exempt = false;
44
44
  try {
45
- exempt = (await opts.isExempt(canonical, canon.key)) === true;
45
+ exempt = (await opts.isExempt(req.toolName, canon.key, req.face !== undefined ? { capabilityId: req.face.capabilityId, contractId: req.face.contractId, shapeDigest: req.face.shapeDigest } : undefined)) === true;
46
46
  }
47
47
  catch {
48
48
  exempt = false;
@@ -30,7 +30,7 @@ function withProbeTimeout(p, ms, signal) {
30
30
  const DEFAULT_PROBE_TIMEOUT_MS = 30_000;
31
31
  export async function runGateFold(pass) {
32
32
  const { input, toolName, toolCallId, callSignal, ledger, notifier, adjudicate } = pass;
33
- pass.req = { toolName, args: pass.currentInput, toolCallId };
33
+ pass.req = { toolName, args: pass.currentInput, toolCallId, ...(pass.req.face !== undefined ? { face: pass.req.face } : {}) };
34
34
  pass.decision = adjudicate ? await (callSignal !== undefined ? adjudicate(pass.req, callSignal) : adjudicate(pass.req)) : { action: "allow" };
35
35
  {
36
36
  const refusedBy = ledger.fromDecision(pass.decision);
@@ -144,7 +144,7 @@ export async function runGateFold(pass) {
144
144
  pass.tightenedBy = "safety";
145
145
  }
146
146
  if (pass.decision.action === "allow" && input.writeProtectionCheck !== undefined) {
147
- const writeProtectedHit = input.writeProtectionCheck(toolName, pass.policyRewrite !== undefined ? pass.policyRewrite : pass.currentInput);
147
+ const writeProtectedHit = input.writeProtectionCheck(toolName, pass.policyRewrite !== undefined ? pass.policyRewrite : pass.currentInput, pass.req.face);
148
148
  if (writeProtectedHit !== null) {
149
149
  pass.decision = {
150
150
  action: "ask",
@@ -2,7 +2,9 @@ import { decisionText, describeThrown } from "./tool-policy.js";
2
2
  import { askOriginOf, classifierMayAnswer } from "./ask-origin.js";
3
3
  import { denialLimitFallbackMessage, unarmedWindow } from "./auto-mode.js";
4
4
  import { inlineUntrusted } from "./untrusted-text.js";
5
+ import { isRuleBehavior } from "./permission-rule-model.js";
5
6
  import { ASK_USER_QUESTION_TOOL_NAME } from "./ask-question.js";
7
+ import { applyPersistedTightening, disclosedRuleSet } from "./persisted-rule-arms.js";
6
8
  import { ORG_ADJUDICATION_TIMEOUT_MS, ORG_RULE_DECISION_REASON, ORG_UNAVAILABLE_DECISION_REASON, settleOrgVerdictWithin } from "./permission-rule-org.js";
7
9
  import { exitGate, traceHookCrash } from "./runner/gate-exit.js";
8
10
  function isPlainOwnRecord(x) {
@@ -89,8 +91,6 @@ function copiedUsableDots(dots) {
89
91
  return usable ? { dots: copied } : {};
90
92
  }
91
93
  export function normalizePersistedRuleHit(hit) {
92
- if (typeof hit === "string")
93
- return { hit: { rules: [{ rule: hit }] } };
94
94
  if (!isPlainOwnRecord(hit))
95
95
  return {};
96
96
  if (ownDataValue(hit, "unreadable") === true)
@@ -132,7 +132,10 @@ export function normalizePersistedRuleHit(hit) {
132
132
  return {};
133
133
  if (!members.every((m) => typeof m.rule === "string"))
134
134
  return {};
135
- return { hit: { rules: members.map((m) => ({ rule: m.rule, ...copiedUsableDots(m.dots) })) } };
135
+ const behavior = ownDataValue(hit, "behavior");
136
+ if (!isRuleBehavior(behavior))
137
+ return {};
138
+ return { hit: { behavior, rules: members.map((m) => ({ rule: m.rule, ...copiedUsableDots(m.dots) })) } };
136
139
  }
137
140
  export function persistedRuleMandateOf(marks) {
138
141
  return marks.probeMandated === true
@@ -153,10 +156,11 @@ function parkWith(suspendAsk, parkArgs, carry) {
153
156
  export async function runGateLanes(pass) {
154
157
  const { input, toolName, toolCallId, callSignal, ledger, notifier, screening, adjudicate, resolveAsk, suspendAsk } = pass;
155
158
  let orgRealApprovalRequired = false;
156
- const askOriginFacts = (org) => ({
159
+ const askOriginFacts = (org, ruleStore) => ({
157
160
  contentQuestion: toolName === ASK_USER_QUESTION_TOOL_NAME,
158
161
  markedUnresolvable: input.isMarkedUnresolvable?.(input.event.toolCallId) === true,
159
162
  org,
163
+ ...(ruleStore !== undefined ? { ruleStore } : {}),
160
164
  tightened: pass.tightenedBy,
161
165
  });
162
166
  let orgTightenCount = 0;
@@ -240,6 +244,24 @@ export async function runGateLanes(pass) {
240
244
  };
241
245
  const orgAnswer = await applyOrgLayer(pass.decision, pass.policyRewrite !== undefined ? pass.policyRewrite : pass.currentInput);
242
246
  pass.decision = orgAnswer.decision;
247
+ const readPersistedRules = async (req) => {
248
+ let pendingHit;
249
+ try {
250
+ pendingHit = Promise.resolve(input.persistedRules?.adjudicate(req));
251
+ }
252
+ catch {
253
+ return { unreadable: true };
254
+ }
255
+ return await settleOrgVerdictWithin(pendingHit.then(normalizePersistedRuleHit).catch(() => ({ unreadable: true })), { unreadable: true }, { ...(input.abortSignal !== undefined ? { signal: input.abortSignal } : {}), timeoutMs: ORG_ADJUDICATION_TIMEOUT_MS });
256
+ };
257
+ const laneReq = { ...pass.req, ...(pass.policyRewrite !== undefined ? { args: pass.policyRewrite } : {}) };
258
+ const laneAnswer = input.persistedRules !== undefined && pass.req.toolName !== ASK_USER_QUESTION_TOOL_NAME ? await readPersistedRules(laneReq) : undefined;
259
+ {
260
+ const tightened = applyPersistedTightening(pass.decision, laneAnswer);
261
+ pass.decision = tightened.decision;
262
+ if (tightened.tightened === "deny")
263
+ pass.deniedBy = "persisted_rule";
264
+ }
243
265
  if (pass.decision.action === "ask") {
244
266
  if (pass.policyRewrite !== undefined) {
245
267
  pass.currentInput = pass.policyRewrite;
@@ -252,6 +274,8 @@ export async function runGateLanes(pass) {
252
274
  probeMandated: pass.decision.action === "ask" && pass.decision.probeMandated === true,
253
275
  });
254
276
  let personalEvidence = { dotsAbsent: input.persistedRules === undefined ? "not_wired" : "not_adjudicated" };
277
+ if (laneAnswer?.unreadable === true)
278
+ personalEvidence = { dotsAbsent: "unavailable" };
255
279
  let laneCoverage;
256
280
  if (input.persistedRules &&
257
281
  !orgRealApprovalRequired &&
@@ -260,24 +284,10 @@ export async function runGateLanes(pass) {
260
284
  pass.decision.decisionReason !== "hook" &&
261
285
  pass.req.toolName !== ASK_USER_QUESTION_TOOL_NAME &&
262
286
  input.isMarkedUnresolvable?.(input.event.toolCallId) !== true) {
263
- const answer = await (async () => {
264
- let pendingHit;
265
- try {
266
- pendingHit = Promise.resolve(input.persistedRules?.admits(pass.req));
267
- }
268
- catch {
269
- return { unreadable: true };
270
- }
271
- return await settleOrgVerdictWithin(pendingHit.then(normalizePersistedRuleHit).catch(() => ({ unreadable: true })), { unreadable: true }, { ...(input.abortSignal !== undefined ? { signal: input.abortSignal } : {}), timeoutMs: ORG_ADJUDICATION_TIMEOUT_MS });
272
- })();
273
- const hitRules = answer.hit?.rules;
287
+ const answer = laneAnswer ?? {};
288
+ const hitRules = answer.hit?.behavior === "allow" ? answer.hit.rules : undefined;
274
289
  laneCoverage = answer.coverage;
275
290
  personalEvidence = { dotsAbsent: answer.unreadable === true ? "unavailable" : "no_match" };
276
- const disclosedRuleSet = (rules) => {
277
- const shown = rules.slice(0, 5).map((r) => inlineUntrusted(r.rule, 200));
278
- const rest = rules.length - shown.length;
279
- return rest > 0 ? `${shown.join(", ")} and ${rest} more` : shown.join(", ");
280
- };
281
291
  const unionDotsOf = (rules) => {
282
292
  const seen = new Set();
283
293
  const union = [];
@@ -294,6 +304,8 @@ export async function runGateLanes(pass) {
294
304
  }
295
305
  return { dots: union };
296
306
  };
307
+ if (answer.hit?.behavior === "ask")
308
+ personalEvidence = unionDotsOf(answer.hit.rules);
297
309
  if (hitRules !== undefined && persistedRuleMandate === undefined && pass.decision.matchedAskRule === undefined) {
298
310
  pass.decision = {
299
311
  action: "allow",
@@ -324,7 +336,7 @@ export async function runGateLanes(pass) {
324
336
  };
325
337
  }
326
338
  }
327
- const originFacts = askOriginFacts(orgAnswer.org);
339
+ const originFacts = askOriginFacts(orgAnswer.org, laneAnswer?.unreadable === true ? "unavailable" : undefined);
328
340
  if (pass.decision.action === "ask") {
329
341
  const stamped = { ...pass.decision, ruleEvidence: mintRuleEvidence(personalEvidence), ...(laneCoverage !== undefined ? { segmentCoverage: laneCoverage } : {}) };
330
342
  pass.decision = { ...stamped, origin: askOriginOf(stamped, originFacts) };
@@ -374,6 +386,7 @@ export async function runGateLanes(pass) {
374
386
  input.isMarkedUnresolvable?.(input.event.toolCallId) !== true &&
375
387
  pass.decision.requiresRealApproval !== true &&
376
388
  pass.decision.decisionReason !== "hook" &&
389
+ pass.decision.origin !== "ask_rule" &&
377
390
  pass.hookAsk === undefined &&
378
391
  input.egress !== true &&
379
392
  input.peerMessage !== true &&
@@ -543,12 +556,13 @@ export async function runGateLanes(pass) {
543
556
  }
544
557
  }
545
558
  }
546
- if (!adjudicate && input.orgRules === undefined)
559
+ if (!adjudicate && input.orgRules === undefined && input.persistedRules === undefined)
547
560
  break;
548
561
  let recheck = { action: "allow" };
549
562
  if (adjudicate) {
550
563
  try {
551
- recheck = await (callSignal !== undefined ? adjudicate({ toolName, args: editArgs, toolCallId }, callSignal) : adjudicate({ toolName, args: editArgs, toolCallId }));
564
+ const editReq = { toolName, args: editArgs, toolCallId, ...(pass.req.face !== undefined ? { face: pass.req.face } : {}) };
565
+ recheck = await (callSignal !== undefined ? adjudicate(editReq, callSignal) : adjudicate(editReq));
552
566
  }
553
567
  catch (err) {
554
568
  recheck = {
@@ -575,6 +589,19 @@ export async function runGateLanes(pass) {
575
589
  editDenied = recheck;
576
590
  break;
577
591
  }
592
+ let editRuleStore;
593
+ if (input.persistedRules !== undefined && toolName !== ASK_USER_QUESTION_TOOL_NAME) {
594
+ const editLane = await readPersistedRules({ toolName, args: editArgs, toolCallId, ...(pass.req.face !== undefined ? { face: pass.req.face } : {}) });
595
+ editRuleStore = editLane.unreadable === true ? "unavailable" : undefined;
596
+ const editTightened = applyPersistedTightening(recheck, editLane);
597
+ recheck = editTightened.decision;
598
+ if (editTightened.tightened === "deny")
599
+ pass.deniedBy = "persisted_rule";
600
+ if (recheck.action === "deny") {
601
+ editDenied = recheck;
602
+ break;
603
+ }
604
+ }
578
605
  if (recheck.action === "allow") {
579
606
  if (recheck.updatedInput !== undefined) {
580
607
  editArgs = recheck.updatedInput;
@@ -587,8 +614,8 @@ export async function runGateLanes(pass) {
587
614
  editRewrittenSinceHuman = true;
588
615
  }
589
616
  const editAskSnapshot = { ...recheck, ruleEvidence: mintRuleEvidence({ dotsAbsent: "not_adjudicated" }) };
590
- const editAskDecision = { ...editAskSnapshot, origin: askOriginOf(editAskSnapshot, askOriginFacts(editOrg.org)) };
591
- const editAskReq = { toolName, args: editArgs, toolCallId };
617
+ const editAskDecision = { ...editAskSnapshot, origin: askOriginOf(editAskSnapshot, askOriginFacts(editOrg.org, editRuleStore)) };
618
+ const editAskReq = { toolName, args: editArgs, toolCallId, ...(pass.req.face !== undefined ? { face: pass.req.face } : {}) };
592
619
  const rr = await (callSignal !== undefined ? resolveAsk(editAskDecision, editAskReq, callSignal) : resolveAsk(editAskDecision, editAskReq));
593
620
  ledger.fromResolved(rr, editAskDecision, toolName);
594
621
  if (rr.action !== "allow") {
@@ -606,7 +633,7 @@ export async function runGateLanes(pass) {
606
633
  editRewrittenSinceHuman = false;
607
634
  }
608
635
  if (editDenied === undefined && editRewrittenSinceHuman && input.writeProtectionCheck !== undefined) {
609
- const editHit = input.writeProtectionCheck(toolName, editArgs);
636
+ const editHit = input.writeProtectionCheck(toolName, editArgs, pass.req.face);
610
637
  if (editHit !== null) {
611
638
  editDenied = {
612
639
  action: "deny",
@@ -129,18 +129,22 @@ export type SettlementRefusalTableCoversEveryKind = AssertAllKeysHandled<Exclude
129
129
  * - `ask_resolution` — the ask's own settlement is the refusal (a person said no, a window elapsed,
130
130
  * nobody could be asked…); the detail is on {@link GateOutcome.settlement}.
131
131
  */
132
- export declare const DENIED_BY_VALUES: readonly ["policy", "hook", "org", "classifier", "plan_mode", "compliance", "write_protection", "ask_resolution"];
132
+ export declare const DENIED_BY_VALUES: readonly ["policy", "hook", "org", "persisted_rule", "classifier", "plan_mode", "compliance", "write_protection", "ask_resolution"];
133
133
  export type DeniedBy = (typeof DENIED_BY_VALUES)[number];
134
134
  /** Membership test for {@link DeniedBy}. */
135
135
  export declare function isDeniedBy(v: unknown): v is DeniedBy;
136
136
  /** Which layers may VETO an approval a person already gave (invariant I3: a `human_allowed` settlement
137
- * beside a `denied` disposition names one of these). The approval-edit re-screen (hook), the policy and
138
- * org re-checks, and the write-protection re-judge run AFTER the person's yes; the classifier, plan mode
139
- * and compliance run before any ask exists, and `ask_resolution` IS the settlement, not a veto of it. */
137
+ * beside a `denied` disposition names one of these). The approval-edit re-screen (hook), the policy, org
138
+ * and persisted-rule re-checks, and the write-protection re-judge run AFTER the person's yes; the
139
+ * classifier, plan mode and compliance run before any ask exists, and `ask_resolution` IS the
140
+ * settlement, not a veto of it. `persisted_rule` = a person's own standing DENY row (their settings'
141
+ * deny list, imported) — the personal-store sibling of `org`, and like it re-judged on an edited
142
+ * command. */
140
143
  export declare const DENIED_BY_MAY_VETO: {
141
144
  readonly policy: true;
142
145
  readonly hook: true;
143
146
  readonly org: true;
147
+ readonly persisted_rule: true;
144
148
  readonly classifier: false;
145
149
  readonly plan_mode: false;
146
150
  readonly compliance: false;
@@ -31,7 +31,7 @@ export const SETTLEMENT_IS_REFUSAL = {
31
31
  blanket_allow_refused: true,
32
32
  task_aborted: true,
33
33
  };
34
- export const DENIED_BY_VALUES = ["policy", "hook", "org", "classifier", "plan_mode", "compliance", "write_protection", "ask_resolution"];
34
+ export const DENIED_BY_VALUES = ["policy", "hook", "org", "persisted_rule", "classifier", "plan_mode", "compliance", "write_protection", "ask_resolution"];
35
35
  const DENIED_BY_SET = new Set(DENIED_BY_VALUES);
36
36
  export function isDeniedBy(v) {
37
37
  return DENIED_BY_SET.has(v);
@@ -40,6 +40,7 @@ export const DENIED_BY_MAY_VETO = {
40
40
  policy: true,
41
41
  hook: true,
42
42
  org: true,
43
+ persisted_rule: true,
43
44
  classifier: false,
44
45
  plan_mode: false,
45
46
  compliance: false,
@@ -103,7 +103,7 @@ export type NoticeAudience = "user" | "operator";
103
103
  * src/ for notice mint shapes and names any code that is minted but unregistered, or registered but
104
104
  * no longer minted.
105
105
  */
106
- export declare const ENGINE_NOTICE_CODES: readonly ["config.autocompact_window_clamped", "config.env_timeout_discarded", "config.materialize_env_discarded", "config.models_swapped", "config.read_face_deployment_clamped", "config.tool_model_gate_removed", "config.tool_model_gate_unknown_class", "config.tool_model_gate_env_invalid", "config.durable_gate_unavailable", "config.peer_admission_out_of_range", "config.peer_lane_unmounted", "peer.inbound_disposition", "peer.held_settled", "peer.idle_subscription", "classifier.denial_limit", "checkpoint.execution_outcome_unrecorded", "delegation.transcript_integrity", "mcp.revocation_probe_failed", "workflow.governance_key_stripped", "workflow.agent_option_ignored", "memory.session_polluted", "memory.harvest_quarantined", "memory.delegation_static_mark_waived", "memory.content_class_declared", "memory.hold_opened", "memory.hold_released", "memory.hold_disposed", "memory.consolidation_recommended", "memory.consolidation_committed", "memory.consolidation_conflict", "memory.consolidation_incomplete", "memory.consolidation_refused", "memory.consolidation_withheld", "route.fallback_to_primary", "route.base_url_changed_key_unchanged", "task.user_steer_undrained", "task.user_followup_undrained", "steering.parked_input_blocked", "task.turn_interrupted", "task.halt_unconsumed", "task.late_approval", "memory.capture_opted_out", "memory.capture_optout_unpersisted", "tool_result.offload_put_failed"];
106
+ export declare const ENGINE_NOTICE_CODES: readonly ["config.autocompact_window_clamped", "config.env_timeout_discarded", "config.materialize_env_discarded", "config.models_swapped", "config.read_face_deployment_clamped", "config.tool_model_gate_removed", "config.tool_model_gate_unknown_class", "config.tool_model_gate_env_invalid", "config.tool_card_undeclared", "config.tool_face_undeclared", "config.tool_face_invalid", "config.durable_gate_unavailable", "config.peer_admission_out_of_range", "config.peer_lane_unmounted", "peer.inbound_disposition", "peer.held_settled", "peer.idle_subscription", "classifier.denial_limit", "checkpoint.execution_outcome_unrecorded", "delegation.transcript_integrity", "mcp.revocation_probe_failed", "workflow.governance_key_stripped", "workflow.agent_option_ignored", "memory.session_polluted", "memory.harvest_quarantined", "memory.delegation_static_mark_waived", "memory.content_class_declared", "memory.hold_opened", "memory.hold_released", "memory.hold_disposed", "memory.consolidation_recommended", "memory.consolidation_committed", "memory.consolidation_conflict", "memory.consolidation_incomplete", "memory.consolidation_refused", "memory.consolidation_withheld", "route.fallback_to_primary", "route.base_url_changed_key_unchanged", "task.user_steer_undrained", "task.user_followup_undrained", "steering.parked_input_blocked", "task.turn_interrupted", "task.halt_unconsumed", "task.late_approval", "memory.capture_opted_out", "memory.capture_optout_unpersisted", "tool_result.offload_put_failed"];
107
107
  /** A code this engine mints (see {@link ENGINE_NOTICE_CODES}). NOT the type of
108
108
  * `EngineNotice.code`, which stays `string` — a host forwarding its own notices through the same
109
109
  * sink is a supported shape, and narrowing that field would break it. */
@@ -101,6 +101,9 @@ export const ENGINE_NOTICE_CODES = [
101
101
  "config.tool_model_gate_removed",
102
102
  "config.tool_model_gate_unknown_class",
103
103
  "config.tool_model_gate_env_invalid",
104
+ "config.tool_card_undeclared",
105
+ "config.tool_face_undeclared",
106
+ "config.tool_face_invalid",
104
107
  "config.durable_gate_unavailable",
105
108
  "config.peer_admission_out_of_range",
106
109
  "config.peer_lane_unmounted",
@@ -164,6 +167,9 @@ const NOTICE_AUDIENCE_TABLE = {
164
167
  "config.tool_model_gate_removed": "operator",
165
168
  "config.tool_model_gate_unknown_class": "operator",
166
169
  "config.tool_model_gate_env_invalid": "operator",
170
+ "config.tool_card_undeclared": "operator",
171
+ "config.tool_face_undeclared": "operator",
172
+ "config.tool_face_invalid": "operator",
167
173
  "config.peer_lane_unmounted": "operator",
168
174
  "config.peer_admission_out_of_range": "operator",
169
175
  "peer.inbound_disposition": "user",
@@ -1,6 +1,6 @@
1
1
  import type { ActorAssertion, DocumentContent, ImageContent, TextContent } from "../internal/llm.js";
2
2
  import type { ExecutionEnv, FileError, Result, SessionTreeEntry } from "../internal/harness-types.js";
3
- import type { DecisionReason, PermissionResult, ResolvedAsk, ToolCallRequest, ToolPolicy } from "./tool-policy.js";
3
+ import type { DecisionReason, PermissionResult, ResolvedAsk, ToolCallFace, ToolCallRequest, ToolPolicy } from "./tool-policy.js";
4
4
  import type { GateOutcome } from "./gate-outcome.js";
5
5
  export { normalizeOrgGateVerdict, normalizePersistedRuleHit, persistedRuleMandateOf } from "./gate-lanes.js";
6
6
  export { cloneObserverInput } from "./runner/gate-exit.js";
@@ -1022,22 +1022,27 @@ export interface PersistedRuleHitRule {
1022
1022
  readonly dots?: readonly import("./permission-rule-model.js").RuleDot[];
1023
1023
  }
1024
1024
  /**
1025
- * design/252 G-2, re-formed by design/375 (BREAKING) the structured answer a persisted-rule lane
1026
- * gives: the COVERAGE SET that admits the call. A single rule admitting the whole command is a
1027
- * one-element set (the historical case); a compound admitted by the conjunction of per-segment rules
1028
- * (design/375 §5.1) is the set of those rules, in segment order. `rules` is non-empty by contract
1029
- * an empty set covers nothing and is not a hit (the normalizer reads it as a clean negative).
1025
+ * design/252 G-2, re-formed by design/375 and again by the three-behavior model (BREAKING) the
1026
+ * structured answer a persisted-rule lane gives: WHICH behavior spoke for the call, and the rule set it
1027
+ * stands on. For an `allow` the set is the COVERAGE SET that admits the call a single rule admitting
1028
+ * the whole command is a one-element set (the historical case); a compound admitted by the conjunction
1029
+ * of per-segment rules (design/375 §5.1) is the set of those rules, in segment order. For a `deny` or an
1030
+ * `ask` the set is the one rule of that behavior that reaches the call (the lane's precedence walk —
1031
+ * deny > ask > allow — already chose it). `rules` is non-empty by contract — an empty set covers nothing
1032
+ * and is not a hit (the normalizer reads it as a clean negative).
1030
1033
  *
1031
- * Why dots and not an id: a persisted allow rule HAS no scalar id — it is a (text, scope) group whose
1034
+ * Why dots and not an id: a persisted rule HAS no scalar id — it is a (behavior, text, scope) group whose
1032
1035
  * identity is the set of add dots that redeemed it, and two concurrently approved records for one text
1033
1036
  * each carry their own. The set is the identity; a joined string would not be one.
1034
1037
  *
1035
- * The pre-375 single-rule object shape (`{ rule, dots? }`) is NOT accepted: on this loosening seam an
1036
- * unrecognized answer degrades to a clean negative (more asks), never to a match — the safe failure
1037
- * shape for a lane that missed the same-window upgrade.
1038
+ * The pre-375 single-rule object shape (`{ rule, dots? }`) and the pre-behavior `{ rules }` shape are
1039
+ * NOT accepted: on this loosening seam an unrecognized answer degrades to a clean negative (more asks),
1040
+ * never to a match — the safe failure shape for a lane that missed the same-window upgrade.
1038
1041
  */
1039
1042
  export interface PersistedRuleHit {
1040
- /** The coverage set every rule the decision stands on. Non-empty. */
1043
+ /** What the deciding rule(s) say about the call the closed set's word. */
1044
+ readonly behavior: import("./permission-rule-model.js").RuleBehavior;
1045
+ /** The rule set the verdict stands on. Non-empty. */
1041
1046
  readonly rules: readonly PersistedRuleHitRule[];
1042
1047
  }
1043
1048
  /**
@@ -1065,8 +1070,9 @@ export interface PersistedRuleUnreadable {
1065
1070
  export interface PersistedRuleCoverage {
1066
1071
  readonly segmentCoverage: readonly import("./permission-rule-model.js").SegmentCoverage[];
1067
1072
  }
1068
- /** Every shape a lane may answer with. A bare string stays valid and unchanged. */
1069
- export type PersistedRuleAnswer = string | PersistedRuleHit | PersistedRuleCoverage | PersistedRuleUnreadable | undefined;
1073
+ /** Every shape a lane may answer with. The retired bare-string form (a single allow rule's text) is read
1074
+ * by the normalizer as a clean negative, like every other unrecognized shape. */
1075
+ export type PersistedRuleAnswer = PersistedRuleHit | PersistedRuleCoverage | PersistedRuleUnreadable | undefined;
1070
1076
  /** Inputs to the two-phase tool gate. `adjudicate`/`resolveAsk` are pre-bound to the task abort
1071
1077
  * signal; when the caller also supplies {@link ToolGateInput.callSignal}, the Runner's closures
1072
1078
  * additionally bind their waits to that per-call signal (`AbortSignal.any` of the two), so a turn
@@ -1115,6 +1121,12 @@ export interface ToolGateInput {
1115
1121
  * builds ({@link HookEnvCapabilities}). Built ONCE per task by the runner (after the env is minted) and
1116
1122
  * passed through unchanged; omitted when the deployment wired no execution environment. */
1117
1123
  hookEnv?: HookEnvCapabilities;
1124
+ /**
1125
+ * design/388 B6 — the leg's live roster lookup: the face of the mounted tool a call dispatches to, stamped
1126
+ * on the {@link ToolCallRequest} every policy sees (read at the gate entry, so a run-time roster change is
1127
+ * honoured on the next call). Absent ⇒ requests carry no face (a bare producer).
1128
+ */
1129
+ toolFaceOf?: (toolName: string) => ToolCallFace | undefined;
1118
1130
  /**
1119
1131
  * design/384 slice 1 — the PER-CALL abort signal, carried in from the `tool_call` hook event's
1120
1132
  * own `signal` seat (the loop's turn-scoped controller; the run abort is composed into it). The
@@ -1332,7 +1344,7 @@ export interface ToolGateInput {
1332
1344
  * (the write-protection tighten below). Absent ⇒ the deployment replaced the table with `[]` (or
1333
1345
  * the caller runs the gate without one) and the decision path is byte-identical.
1334
1346
  */
1335
- writeProtectionCheck?: (toolName: string, args: unknown) => import("./write-protect.js").WriteProtectedHit | null;
1347
+ writeProtectionCheck?: (toolName: string, args: unknown, face?: import("./tool-registry.js").ToolCallLike["face"]) => import("./write-protect.js").WriteProtectedHit | null;
1336
1348
  /**
1337
1349
  * design/143 批2 (A, CC 2.1.207 auto mode): when present, a surviving `ask` is routed to the
1338
1350
  * small-model policy CLASSIFIER before any human/durable resolution:
@@ -1398,15 +1410,27 @@ export interface ToolGateInput {
1398
1410
  */
1399
1411
  isMarkedUnresolvable?: (toolCallId: string) => boolean;
1400
1412
  /**
1401
- * design/179 — the persisted allow-rule lane. Present only when a deployment wired a rule store; absent
1402
- * it, this whole step does not exist and the gate's decision path is byte-identical to what it was.
1413
+ * design/179 — the persisted-rule lane. Present only when a deployment wired a rule store; absent it,
1414
+ * this whole step does not exist and the gate's decision path is byte-identical to what it was.
1403
1415
  *
1404
- * A persisted rule is the standing form of ONE recorded human approval, so it resolves the asks a real
1405
- * person saying "allow" would have resolvedand nothing else. It sits post-fold, at the entry of the
1406
- * `ask` branch, BEFORE the classifier and AFTER every tighten: earlier and a tighten would simply put
1407
- * the ask back; later and the classifier would already have spoken.
1416
+ * The lane answers with the BEHAVIOR that spoke ({@link PersistedRuleHit}), and the gate consumes the
1417
+ * three behaviors at two different placesthe upstream arbiter's order, deny > ask > allow, across
1418
+ * the person's own standing rules:
1419
+ * · a DENY row refuses the call outright, on EVERY decision (an allow from the policy fold included):
1420
+ * it is the person's own "never run this" (their settings' deny list, imported), the personal-store
1421
+ * sibling of an org deny, and it runs right after the org layer — nothing downstream re-opens it;
1422
+ * · an ASK row is the person's standing "ask me each time": it turns a surviving allow into an ask
1423
+ * and stamps `matchedAskRule` on it (the same marker an explicit `ask` permission rule stamps), so
1424
+ * the ask's origin is `ask_rule` — the auto-mode classifier never answers it and a persisted ALLOW
1425
+ * never clears it; on a call that is already an ask it stamps the marker and leaves the rest;
1426
+ * · an ALLOW row is the standing form of ONE recorded human approval, so it resolves the asks a real
1427
+ * person saying "allow" would have resolved — and nothing else. It sits post-fold, at the entry of
1428
+ * the `ask` branch, BEFORE the classifier and AFTER every tighten: earlier and a tighten would
1429
+ * simply put the ask back; later and the classifier would already have spoken.
1408
1430
  *
1409
- * Its consumption predicate is its own, not the classifier's blanket flip:
1431
+ * The deny/ask arms tighten and take no conjunct but the two structural ones every lane shares (the
1432
+ * engine's own content-ask tool is exempt; the lane must be wired). The ALLOW arm's consumption
1433
+ * predicate is its own, not the classifier's blanket flip:
1410
1434
  * · `requiresRealApproval` asks are never consumed — the two integrity gates are the floor, and the
1411
1435
  * same flag is what a v2 organization-level ask will carry;
1412
1436
  * · a PreToolUse hook's ask is never consumed — that is the deployment's own adjudication, and a rule
@@ -1435,19 +1459,26 @@ export interface ToolGateInput {
1435
1459
  * bullets above are what keep integrity/hook/mandated asks out of that set.
1436
1460
  */
1437
1461
  persistedRules?: {
1438
- /** The rule COVERAGE that admits this call, or `undefined`. Must not throw: a store that cannot
1439
- * be read answers "no rule" and discloses on its own channel (fail-closed = more asks).
1462
+ /** The lane's verdict for this call a {@link PersistedRuleHit} naming the behavior that spoke and
1463
+ * the rule set it stands on, a coverage-carrying clean negative, an unreadable-store report, or
1464
+ * `undefined` (the store was READ and no rule speaks). Must not throw — and a store that cannot be
1465
+ * read answers `{ unreadable: true }`, NEVER `undefined`: the gate reads the unreadable report as
1466
+ * fail-closed (an allow tightens to a real-approval ask of origin `rule_store_unavailable`), while
1467
+ * `undefined` is a successful no-match that leaves an allow standing. An adapter that answers
1468
+ * `undefined` on a read failure silently disarms every deny/ask row it holds.
1440
1469
  *
1441
- * TWO ACCEPTED SHAPES (design/252 G-2 / design/375 BREAKING): the canonical rule TEXT (a
1442
- * single-rule hit with no identity), or a {@link PersistedRuleHit} carrying the coverage SET
1443
- * each member's canonical text plus its add DOTS. A single rule admitting the whole command is a
1444
- * one-element set; a compound admitted by the per-segment conjunction (design/375 §5.1) is the
1445
- * set of the covering rules. The dots are a rule's durable identity, and the text is not one (it
1446
- * reaches a card sanitized and capped); a lane that can name them lets the shadowed-ask evidence
1447
- * say WHICH stored rules matched rather than merely what they looked like. The retired pre-375
1448
- * single-rule object shape (`{ rule, dots? }`) is read as a clean negative by the normalizer —
1449
- * a stale lane degrades toward asking, never toward an allow. */
1450
- admits: (req: ToolCallRequest) => Promise<PersistedRuleAnswer>;
1470
+ * For an ALLOW the set is the COVERAGE SET — each member's canonical text plus its add DOTS. A
1471
+ * single rule admitting the whole command is a one-element set; a compound admitted by the
1472
+ * per-segment conjunction (design/375 §5.1) is the set of the covering rules. The dots are a rule's
1473
+ * durable identity, and the text is not one (it reaches a card sanitized and capped); a lane that
1474
+ * can name them lets the shadowed-ask evidence say WHICH stored rules matched rather than merely
1475
+ * what they looked like. The retired shapes a bare rule TEXT, the pre-375 `{ rule, dots? }` object,
1476
+ * a `{ rules }` hit without its behavior are read as a clean negative by the normalizer: a stale
1477
+ * lane degrades toward asking, never toward an allow. Consulted on EVERY decision (a deny/ask row
1478
+ * must be able to tighten an allow), so the lane pays one store read per call on the tools it
1479
+ * speaks for; the runner's lane hands the org layer's read to it, so a governed deployment still
1480
+ * pays one. */
1481
+ adjudicate: (req: ToolCallRequest) => Promise<PersistedRuleAnswer>;
1451
1482
  /** Observation sink for an ask a rule coverage resolved — the allow-side disclosure. `rules` is
1452
1483
  * the coverage set's canonical texts in reporting order (design/375: a conjunction names every
1453
1484
  * rule the decision stands on). Never affects the outcome. */
@@ -301,7 +301,7 @@ export async function runToolGate(input) {
301
301
  screening,
302
302
  afterFold: runGateLanes,
303
303
  currentInput: modelInput,
304
- req: { toolName, args: modelInput, toolCallId },
304
+ req: { toolName, args: modelInput, toolCallId, ...(() => { const f = input.toolFaceOf?.(toolName); return f !== undefined ? { face: f } : {}; })() },
305
305
  preToolContext: [],
306
306
  hookAsk: undefined,
307
307
  parkFailed: undefined,
package/dist/core/lsp.js CHANGED
@@ -2,6 +2,7 @@ import { uriToPath } from "./lsp-protocol.js";
2
2
  import { foldRedundantPathSegments, isAbsolutePathForm } from "../tools/fs/safety.js";
3
3
  import { Type } from "typebox";
4
4
  import { defineTool, errorResult } from "./tools.js";
5
+ import { toolFace } from "./tool-catalog-entries.js";
5
6
  export const LSP_OPERATIONS = [
6
7
  "goToDefinition", "findReferences", "hover", "documentSymbol", "workspaceSymbol",
7
8
  "goToImplementation", "prepareCallHierarchy", "incomingCalls", "outgoingCalls",
@@ -145,7 +146,7 @@ const POSITION_OPS = new Set([
145
146
  export function createLspTool(manager, opts = {}) {
146
147
  return defineTool({
147
148
  name: "LSP",
148
- contract: { contractId: "core.lsp@1", implementationRevision: "1" },
149
+ ...toolFace("lsp"),
149
150
  description: "Interact with Language Server Protocol (LSP) servers to get code intelligence features.\n" +
150
151
  "\n" +
151
152
  "Supported operations:\n" +
@@ -172,7 +173,6 @@ export function createLspTool(manager, opts = {}) {
172
173
  character: Type.Optional(Type.Number({ description: "The character offset (1-based, as shown in editors)." })),
173
174
  query: Type.Optional(Type.String({ description: "Symbol query for workspaceSymbol (may be empty to browse broadly)." })),
174
175
  }),
175
- effect: "read",
176
176
  execute: async (args, ctx) => {
177
177
  const { operation, filePath, line, character, query } = args;
178
178
  if (POSITION_OPS.has(operation) && (line === undefined || character === undefined)) {
@@ -6,6 +6,7 @@
6
6
  * a longer name. Layer 0 vocabulary; `types.ts` re-exports every name below.
7
7
  */
8
8
  import type { ToolContentOrigin, ToolEffect } from "./tool-spec.js";
9
+ import type { ToolFamily, ToolPathTarget, ToolRenderHints, ToolRuleFace } from "./tool-face.js";
9
10
  /** Definition of an MCP server to connect to for the duration of one task, then discard. */
10
11
  export interface McpServerSpec {
11
12
  /** Stable name; tool names are namespaced as `<name>__<tool>` to avoid collisions. */
@@ -71,6 +72,22 @@ export interface McpServerSpec {
71
72
  egress?: boolean;
72
73
  irreversibility?: "always" | "never";
73
74
  }>;
75
+ /**
76
+ * design/388 §1.1 / §2.7 — the OPTIONAL per-tool FACE overlay for this server's tools (keyed by the server's
77
+ * RAW tool name, like {@link allowTools} / {@link toolAxes}): a deployment lets an MCP tool enter a specialised
78
+ * approval card, be confirmed by path, carry a display name / summary, or narrow its rule vocabulary. TRUSTED
79
+ * (the deployment writes it), but validated against the MATERIALIZED schema at every listing (first mount and
80
+ * every refresh): a `pathTarget.param` that is not a top-level property, an `approvalCard` whose keys the schema
81
+ * lacks, a `ruleFace` naming a non-scalar key ⇒ the tool mounts WITHOUT the overlay and the leg announces
82
+ * `config.tool_face_invalid` once; a key naming a tool the listing does not carry announces
83
+ * `config.tool_face_undeclared` once. Applied whole or not at all.
84
+ */
85
+ toolFaces?: Record<string, {
86
+ family?: ToolFamily;
87
+ pathTarget?: ToolPathTarget;
88
+ renderHints?: ToolRenderHints;
89
+ ruleFace?: ToolRuleFace;
90
+ }>;
74
91
  /**
75
92
  * design/378 — declare {@link ToolSpec.contentOrigin} on behalf of THIS SERVER'S ENTIRE TOOL SET
76
93
  * (tools a mid-task refresh adds included), with the same authority and the same responsibility a
package/dist/core/mcp.js CHANGED
@@ -18,6 +18,7 @@ import { discloseReminderShaped, observeReminderMarkEcho } from "./reminder-disc
18
18
  import { withContentOrigin } from "./memory-engine/content-origin.js";
19
19
  import { snapshotMcpEntries } from "./locked-config.js";
20
20
  import { validateJsonSchemaShape } from "./runner/strict-output-schema.js";
21
+ import { schemaKeyBoundProblem } from "./tool-roster.js";
21
22
  export const MCP_PREFIX = MCP_NAMESPACE.prefix;
22
23
  const MCP_OUTPUT_TOKENS_DEFAULT = 25_000;
23
24
  const MCP_CHARS_PER_TOKEN = 4;
@@ -1513,7 +1514,7 @@ function intakeListedTools(listed, spec, client, health, imageResizer, reminderD
1513
1514
  effectiveInputSchema = normalized.schema;
1514
1515
  effectiveDescription = normalized.note + (effectiveDescription ? `\n\n${effectiveDescription}` : "");
1515
1516
  }
1516
- const schemaProblem = mcpToolSchemaProblem(effectiveInputSchema);
1517
+ const schemaProblem = mcpToolSchemaProblem(effectiveInputSchema) ?? schemaKeyBoundProblem(effectiveInputSchema);
1517
1518
  if (schemaProblem !== undefined) {
1518
1519
  dropped.push({ tool: inlineUntrusted(t.name), reason: inlineUntrusted(schemaProblem, 240) });
1519
1520
  continue;
@@ -1551,6 +1552,7 @@ function intakeListedTools(listed, spec, client, health, imageResizer, reminderD
1551
1552
  parameters: (effectiveInputSchema ?? { type: "object" }),
1552
1553
  ...(mcpMaxResultSizeChars !== undefined ? { mcpMaxResultSizeChars } : {}),
1553
1554
  ...(mcpAlwaysLoad ? { mcpAlwaysLoad: true } : {}),
1555
+ mcpRemoteName: remoteName,
1554
1556
  execute: async (_toolCallId, params, signal) => {
1555
1557
  const what = `The call to tool ${inlineUntrusted(remoteName)}`;
1556
1558
  if (isServerRevoked?.(spec.name) === true) {