@sema-agent/core 7.46.0 → 7.48.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 (77) hide show
  1. package/CHANGELOG.md +106 -0
  2. package/dist/core/allow-entry.d.ts +1 -1
  3. package/dist/core/allow-entry.js +4 -5
  4. package/dist/core/ask-origin.d.ts +35 -12
  5. package/dist/core/ask-origin.js +23 -6
  6. package/dist/core/checkpoint-store.d.ts +1 -1
  7. package/dist/core/engine-notice.d.ts +7 -4
  8. package/dist/core/gate-fold.js +2 -2
  9. package/dist/core/gate-lanes.js +2 -1
  10. package/dist/core/gate-outcome.d.ts +4 -2
  11. package/dist/core/gate-outcome.js +2 -2
  12. package/dist/core/mcp-server-spec.d.ts +1 -1
  13. package/dist/core/permission-rule-model.d.ts +12 -6
  14. package/dist/core/permission-rule-model.js +10 -2
  15. package/dist/core/permission-rules.d.ts +4 -5
  16. package/dist/core/permission-rules.js +28 -15
  17. package/dist/core/runner/gate-exit.d.ts +4 -1
  18. package/dist/core/runner/gate-exit.js +5 -1
  19. package/dist/core/runner/park-commit.d.ts +1 -1
  20. package/dist/core/runner/prepare-hands-readface.js +5 -5
  21. package/dist/core/runner/prepare-policy-chain.js +2 -1
  22. package/dist/core/safety-axis-vocab.d.ts +1 -1
  23. package/dist/core/safety-merge-corpus.d.ts +1 -1
  24. package/dist/core/session-policy-store.d.ts +6 -3
  25. package/dist/core/session-policy-store.js +28 -10
  26. package/dist/core/shell-wrapper-projection.js +1 -1
  27. package/dist/core/shell-wrapper-table.d.ts +8 -8
  28. package/dist/core/shell-wrapper-table.js +3 -3
  29. package/dist/core/tool-catalog-entries.js +3 -2
  30. package/dist/core/tool-face.d.ts +10 -1
  31. package/dist/core/tool-name-aliases.d.ts +9 -8
  32. package/dist/core/tool-name-aliases.js +2 -0
  33. package/dist/core/tool-name-readings.d.ts +8 -0
  34. package/dist/core/tool-name-readings.js +2 -0
  35. package/dist/core/tool-policy.d.ts +10 -9
  36. package/dist/core/tool-policy.js +6 -7
  37. package/dist/core/tool-registry.d.ts +9 -0
  38. package/dist/core/tool-registry.js +10 -0
  39. package/dist/core/write-protect.d.ts +10 -3
  40. package/dist/core/write-protect.js +11 -1
  41. package/dist/index.d.ts +1 -1
  42. package/dist/index.js +1 -1
  43. package/dist/orchestration/workflow.js +2 -2
  44. package/dist/prompt-assembly/types.d.ts +1 -1
  45. package/dist/shell/lex.d.ts +15 -0
  46. package/dist/shell/lex.js +86 -15
  47. package/dist/tools/fs/bash-program-position.d.ts +16 -6
  48. package/dist/tools/fs/bash-program-position.js +18 -12
  49. package/dist/tools/fs/index.d.ts +3 -2
  50. package/dist/tools/fs/index.js +2 -2
  51. package/dist/tools/fs/program-read-model.d.ts +84 -12
  52. package/dist/tools/fs/program-read-model.js +105 -34
  53. package/dist/tools/fs/program-read-model.rows.js +26 -7
  54. package/dist/tools/fs/read-deny-home.d.ts +20 -1
  55. package/dist/tools/fs/read-deny-home.js +28 -7
  56. package/dist/tools/fs/read-deny.d.ts +13 -6
  57. package/dist/tools/fs/read-deny.js +12 -3
  58. package/dist/tools/fs/removal-targets.js +24 -5
  59. package/dist/tools/fs/shell-read-argv.d.ts +6 -8
  60. package/dist/tools/fs/shell-read-argv.js +5 -5
  61. package/dist/tools/fs/shell-read-bindings.d.ts +71 -19
  62. package/dist/tools/fs/shell-read-bindings.js +190 -75
  63. package/dist/tools/fs/shell-read-establish.js +6 -7
  64. package/dist/tools/fs/shell-read-plan.d.ts +3 -15
  65. package/dist/tools/fs/shell-read-plan.js +126 -129
  66. package/dist/tools/fs/shell-read-start.d.ts +57 -0
  67. package/dist/tools/fs/shell-read-start.js +92 -0
  68. package/dist/tools/fs/shell-read-word.d.ts +68 -8
  69. package/dist/tools/fs/shell-read-word.js +106 -12
  70. package/dist/tools/fs/shell-read-write-targets.js +4 -3
  71. package/dist/tools/fs/shell-reentry-text.d.ts +103 -3
  72. package/dist/tools/fs/shell-reentry-text.js +122 -4
  73. package/dist/tools/monitor.d.ts +3 -3
  74. package/package.json +1 -1
  75. package/test/export-surface.snapshot.json +9 -3
  76. package/wire-changes/core@7.47.0.json +412 -0
  77. package/wire-changes/core@7.48.0.json +252 -0
@@ -131,7 +131,9 @@ export declare function readRowBehavior(v: unknown): RuleBehavior | undefined;
131
131
  * clears it. The org/hook/matchedAskRule immunities stay: those conjuncts live in the gate's
132
132
  * persisted-rule lane and do not loosen with this set. */
133
133
  export type PersistedRuleTool = string;
134
- /** The one shell tool the command grammar speaks for. */
134
+ /** The command grammar's ONE rule word — the spelling a shell command rule is written in (`Bash(<command>)`, upstream's).
135
+ * A rule word, not a tool list: the calls a command rule reaches are the SHELL FACE's — every catalog definition whose
136
+ * `command` the command grammar judges ({@link laneToolNamesOf}). */
135
137
  export declare const COMMAND_RULE_TOOL = "Bash";
136
138
  /** The one tool whose path family has an ALLOW form (the `Read(//abs-dir/**)` directory grant). */
137
139
  export declare const READ_RULE_TOOL = "Read";
@@ -167,8 +169,9 @@ export declare const NAME_RULE_FACES: Readonly<Record<string, {
167
169
  readonly params: readonly string[];
168
170
  }>>;
169
171
  /**
170
- * Which tools a rule may name — the model's tool set is DERIVED, never a hand-kept list: the shell tool
171
- * takes the COMMAND grammar; every tool the catalog declares a path target for (Read / Edit / Write /
172
+ * Which tools a rule may name — the model's tool set is DERIVED, never a hand-kept list: the command rule word
173
+ * ({@link COMMAND_RULE_TOOL}) takes the COMMAND grammar — the other shell-face tools take none of their own, their command is
174
+ * judged by the `Bash(…)` rules ({@link laneToolNamesOf}); every tool the catalog declares a path target for (Read / Edit / Write /
172
175
  * Glob / NotebookEdit / …, `pathTargetOf`) takes the PATH grammar; the tools of {@link NAME_RULE_FACES} take the NAME grammar.
173
176
  * A rule on any other tool has no content this model can judge and is refused (`unsupported.tool`). The tool set therefore
174
177
  * moves with the catalog's declarations, exactly as the protective fences' covered set does.
@@ -177,9 +180,12 @@ export declare function ruleToolGrammarOf(toolName: string): "command" | "path"
177
180
  /** The value a name-grammar tool's call carries in its name argument — the string every NAME rule is read against, the one a
178
181
  * `Skill` call is resolved by — or undefined (not a name-grammar tool; no such argument; not a string). */
179
182
  export declare function nameRuleSubjectOf(toolName: string, args: unknown): string | undefined;
180
- /** The names the persisted grammar judges a call under: every name the call answers to (the model's spelling, the wire
181
- * name, the other aliases that resolve to it) the grammar speaks for, so a stored `Bash(…)` or `Edit(…)` row reaches a
182
- * tool that answers to that name under another wire name, as a policy's rule does. Empty ⇒ the grammar speaks for none. */
183
+ /** The rule WORDS a call is judged under, once each: for every name the call answers to (the model's spelling, the wire
184
+ * name, the other aliases that resolve to it), the word its rules are written in — a shell-face name's is the command
185
+ * grammar's {@link COMMAND_RULE_TOOL} (a `Bash(<command>)` rule reaches `Monitor` as it reaches `Bash`), a path or name
186
+ * tool's its own name — so a stored `Bash(…)` or `Edit(…)` row reaches a tool that answers to that name under another wire
187
+ * name, as a policy's rule does. Every lane that judges a call by a rule's word reads it here: the policy compiler, the
188
+ * person's stored rows, the org partition, the allow lists. Empty ⇒ no grammar speaks for the call. */
183
189
  export declare function laneToolNamesOf(req: Pick<import("./tool-policy.js").ToolCallRequest, "toolName" | "face">): readonly string[];
184
190
  /**
185
191
  * Match forms. Four members, two of them one predicate:
@@ -3,7 +3,7 @@ import { parsePermissionRule } from "./permission-rule-syntax.js";
3
3
  import { compileReadDeny } from "../tools/fs/read-deny.js";
4
4
  import { carriesShellRedirection, parseLeadingCommandName, splitShellCompoundSegments } from "../tools/fs/bash-readonly-classifier.js";
5
5
  import { inlineUntrusted } from "./untrusted-text.js";
6
- import { pathTargetOf, protectivePathTargetOf } from "./tool-registry.js";
6
+ import { isShellFaceToolName, pathTargetOf, protectivePathTargetOf } from "./tool-registry.js";
7
7
  import { heldValues } from "./tool-name-readings.js";
8
8
  import { carriesDotSegment, isAbsoluteForFamily, isIdentityDir, isPathIdentity, isShellRootedSpellingUnmapped, isUncIdentity, pathFamilyOf, pathIdentityOf } from "../tools/fs/safety.js";
9
9
  import { bindPathRules, pathRowsReach, rowsWhereTheyLead } from "./path-rule-binding.js";
@@ -26,6 +26,8 @@ export function readRowBehavior(v) {
26
26
  return isRuleBehavior(v) ? v : undefined;
27
27
  }
28
28
  export const COMMAND_RULE_TOOL = "Bash";
29
+ if (!isShellFaceToolName(COMMAND_RULE_TOOL))
30
+ throw new Error(`permission rules: the command rule word ${JSON.stringify(COMMAND_RULE_TOOL)} names no definition on the catalog's shell face`);
29
31
  export const READ_RULE_TOOL = "Read";
30
32
  export const PATH_RULE_FACES = Object.freeze({
31
33
  [READ_RULE_TOOL]: Object.freeze({ deny: Object.freeze(["read", "write"]), ask: Object.freeze(["read"]) }),
@@ -56,7 +58,13 @@ export function nameRuleSubjectOf(toolName, args) {
56
58
  return typeof value === "string" ? value : undefined;
57
59
  }
58
60
  export function laneToolNamesOf(req) {
59
- return heldValues("policy.persistedGrammar", { get: (n) => (ruleToolGrammarOf(n) !== undefined ? n : undefined) }, req);
61
+ const words = heldValues("policy.persistedGrammar", { get: ruleWordOf }, req);
62
+ return words.length <= 1 ? words : [...new Set(words)];
63
+ }
64
+ function ruleWordOf(name) {
65
+ if (isShellFaceToolName(name))
66
+ return COMMAND_RULE_TOOL;
67
+ return ruleToolGrammarOf(name) !== undefined ? name : undefined;
60
68
  }
61
69
  function isCommandMatch(match) {
62
70
  return match === "exact" || match === "prefix" || match === "wildcard";
@@ -22,6 +22,10 @@
22
22
  * (`programRunReachOf`: the whole command line or any program run in a connector chain — upstream's
23
23
  * per-subcommand deny, the same instrument the org partition and the personal store's deny/ask rows use;
24
24
  * a call the lexer cannot read against a rule is a fail-closed ask, never silence).
25
+ * The rule is written in the command word (`Bash`) and reaches every call on the catalog's SHELL FACE — `Bash`, and
26
+ * `Monitor`, which runs its `command` through the same execution seam — under any name the call answers to; a command
27
+ * spelling on another shell-face name (`Monitor(touch:*)`) is refused (`invalid.command_rule`, naming the `Bash(…)` form):
28
+ * upstream reads no such rule. A bare `Bash` rule is a tool-name rule and reaches the tool of that name only.
25
29
  * It sits in the field-level tier of the evaluation order — right after the bare deny (resp. ask) — which
26
30
  * is upstream's placement (`bs()` whole-tool deny → `AN(…,"deny")` field-level deny → `ph()` whole-tool
27
31
  * ask → `AN(…,"ask")` field-level ask).
@@ -57,11 +61,6 @@ import type { NamedToolPolicy } from "./tool-policy.js";
57
61
  import { parsePermissionRule, type ParsedPermissionRule } from "./permission-rule-syntax.js";
58
62
  import { type PathRuleBases } from "./permission-rule-model.js";
59
63
  export { parsePermissionRule, type ParsedPermissionRule };
60
- /** [K-PLATFORM-SWEEP] MINOR#11: Bash's REAL non-primary arg names, accepted as generic param rules
61
- * (`Bash(timeout:...)`). Exported SOLELY so a lockstep test can assert it against the live Bash tool
62
- * schema — a schema param added without updating this set would compile to silently-never-matching
63
- * rules (the exact drift class the Opus 复审 m note warned about). */
64
- export declare const BASH_GENERIC_PARAMS: ReadonlySet<string>;
65
64
  export interface PermissionRule {
66
65
  /** CC 语法:`"Tool"` 或 `"Tool(param:pattern)"`。`Tool()`/`Tool(*)` 归一为裸规则;括号可 `\(`/`\)` 转义。 */
67
66
  rule: string;
@@ -1,15 +1,14 @@
1
1
  import { MCP_NAMESPACE, protocolOf } from "./protocol-table.js";
2
2
  import { splitNamespacedName } from "./tool-name-aliases.js";
3
- import { catalogRuleFaceOf, pathTargetOf } from "./tool-registry.js";
3
+ import { catalogRuleFaceOf, isShellFaceToolName, pathTargetOf } from "./tool-registry.js";
4
4
  import { indexOfUnescaped, lastIndexOfUnescaped, parsePermissionRule } from "./permission-rule-syntax.js";
5
- import { NAME_RULE_FACES, PATH_RULE_BASE_LABEL, PATH_RULE_FACES, callPathFaceOf, isUsablePathBase, nameRuleAdmits, nameRuleSubjectOf, parseRuleText, programRunReachOf, ruleBasesNeeded, ruleToolGrammarOf } from "./permission-rule-model.js";
5
+ import { COMMAND_RULE_TOOL, NAME_RULE_FACES, PATH_RULE_BASE_LABEL, PATH_RULE_FACES, callPathFaceOf, isUsablePathBase, laneToolNamesOf, nameRuleAdmits, nameRuleSubjectOf, parseRuleText, programRunReachOf, ruleBasesNeeded, ruleToolGrammarOf } from "./permission-rule-model.js";
6
6
  import { pathFamilyOf } from "../tools/fs/safety.js";
7
7
  import { callTargetReadingOf, callTargetViewsOf } from "./effective-path-target.js";
8
8
  import { bindPathRules, pathRowsReach, policyAnchors, rowsWhereTheyLead } from "./path-rule-binding.js";
9
9
  import { ruleDenialSentence } from "./rule-denial-sentence.js";
10
10
  import { heldValues } from "./tool-name-readings.js";
11
11
  export { parsePermissionRule };
12
- export const BASH_GENERIC_PARAMS = new Set(catalogRuleFaceOf("Bash")?.params ?? []);
13
12
  function describeBehavior(v) {
14
13
  switch (typeof v) {
15
14
  case "undefined": return "undefined";
@@ -110,13 +109,14 @@ function compile(rules, caps, primaryFieldGeneric, ruleFaces, pathBases = {}) {
110
109
  return { face, judged: face !== undefined };
111
110
  };
112
111
  const byTool = new Map();
112
+ const command = { deny: [], ask: [] };
113
113
  const namespacedCovering = { deny: [], ask: [] };
114
114
  const issues = [];
115
115
  const bad = (rule, code, message) => {
116
116
  issues.push({ rule, code, message });
117
117
  };
118
118
  if (rules.length > caps.maxRules) {
119
- return { byTool, namespacedCovering, issues: [{ rule: "", code: "invalid.cap_exceeded", message: `${rules.length} rules > maxRules ${caps.maxRules}` }] };
119
+ return { byTool, command, namespacedCovering, issues: [{ rule: "", code: "invalid.cap_exceeded", message: `${rules.length} rules > maxRules ${caps.maxRules}` }] };
120
120
  }
121
121
  for (const r of rules) {
122
122
  const text = r.rule;
@@ -174,17 +174,24 @@ function compile(rules, caps, primaryFieldGeneric, ruleFaces, pathBases = {}) {
174
174
  }
175
175
  const content = parsed.ruleContent;
176
176
  const colon = content.indexOf(":");
177
- if (toolName === "Bash") {
177
+ if (isShellFaceToolName(toolName)) {
178
178
  const paramPart = colon > 0 ? content.slice(0, colon).trim() : "";
179
- const looksLikeParamRule = colon > 0 && /^[A-Za-z_][A-Za-z0-9_]*$/.test(paramPart) && BASH_GENERIC_PARAMS.has(paramPart);
180
- if (!looksLikeParamRule || content.endsWith(":*")) {
179
+ const declaresParam = (tool) => colon > 0 && /^[A-Za-z_][A-Za-z0-9_]*$/.test(paramPart) && (catalogRuleFaceOf(tool)?.params ?? []).includes(paramPart);
180
+ const looksLikeParamRule = declaresParam(toolName);
181
+ if (toolName !== COMMAND_RULE_TOOL && !looksLikeParamRule) {
182
+ const commandWordParam = declaresParam(COMMAND_RULE_TOOL) && !content.endsWith(":*");
183
+ bad(text, "invalid.command_rule", commandWordParam
184
+ ? `"${text}" is a parameter rule on "${paramPart}", which ${toolName} does not declare (its rule parameters: ${(catalogRuleFaceOf(toolName)?.params ?? []).join(", ")}); a parameter rule reaches only the tool that declares the parameter, so ${COMMAND_RULE_TOOL}(${content}) is a rule on ${COMMAND_RULE_TOOL}'s own argument and does not reach ${toolName}. A shell command rule is written ${COMMAND_RULE_TOOL}(<command>) — it reaches ${toolName} and every other tool that runs a shell command by the command it runs.`
185
+ : `a shell command rule is written ${COMMAND_RULE_TOOL}(…) — it reaches ${toolName} and every other tool that runs a shell command by the command it runs; "${text}" names no rule upstream reads. Write ${COMMAND_RULE_TOOL}(${content}).`);
186
+ continue;
187
+ }
188
+ if (toolName === COMMAND_RULE_TOOL && (!looksLikeParamRule || content.endsWith(":*"))) {
181
189
  const parsedCommand = parseRuleText(text, verb);
182
190
  if ("reject" in parsedCommand) {
183
191
  bad(text, "invalid.command_rule", `${parsedCommand.reject.code}: ${parsedCommand.reject.message}`);
184
192
  continue;
185
193
  }
186
- entry.content[verb].push({ parsed: parsedCommand.rule, ruleText: text, ...(r.source !== undefined ? { source: r.source } : {}) });
187
- byTool.set(toolName, entry);
194
+ command[verb].push({ parsed: parsedCommand.rule, ruleText: text, ...(r.source !== undefined ? { source: r.source } : {}) });
188
195
  continue;
189
196
  }
190
197
  }
@@ -264,7 +271,7 @@ function compile(rules, caps, primaryFieldGeneric, ruleFaces, pathBases = {}) {
264
271
  entry.param[verb].push({ param, pattern, behavior: verb, ruleText: text, source: r.source });
265
272
  byTool.set(toolName, entry);
266
273
  }
267
- return { byTool, namespacedCovering, issues };
274
+ return { byTool, command, namespacedCovering, issues };
268
275
  }
269
276
  export function validatePermissionRules(rules, opts) {
270
277
  const caps = { ...DEFAULT_CAPS, ...opts?.caps };
@@ -303,7 +310,7 @@ const NO_LEADS = new Map();
303
310
  export function createPermissionRulePolicy(rules, opts) {
304
311
  const caps = { ...DEFAULT_CAPS, ...opts?.caps };
305
312
  const pathBases = opts?.pathBases ?? {};
306
- const { byTool, namespacedCovering, issues } = compile(rules, caps, opts?.primaryFieldGeneric ?? "reject", opts?.ruleFaces, pathBases);
313
+ const { byTool, command, namespacedCovering, issues } = compile(rules, caps, opts?.primaryFieldGeneric ?? "reject", opts?.ruleFaces, pathBases);
307
314
  const setLevelCap = issues.some((i) => i.code === "invalid.cap_exceeded" && i.rule === "");
308
315
  if (issues.length > 0 && ((opts?.onInvalidRule ?? "throw") === "throw" || setLevelCap)) {
309
316
  const e = new Error(`createPermissionRulePolicy: ${issues.length} invalid/unsupported rule(s):\n` +
@@ -335,6 +342,9 @@ export function createPermissionRulePolicy(rules, opts) {
335
342
  if (entry.bare.ask !== undefined || entry.param.ask.length > 0 || entry.content.ask.length > 0)
336
343
  nameSets.ask.push(toolName);
337
344
  }
345
+ for (const behavior of ["deny", "ask"])
346
+ if (command[behavior].length > 0 && !nameSets[behavior].includes(COMMAND_RULE_TOOL))
347
+ nameSets[behavior].push(COMMAND_RULE_TOOL);
338
348
  for (const behavior of ["deny", "ask"]) {
339
349
  for (const r of namespacedCovering[behavior])
340
350
  nameSets[behavior].push(r.ruleName);
@@ -345,11 +355,13 @@ export function createPermissionRulePolicy(rules, opts) {
345
355
  deny: [...byTool.values()].flatMap((e) => e.content.deny.filter((r) => r.faces !== undefined)),
346
356
  ask: [...byTool.values()].flatMap((e) => e.content.ask.filter((r) => r.faces !== undefined)),
347
357
  };
348
- const contentLaneOf = (behavior, entry, callFace) => [
358
+ const contentLaneOf = (behavior, entry, shellCall, callFace) => [
349
359
  ...(entry?.content[behavior].filter((r) => r.faces === undefined) ?? []),
360
+ ...(shellCall ? command[behavior] : []),
350
361
  ...(callFace !== undefined ? faceRules[behavior].filter((r) => r.faces.includes(callFace)) : []),
351
362
  ];
352
- const commandParam = catalogRuleFaceOf("Bash")?.primaryParams[0] ?? "command";
363
+ const hasCommandRules = command.deny.length > 0 || command.ask.length > 0;
364
+ const commandParam = catalogRuleFaceOf(COMMAND_RULE_TOOL)?.primaryParams[0] ?? "command";
353
365
  const pathRuleOf = new Map();
354
366
  for (const e of byTool.values()) {
355
367
  for (const behavior of ["deny", "ask"]) {
@@ -450,7 +462,8 @@ export function createPermissionRulePolicy(rules, opts) {
450
462
  return { action: "deny", message: ruleDenial(req, coveringDeny.ruleText, coveringDeny.source) };
451
463
  }
452
464
  const callFace = faceRules.deny.length > 0 || faceRules.ask.length > 0 ? callPathFaceOf(req) : undefined;
453
- const denyLane = contentLaneOf("deny", entry, callFace);
465
+ const shellCall = hasCommandRules && laneToolNamesOf(req).includes(COMMAND_RULE_TOOL);
466
+ const denyLane = contentLaneOf("deny", entry, shellCall, callFace);
454
467
  const contentDeny = denyLane.length > 0 ? contentHit(denyLane, req) : undefined;
455
468
  if (contentDeny !== undefined && contentDeny.unreadable === undefined) {
456
469
  return { action: "deny", message: ruleDenial(req, contentDeny.rule.ruleText, contentDeny.rule.source) };
@@ -473,7 +486,7 @@ export function createPermissionRulePolicy(rules, opts) {
473
486
  if (coveringAsk)
474
487
  return { action: "ask", message: ruleAskMessage(coveringAsk.ruleText, coveringAsk.source), matchedAskRule: coveringAsk.ruleText };
475
488
  }
476
- const askLane = contentLaneOf("ask", entry, callFace);
489
+ const askLane = contentLaneOf("ask", entry, shellCall, callFace);
477
490
  const contentAsk = askLane.length > 0 ? contentHit(askLane, req) : undefined;
478
491
  if (contentAsk !== undefined) {
479
492
  if (contentAsk.unreadable !== undefined)
@@ -23,7 +23,7 @@
23
23
  */
24
24
  import { type ClassifierDenyCause, type DeniedBy, type GateOutcome, type ModeRefusal, type ModeRelease, type Settlement } from "../gate-outcome.js";
25
25
  import type { AutoModeClassifierRound } from "../auto-mode.js";
26
- import { type Arbitration, type AskClassification, type AskOrigin, type AskOriginFacts } from "../ask-origin.js";
26
+ import { type Arbitration, type AskClassification, type AskFace, type AskOrigin, type AskOriginFacts } from "../ask-origin.js";
27
27
  import { type PermissionResult, type ResolvedAsk, type ToolCallRequest } from "../tool-policy.js";
28
28
  import type { SafeNotifier } from "../safe-notify.js";
29
29
  import type { HookSeatOutcome, PermissionDeniedPayload, PreToolUseResult, ToolGateInput, ToolGateResult } from "../hooks.js";
@@ -143,6 +143,9 @@ export interface PreToolUseScreening {
143
143
  /** The model-facing text for an answer the decision receipt refused (an ENGINE fail-closed, not the face's deny). */
144
144
  refusedReason(answer: PreToolUseResult): string;
145
145
  }
146
+ /** The FACE a pass's questions are put on ({@link askFaceOf} of the call's wire name) — the one reading of it the
147
+ * write-protection tighten (the fold) and every class derivation (the lanes) make. */
148
+ export declare function passAskFace(pass: Pick<GatePass, "req">): AskFace;
146
149
  /**
147
150
  * ONE gate pass — the record the station groups (`runGateFold`, `runGateLanes`) and the exits read and write,
148
151
  * built once per call by `runToolGate` before the screening pass. What used to be the closure-captured `let`
@@ -1,5 +1,6 @@
1
1
  import { screenGateOutcome, SETTLEMENT_IS_REFUSAL } from "../gate-outcome.js";
2
- import { classifyAsk, headlessApprovalRemedy } from "../ask-origin.js";
2
+ import { askFaceOf, classifyAsk, headlessApprovalRemedy } from "../ask-origin.js";
3
+ import { wireOf } from "../tool-name-readings.js";
3
4
  import { isProtectedWrite } from "../tool-registry.js";
4
5
  import { askUnresolvableCauseOf, classifierDenyCauseOf, classifierRoundOf, decisionText, engineSettlementOf } from "../tool-policy.js";
5
6
  import { askUnresolvableRemedySeat } from "../ask-unresolvable-notice.js";
@@ -97,6 +98,9 @@ function withParkFailureCause(reason, parkFailed) {
97
98
  `(${inlineUntrusted(parkFailed, PARK_FAILURE_CAUSE_MAX)}), so the refusal above is what the fallback had ` +
98
99
  `left to say, not the reason the call stopped.`);
99
100
  }
101
+ export function passAskFace(pass) {
102
+ return askFaceOf(wireOf("gate.askFace", pass.req));
103
+ }
100
104
  export async function engineFailClosedExit(pass, reason) {
101
105
  const { input, toolName, toolCallId, notifyPermissionDenied } = pass;
102
106
  const gate = directDeny("hook");
@@ -33,7 +33,7 @@ export declare const DEFAULT_UNATTENDED_APPROVAL_TTL_MS: number;
33
33
  * A caller-supplied TTL is only a TTL if it is a finite, positive number.
34
34
  * `TaskSpec.durableApproval.ttlMs` / `resourceSuspend.ttlMs` are typed as a bare `number`, and the mint
35
35
  * arithmetic was `Date.now() + ttlMs` with NO validation — so `Number(process.env.APPROVAL_TTL_MS)` on an
36
- * unset/malformed env var (a config-center miss, an upstream arithmetic slip) silently produced
36
+ * unset/malformed env var (a web-admin miss, an upstream arithmetic slip) silently produced
37
37
  * `deadline = NaN`. Every reaper comparison is `deadline <= cutoff`, and `NaN <= anything` is FALSE, so a
38
38
  * checkpoint that LOOKS bounded (it carries a ttlMs and a deadline) became permanently IMMORTAL: no reap
39
39
  * cutoff, however far in the future, could ever expire it → a pinned session + a stranded paused VM, and
@@ -8,7 +8,7 @@ import { placeReadDenyAtHome } from "../../tools/fs/read-deny-home.js";
8
8
  import { createShellOverflowSpoolFence } from "../../tools/fs/fs-shared.js";
9
9
  import { createShellReadStation } from "../../tools/fs/shell-read-check.js";
10
10
  import { composeReadChecks, createFileReadStation } from "../../tools/fs/file-read-check.js";
11
- import { readTargetToolNames } from "../tool-registry.js";
11
+ import { isShellFaceToolName, predictMountedToolNames, readTargetToolNames } from "../tool-registry.js";
12
12
  import { normalizeFileText } from "../../tools/fs/encoding.js";
13
13
  import { wholeFileRecordsFromTranscript } from "./session-file-state-replay.js";
14
14
  import { deliverEngineNotice } from "../types.js";
@@ -203,6 +203,7 @@ export async function prepareHandsMount(input) {
203
203
  const handsIncludeShell = !(executionEnv instanceof StubExecutionEnv);
204
204
  const handsReadOnly = spec.handsReadOnly === true;
205
205
  backgroundTaskToolsActive = handsIncludeShell && !handsReadOnly && hasBackgroundShell(executionEnv);
206
+ const shellFaceHere = [...predictMountedToolNames(new Set(backgroundTaskToolsActive ? ["hands", "backgroundTasks"] : ["hands"]))].filter(isShellFaceToolName);
206
207
  wsSnapshot = resume !== undefined || !(handsIncludeShell && !handsReadOnly) ? undefined : await session.getWorkspaceState().catch(() => undefined);
207
208
  rebaseWsPath = (p) => (wsSnapshot === undefined || wsSnapshot.taskRoot === rootCanonical ? p : rebaseWorkspacePath(p, wsSnapshot.taskRoot, rootCanonical));
208
209
  if (handsIncludeShell && !handsReadOnly) {
@@ -307,7 +308,7 @@ export async function prepareHandsMount(input) {
307
308
  if (handsIncludeShell) {
308
309
  const spoolFence = handsReadOnly ? createShellOverflowSpoolFence(executionEnv) : undefined;
309
310
  const readonlyBoundary = () => spoolFence !== undefined ? { roots: [rootCanonical, ...additionalRootsCanonical, ...additionalReadRootsCanonical], cwd: rootCanonical, ...denySeat, ...writeDenySeat, alsoInside: spoolFence } : undefined;
310
- shellReadTools = new Set(handsReadOnly ? ["Bash"] : backgroundTaskToolsActive ? ["Bash", "Monitor"] : ["Bash"]);
311
+ shellReadTools = new Set(shellFaceHere);
311
312
  shellReadStation = createShellReadStation({
312
313
  env: executionEnv,
313
314
  tools: shellReadTools,
@@ -400,9 +401,8 @@ export async function prepareHandsMount(input) {
400
401
  if (seatOwned)
401
402
  shellGatedTools.add(name);
402
403
  };
403
- installShellSeat("Bash");
404
- if (backgroundTaskToolsActive)
405
- installShellSeat("Monitor");
404
+ for (const name of shellFaceHere)
405
+ installShellSeat(name);
406
406
  }
407
407
  }
408
408
  return {
@@ -14,7 +14,7 @@ import { emitTrace } from "../trace.js";
14
14
  import { skillGrantBindingMembers } from "../skill-grant.js";
15
15
  import { SKILL_TOOL_NAME } from "./synthetic-tools.js";
16
16
  import { autoStrippedRule, cardDisclosureOf } from "../allow-entry.js";
17
- import { arbitrateByMode, askClassMembersOf, askMandateMember } from "../ask-origin.js";
17
+ import { arbitrateByMode, askClassMembersOf, askFaceOf, askMandateMember } from "../ask-origin.js";
18
18
  import { createModeGatePolicy, mountedToolDeclaresReadOnly } from "../mode-policy.js";
19
19
  const ancestorFacts = (ask) => ({ ...(ask ?? {}), ancestorResolved: true });
20
20
  import { headlessDenyAtFold, headlessDenyAtRecheck, INHERITED_STATION_FACTS, inheritedAskCarry, inheritedAskFallbackMessage, judgeInheritedClassifier, lateAskSettlementObserver, resolveInheritedAsk, settleDenialLimitFallback } from "./denial-limit-arms.js";
@@ -313,6 +313,7 @@ export async function preparePolicyChain(input) {
313
313
  ...(seat.sources.size > 0 ? { toolMarkSources: seat.sources } : {}),
314
314
  ...(deps.a2a?.isolatePeers === true ? { a2aIsolatePeers: true } : {}),
315
315
  ...(face !== undefined ? { readFace: face } : {}),
316
+ askFace: askFaceOf(wireOf("inherited.askFace", call)),
316
317
  };
317
318
  };
318
319
  const inheritedAskEvidence = inheritedAskRuleEvidence(deps.permissionRuleStore);
@@ -2,7 +2,7 @@
2
2
  * `SAFETY_AXIS_VOCABULARY` — the PUBLISHED safety-axis vocabulary as a zero-logic, zero-runtime-dependency DATA
3
3
  * artifact (pure string / number / boolean literal constants).
4
4
  *
5
- * **design/99 center control-plane seam (2026-06-27).** The sema-registry control plane's per-field
5
+ * **design/99 center control-plane seam (2026-06-27).** The web-admin control plane's per-field
6
6
  * safety-classification table lives in `@sema-agent/registry-core` and, for every safety-critical config field, cites a
7
7
  * core axis VALUE (e.g. `effect: "write"`, `irreversibility: "always"`). That contract package **must not depend on
8
8
  * `@sema-agent/core` (the engine) at runtime** — so it dev-imports THIS const (test-only) and runs a conformance test
@@ -1,6 +1,6 @@
1
1
  import type { SessionPermissionRules } from "./session-policy-store.js";
2
2
  /**
3
- * design/99 (sema-registry center control-plane seam, channel review R2): the PUBLISHED golden conformance corpus
3
+ * design/99 (web-admin control-plane seam, channel review R2): the PUBLISHED golden conformance corpus
4
4
  * for the safety-domain MERGE predicate {@link loosenReasons}.
5
5
  *
6
6
  * THE SEAM. The center control-plane's ConfigBundle merger is a DISPATCHER: for any field it classifies
@@ -67,7 +67,7 @@ export interface SessionRuleNameRefusal {
67
67
  /** Typed error from {@link SessionPolicyStore.putRules}. `conflict` = stale `expectedRev`; `loosen_forbidden`
68
68
  * = a non-operator write tried to relax the rules; `corrupt` = a backend could not read the erase-surviving
69
69
  * rev mark ({@link SessionPolicyStore.deleteBySession}) and refuses rather than stamp an unproven rev; the fourth
70
- * code = a tool-name bucket names a retired name, a prefix-less `__` name or a peer segment no mint spells
70
+ * code = a tool-name bucket names a retired name, a prefix-less `__` name or a segment no mint spells (an empty one, or a peer spelled otherwise)
71
71
  * ({@link assertSessionRuleNamesCurrent}) — `detail` is present on that code and only on it. */
72
72
  export declare class SessionPolicyError extends Error {
73
73
  readonly code: "conflict" | "loosen_forbidden" | "corrupt" | "legacy_tool_name";
@@ -121,7 +121,8 @@ export interface SessionPolicyStore {
121
121
  /** Write the rules for a key: CAS on `expectedRev`, then the name judge ({@link assertSessionRuleNamesCurrent} —
122
122
  * operator writes included), then, for a non-operator write over an existing row, the
123
123
  * tighten-only check ({@link loosenReasons}) — both against the names the deployment declares it mounts its own
124
- * tools under. */
124
+ * tools under. A bucket that is not a list of strings (a bare `"rm"` for `commandDeny`) throws a `TypeError` from
125
+ * {@link normalizeRules} before any of those is judged, and nothing is written. */
125
126
  putRules(sessionId: string, principal: string | undefined, rules: SessionPermissionRules, opts?: PutRulesOptions): Promise<StoredSessionRules>;
126
127
  /**
127
128
  * 2c session-sync: every (principal, rules) record for `sessionId` across ALL principals — `getRules`
@@ -172,7 +173,9 @@ export interface SessionPolicyStore {
172
173
  */
173
174
  declare function lexWithin(root: string, p: string): boolean;
174
175
  /** Normalize a rules object's `allowDirs` (lexical `posix.normalize`) so stored rules are `..`-free and the
175
- * tighten diff compares the same canonical-ish form the runtime resolves. */
176
+ * tighten diff compares the same canonical-ish form the runtime resolves. Every bucket must be a list of strings
177
+ * ({@link ruleList}); a bucket that is not one throws `TypeError`, so neither bundled store (nor a durable twin that
178
+ * normalizes through this function) can store a rule list the matcher would read differently from the one written. */
176
179
  export declare function normalizeRules(rules: SessionPermissionRules): SessionPermissionRules;
177
180
  /**
178
181
  * Pure tighten-only diff: the reasons `next` LOOSENS `prior` (empty ⇒ `next` is a pure tightening or no-op).
@@ -75,18 +75,36 @@ function lexWithin(root, p) {
75
75
  return true;
76
76
  return np === nr || np.startsWith(nr + "/");
77
77
  }
78
+ function ruleList(field, value) {
79
+ if (value === undefined)
80
+ return undefined;
81
+ const refuse = (got) => {
82
+ throw new TypeError(`session rules ${field} must be an array of strings, got ${got} — a rule list is written as a list of names (["rm"], not "rm")`);
83
+ };
84
+ if (Array.isArray(value) && Array.from(value).every((entry) => typeof entry === "string")) {
85
+ if (value.some((entry) => entry.trim() === ""))
86
+ refuse("an array holding an empty or blank entry");
87
+ return value;
88
+ }
89
+ return refuse(Array.isArray(value) ? "an array holding an entry that is not a string" : value === null ? "null" : `a ${typeof value}`);
90
+ }
78
91
  export function normalizeRules(rules) {
79
92
  const out = {};
80
- if (rules.toolAllow !== undefined)
81
- out.toolAllow = [...rules.toolAllow];
82
- if (rules.toolDeny !== undefined)
83
- out.toolDeny = [...rules.toolDeny];
84
- if (rules.commandAllow !== undefined)
85
- out.commandAllow = [...rules.commandAllow];
86
- if (rules.commandDeny !== undefined)
87
- out.commandDeny = [...rules.commandDeny];
88
- if (rules.allowDirs !== undefined)
89
- out.allowDirs = rules.allowDirs.map((d) => posix.normalize(d).replace(/\/+$/, "") || "/");
93
+ const toolAllow = ruleList("toolAllow", rules.toolAllow);
94
+ const toolDeny = ruleList("toolDeny", rules.toolDeny);
95
+ const commandAllow = ruleList("commandAllow", rules.commandAllow);
96
+ const commandDeny = ruleList("commandDeny", rules.commandDeny);
97
+ const allowDirs = ruleList("allowDirs", rules.allowDirs);
98
+ if (toolAllow !== undefined)
99
+ out.toolAllow = [...toolAllow];
100
+ if (toolDeny !== undefined)
101
+ out.toolDeny = [...toolDeny];
102
+ if (commandAllow !== undefined)
103
+ out.commandAllow = [...commandAllow];
104
+ if (commandDeny !== undefined)
105
+ out.commandDeny = [...commandDeny];
106
+ if (allowDirs !== undefined)
107
+ out.allowDirs = allowDirs.map((d) => posix.normalize(d).replace(/\/+$/, "") || "/");
90
108
  return out;
91
109
  }
92
110
  export function loosenReasons(prior, next, mountedToolNames = NO_DECLARED_TOOLS) {
@@ -30,7 +30,7 @@ const runsCommand = (spec) => spec.effect === "executes" || spec.value?.kind ===
30
30
  function projectRow(row) {
31
31
  const sorted = (xs) => [...new Set(xs)].sort();
32
32
  const all = spellings(row);
33
- const viaShell = row.reentry?.kind === "shell" || row.launcher?.shellText === true || row.body === "first-operand" || row.body === "assignment-values" || (row.operands?.rest?.kind === "program-text" && row.operands.rest.language === "arithmetic");
33
+ const viaShell = row.reentry?.kind === "shell" || row.launcher?.shellText !== undefined || row.body === "first-operand" || row.body === "assignment-values" || (row.operands?.rest?.kind === "program-text" && row.operands.rest.language === "arithmetic");
34
34
  if (row.launcher === undefined) {
35
35
  if (viaShell)
36
36
  return { valueFlags: [], flags: [], commandString: [], viaShell: true };
@@ -114,8 +114,8 @@ export declare const SHELL_WRAPPER_TABLE: {
114
114
  readonly commandString: readonly [];
115
115
  };
116
116
  readonly watch: {
117
- readonly valueFlags: readonly [];
118
- readonly flags: readonly [];
117
+ readonly valueFlags: readonly ["--equexit", "--interval", "--shotsdir", "-n", "-q", "-s"];
118
+ readonly flags: readonly ["--beep", "--chgexit", "--color", "--differences", "--errexit", "--exec", "--follow", "--help", "--no-color", "--no-rerun", "--no-title", "--no-wrap", "--precise", "--version", "-C", "-b", "-c", "-e", "-f", "-g", "-h", "-p", "-r", "-t", "-v", "-w", "-x"];
119
119
  readonly commandString: readonly [];
120
120
  readonly viaShell: true;
121
121
  };
@@ -276,15 +276,15 @@ export declare const SHELL_WRAPPER_TABLE: {
276
276
  readonly commandString: readonly [];
277
277
  };
278
278
  readonly su: {
279
- readonly valueFlags: readonly [];
280
- readonly flags: readonly [];
281
- readonly commandString: readonly [];
279
+ readonly valueFlags: readonly ["--group", "--shell", "--supp-group", "--whitelist-environment", "-G", "-g", "-s", "-w"];
280
+ readonly flags: readonly ["--fast", "--help", "--login", "--no-pty", "--preserve-environment", "--pty", "--version", "-P", "-T", "-V", "-f", "-h", "-l", "-m", "-p"];
281
+ readonly commandString: readonly ["--command", "--session-command", "-c"];
282
282
  readonly viaShell: true;
283
283
  };
284
284
  readonly runuser: {
285
- readonly valueFlags: readonly [];
286
- readonly flags: readonly [];
287
- readonly commandString: readonly [];
285
+ readonly valueFlags: readonly ["--group", "--shell", "--supp-group", "--user", "--whitelist-environment", "-G", "-g", "-s", "-u", "-w"];
286
+ readonly flags: readonly ["--fast", "--help", "--login", "--no-pty", "--preserve-environment", "--pty", "--version", "-P", "-T", "-V", "-f", "-h", "-l", "-m", "-p"];
287
+ readonly commandString: readonly ["--command", "--session-command", "-c"];
288
288
  readonly viaShell: true;
289
289
  };
290
290
  readonly chroot: {
@@ -15,7 +15,7 @@ export const SHELL_WRAPPER_TABLE = {
15
15
  timeout: { valueFlags: ["--kill-after", "--signal", "-k", "-s"], flags: ["--foreground", "--help", "--preserve-status", "--verbose", "--version", "-v"], commandString: [], duration: true },
16
16
  stdbuf: { valueFlags: ["--error", "--input", "--output", "-e", "-i", "-o"], flags: ["--help", "--version"], commandString: [] },
17
17
  xargs: { valueFlags: ["--arg-file", "--delimiter", "--max-args", "--max-chars", "--max-procs", "--process-slot-var", "-E", "-I", "-L", "-P", "-a", "-d", "-n", "-s"], flags: ["--eof", "--exit", "--help", "--interactive", "--max-lines", "--no-run-if-empty", "--null", "--open-tty", "--replace", "--show-limits", "--verbose", "--version", "-0", "-o", "-p", "-r", "-t", "-x"], attached: ["-i", "-l"], commandString: [] },
18
- watch: { valueFlags: [], flags: [], commandString: [], viaShell: true },
18
+ watch: { valueFlags: ["--equexit", "--interval", "--shotsdir", "-n", "-q", "-s"], flags: ["--beep", "--chgexit", "--color", "--differences", "--errexit", "--exec", "--follow", "--help", "--no-color", "--no-rerun", "--no-title", "--no-wrap", "--precise", "--version", "-C", "-b", "-c", "-e", "-f", "-g", "-h", "-p", "-r", "-t", "-v", "-w", "-x"], commandString: [], viaShell: true },
19
19
  ionice: { valueFlags: ["--class", "--classdata", "--pgid", "--pid", "--uid", "-P", "-c", "-n", "-p", "-u"], flags: ["--help", "--ignore", "--version", "-V", "-h", "-t"], commandString: [] },
20
20
  setsid: { valueFlags: [], flags: ["--ctty", "--fork", "--help", "--version", "--wait", "-c", "-f", "-w"], commandString: [] },
21
21
  taskset: { valueFlags: [], flags: ["--all-tasks", "--cpu-list", "--help", "--pid", "--version", "-a", "-c", "-p"], commandString: [], positional: /^(?:0x)?[\da-f]+$|^[\d,:-]+$/i },
@@ -43,8 +43,8 @@ export const SHELL_WRAPPER_TABLE = {
43
43
  compgen: { valueFlags: [], flags: [], commandString: [], executes: ["-C", "-F"] },
44
44
  complete: { valueFlags: [], flags: [], commandString: [], executes: ["-C", "-F"] },
45
45
  busybox: { valueFlags: [], flags: [], commandString: [] },
46
- su: { valueFlags: [], flags: [], commandString: [], viaShell: true },
47
- runuser: { valueFlags: [], flags: [], commandString: [], viaShell: true },
46
+ su: { valueFlags: ["--group", "--shell", "--supp-group", "--whitelist-environment", "-G", "-g", "-s", "-w"], flags: ["--fast", "--help", "--login", "--no-pty", "--preserve-environment", "--pty", "--version", "-P", "-T", "-V", "-f", "-h", "-l", "-m", "-p"], commandString: ["--command", "--session-command", "-c"], viaShell: true },
47
+ runuser: { valueFlags: ["--group", "--shell", "--supp-group", "--user", "--whitelist-environment", "-G", "-g", "-s", "-u", "-w"], flags: ["--fast", "--help", "--login", "--no-pty", "--preserve-environment", "--pty", "--version", "-P", "-T", "-V", "-f", "-h", "-l", "-m", "-p"], commandString: ["--command", "--session-command", "-c"], viaShell: true },
48
48
  chroot: { valueFlags: [], flags: [], commandString: [], positional: /^/ },
49
49
  setpriv: { valueFlags: [], flags: [], commandString: [] },
50
50
  numactl: { valueFlags: [], flags: [], commandString: [] },
@@ -8,7 +8,8 @@ export const TASK_LIST_TOOL_NAME = "TaskList";
8
8
  export const TODO_WRITE_TOOL_NAME = "TodoWrite";
9
9
  export const REFRESH_MCP_TOOLS_TOOL_NAME = "RefreshMcpTools";
10
10
  export const ARTIFACT_TOOL_NAME = "Artifact";
11
- const SHELL_RULE_FACE = { primaryParams: ["command"], params: ["command", "timeout", "description", "run_in_background"] };
11
+ const shellRuleFace = (params) => ({ grammar: "command", primaryParams: ["command"], params });
12
+ const SHELL_RULE_FACE = shellRuleFace(["command", "timeout", "description", "run_in_background"]);
12
13
  const c = (contractId, implementationRevision = "1") => ({ contractId, implementationRevision });
13
14
  export const TOOL_CATALOG_ENTRIES = [
14
15
  {
@@ -108,7 +109,7 @@ export const TOOL_CATALOG_ENTRIES = [
108
109
  { id: "task-stop-hands", name: "TaskStop", source: "builtin", definedIn: "src/tools/fs/fs-bash.ts", mountedBy: ["hands"], cards: [], face: { family: "delegate", effect: "write", contract: TASK_STOP_CONTRACT } },
109
110
  { id: "task-output", name: "TaskOutput", source: "builtin", definedIn: "src/core/task-registry.ts", mountedBy: ["delegation"], cards: ["task-output"], face: { family: "delegate", effect: "read", contract: TASK_OUTPUT_CONTRACT } },
110
111
  { id: "task-stop", name: "TaskStop", source: "builtin", definedIn: "src/core/task-registry.ts", mountedBy: ["delegation"], cards: ["task-stop"], face: { family: "delegate", effect: "write", contract: TASK_STOP_CONTRACT } },
111
- { id: "monitor", name: "Monitor", source: "builtin", definedIn: "src/tools/monitor.ts", mountedBy: ["backgroundTasks"], cards: ["monitor-start", "readonly_out_of_root", "read_unestablished"], face: { family: "other", effect: "write", contract: c("core.monitor@1"), offloadThresholdChars: 10_000 } },
112
+ { id: "monitor", name: "Monitor", source: "builtin", definedIn: "src/tools/monitor.ts", mountedBy: ["backgroundTasks"], cards: ["monitor-start", "readonly_out_of_root", "read_unestablished"], face: { family: "other", effect: "write", contract: c("core.monitor@1"), ruleFace: shellRuleFace(["command", "description", "timeout_ms", "persistent"]), offloadThresholdChars: 10_000 } },
112
113
  { id: "send-message", name: "SendMessage", source: "builtin", definedIn: "src/agents/send-message-tool.ts", mountedBy: ["delegation", "hostInternals", "peerLane"], cards: ["send-message"], face: { family: "protocol", effect: "write", contract: c("core.send_message@1") } },
113
114
  { id: "agent-transcript", name: "AgentTranscript", source: "builtin", definedIn: "src/agents/agent-transcript-tool.ts", mountedBy: ["delegation"], cards: ["agent-transcript"], face: { family: "delegate", effect: "read", contract: c("core.agent_transcript@1") } },
114
115
  { id: "list-agents", name: "ListAgents", source: "builtin", definedIn: "src/agents/list-agents-tool.ts", mountedBy: ["delegation", "peerLane"], cards: ["list-agents"], face: { family: "protocol", effect: "read", aliases: ["ListPeers"], contract: c("core.list_agents@1") } },
@@ -201,7 +201,16 @@ export interface ToolFaceDeclaration {
201
201
  };
202
202
  aliases?: string[];
203
203
  pathTarget?: ToolPathTarget;
204
- ruleFace?: ToolRuleFace;
204
+ /**
205
+ * The rule face ({@link ToolRuleFace}) and, for a definition whose primary parameter is a shell command line, the grammar
206
+ * that judges it — `grammar: "command"`, the SHELL FACE. Every `Bash(<command>)` rule (the command grammar's one spelling,
207
+ * deny / ask / allow, from every source) is judged on a call that answers to the name of such a definition, against that
208
+ * parameter; a bare `Bash` rule stays a rule on the tool of that name. Only a catalog row declares it (a caller's
209
+ * `ToolSpec.ruleFace` is a {@link ToolRuleFace}): the static catalog is the one place the shell face is read from.
210
+ */
211
+ ruleFace?: ToolRuleFace & {
212
+ readonly grammar?: "command";
213
+ };
205
214
  renderHints?: ToolRenderHints;
206
215
  offload?: boolean;
207
216
  offloadThresholdChars?: number;
@@ -38,12 +38,13 @@ export declare const RULE_TOOL_NAME_VERDICTS: readonly ["current", "retired", "u
38
38
  * - `unprefixed_namespace` — carries the `__` namespace separator with no protocol prefix. The roster cannot clear it:
39
39
  * a caller tool name or alias carrying `__` is refused at prepare, every engine tool name is `__`-free, and every
40
40
  * protocol tool is minted with its prefix.
41
- * - `unmintable_segment` — carries a protocol prefix, but its peer segment (the server / remote agent) is not a spelling
42
- * the mint produces for any peer: the name is read as written (peers compare exactly), and every mounted tool carries
43
- * the minted spelling, so the name reaches none of them. `guidance` says so in one line; `currentName` is the whole name
44
- * rewritten with the spelling the mint produces (`mcp__github.com__*` → `mcp__github_com__*`), absent when the peer
45
- * segment is empty and there is nothing to rewrite. The tool segment after the peer is never judged: it is a literal
46
- * tool name or a glob over one, and an unmatched one is the audit's advisory.
41
+ * - `unmintable_segment` — carries a protocol prefix, but a segment of it is not one the mint produces: the peer segment
42
+ * (the server / remote agent) is empty or not a spelling the mint produces for any peer (the name is read as written —
43
+ * peers compare exactly — and every mounted tool carries the minted spelling, so the name reaches none of them), or the
44
+ * tool segment is EMPTY (a trailing separator, `mcp__srv__`: no tool is minted under an empty segment). `guidance` says
45
+ * so in one line; `currentName` is the whole name rewritten with the spelling the mint produces (`mcp__github.com__*` →
46
+ * `mcp__github_com__*`), absent when a segment is empty and there is nothing to rewrite. A tool segment that is not empty
47
+ * is never judged: it is a literal tool name or a glob over one, and an unmatched one is the audit's advisory.
47
48
  *
48
49
  * `roster` = the tool names (aliases included) the reader knows are mounted: a run's assembled roster at prepare, the
49
50
  * names a deployment DECLARES it mounts its own tools under at a session-rule store (an empty set when it declares none).
@@ -67,8 +68,8 @@ export type RuleToolNameVerdict = {
67
68
  * `undefined` for a name that is not namespaced (no protocol prefix). The peer is the first segment — EMPTY for `mcp____x` and
68
69
  * for the bare prefix, which name no peer; the tool segment is the REST of the split rejoined, so a tool whose minted segment
69
70
  * itself carries the separator (charset normalization maps `get..issue` → `get__issue`) decomposes back to one tool name, and
70
- * is absent when the name stops at the peer (`mcp__srv`). The one decomposition: the judgment, the covering matcher and the
71
- * allow-entry grammar all read a namespaced name through it.
71
+ * is absent when the name stops at the peer (`mcp__srv`) and EMPTY when it ends in a trailing separator (`mcp__srv__`). The
72
+ * one decomposition: the judgment, the covering matcher and the allow-entry grammar all read a namespaced name through it.
72
73
  */
73
74
  export declare function splitNamespacedName(name: string): {
74
75
  readonly ns: ProtocolNamespace;
@@ -56,6 +56,8 @@ export function splitNamespacedName(name) {
56
56
  function unmintableSegmentOf({ ns, peer, tool }) {
57
57
  if (peer === "")
58
58
  return { kind: "unmintable_segment", guidance: `the ${ns.id} name has an empty peer segment — no tool is minted under it` };
59
+ if (tool === "")
60
+ return { kind: "unmintable_segment", guidance: `the ${ns.id} name has an empty tool segment — no tool is minted under it; write ${ns.prefix}<peer> to cover every tool of a peer, or ${ns.prefix}<peer>${NAMESPACE_SEPARATOR}<tool> for one tool` };
59
61
  const minted = mintNamespacePrefix(ns, peer).slice(ns.prefix.length, -NAMESPACE_SEPARATOR.length);
60
62
  if (minted === peer)
61
63
  return undefined;