@sema-agent/core 7.40.1 → 7.41.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 (134) hide show
  1. package/CHANGELOG.md +188 -0
  2. package/dist/agents/agent-transcript-tool.d.ts +10 -2
  3. package/dist/agents/agent-transcript-tool.js +52 -26
  4. package/dist/agents/list-agents-tool.d.ts +17 -2
  5. package/dist/agents/list-agents-tool.js +28 -16
  6. package/dist/agents/peer-session-drain.d.ts +3 -1
  7. package/dist/agents/peer-session-drain.js +1 -0
  8. package/dist/agents/subagent-steps.d.ts +12 -2
  9. package/dist/agents/subagent-steps.js +8 -4
  10. package/dist/agents/subagent.d.ts +1 -17
  11. package/dist/agents/subagent.js +12 -24
  12. package/dist/core/agent-definition.d.ts +19 -40
  13. package/dist/core/agent-state.d.ts +91 -0
  14. package/dist/core/agent-state.js +79 -0
  15. package/dist/core/ask-origin.d.ts +28 -6
  16. package/dist/core/ask-origin.js +9 -5
  17. package/dist/core/ask-unresolvable-notice.d.ts +27 -4
  18. package/dist/core/ask-unresolvable-notice.js +8 -0
  19. package/dist/core/auto-promote.d.ts +14 -27
  20. package/dist/core/auto-promote.js +5 -5
  21. package/dist/core/checkpoint-store.d.ts +11 -12
  22. package/dist/core/decision-receipt.d.ts +5 -2
  23. package/dist/core/decision-receipt.js +1 -1
  24. package/dist/core/engine-notice.d.ts +7 -1
  25. package/dist/core/gate-crash-trace.d.ts +1 -1
  26. package/dist/core/gate-fold.js +7 -3
  27. package/dist/core/gate-lanes.js +22 -3
  28. package/dist/core/governance-codes.d.ts +3 -1
  29. package/dist/core/governance-codes.js +3 -0
  30. package/dist/core/hooks.d.ts +20 -6
  31. package/dist/core/hooks.js +28 -28
  32. package/dist/core/mcp-tool-meta.d.ts +24 -0
  33. package/dist/core/mcp-tool-meta.js +17 -0
  34. package/dist/core/mcp.d.ts +1 -6
  35. package/dist/core/mcp.js +6 -7
  36. package/dist/core/mode-policy.d.ts +25 -0
  37. package/dist/core/mode-policy.js +8 -0
  38. package/dist/core/protocol-table.d.ts +3 -0
  39. package/dist/core/protocol-table.js +3 -0
  40. package/dist/core/reminder-mint.d.ts +21 -1
  41. package/dist/core/reminder-mint.js +20 -0
  42. package/dist/core/retired-keys.d.ts +2 -1
  43. package/dist/core/retired-keys.js +13 -2
  44. package/dist/core/rule-denial-sentence.d.ts +2 -2
  45. package/dist/core/runner/call-hook-stations.d.ts +106 -0
  46. package/dist/core/runner/call-hook-stations.js +118 -0
  47. package/dist/core/runner/contracts.d.ts +35 -25
  48. package/dist/core/runner/gate-exit.d.ts +5 -1
  49. package/dist/core/runner/gate-exit.js +10 -6
  50. package/dist/core/runner/prepare-delegation-surface.d.ts +1 -0
  51. package/dist/core/runner/prepare-delegation-surface.js +4 -4
  52. package/dist/core/runner/prepare-gate-stations.d.ts +8 -3
  53. package/dist/core/runner/prepare-gate-stations.js +13 -70
  54. package/dist/core/runner/prepare-inherited-gate.d.ts +6 -3
  55. package/dist/core/runner/prepare-inherited-gate.js +5 -3
  56. package/dist/core/runner/prepare-park-ask.d.ts +4 -0
  57. package/dist/core/runner/prepare-park-ask.js +4 -1
  58. package/dist/core/runner/prepare-policy-chain.d.ts +6 -5
  59. package/dist/core/runner/prepare-policy-chain.js +14 -25
  60. package/dist/core/runner/prepare-project-context.d.ts +15 -16
  61. package/dist/core/runner/prepare-project-context.js +15 -11
  62. package/dist/core/runner/prepare-run-refs.d.ts +8 -8
  63. package/dist/core/runner/prepare-run-refs.js +3 -3
  64. package/dist/core/runner/prepare-safety-scan.js +3 -3
  65. package/dist/core/runner/prepare-suspend-saga.d.ts +2 -1
  66. package/dist/core/runner/prepare-suspend-saga.js +2 -1
  67. package/dist/core/runner/prepare-task.js +14 -12
  68. package/dist/core/runner/prepare-turn-wiring.d.ts +3 -1
  69. package/dist/core/runner/prepare-turn-wiring.js +16 -13
  70. package/dist/core/runner/resume-admission.js +2 -1
  71. package/dist/core/runner/resume-apply.js +12 -23
  72. package/dist/core/runner/resume-checkpoint-screen.js +3 -0
  73. package/dist/core/runner/run-leg.js +61 -47
  74. package/dist/core/runner/runtask.d.ts +1 -1
  75. package/dist/core/runner/session-rule-policy.d.ts +7 -19
  76. package/dist/core/runner/session-rule-policy.js +1 -2
  77. package/dist/core/runner/skill-base-dir.d.ts +10 -0
  78. package/dist/core/runner/skill-base-dir.js +4 -0
  79. package/dist/core/runner/synthetic-tools.d.ts +11 -17
  80. package/dist/core/runner/synthetic-tools.js +7 -8
  81. package/dist/core/safety-axis-vocab.d.ts +2 -2
  82. package/dist/core/safety-axis-vocab.js +2 -0
  83. package/dist/core/session-policy-store.d.ts +63 -11
  84. package/dist/core/session-policy-store.js +52 -6
  85. package/dist/core/skill-grant.d.ts +239 -0
  86. package/dist/core/skill-grant.js +313 -0
  87. package/dist/core/skills-directory.d.ts +5 -34
  88. package/dist/core/skills-directory.js +9 -58
  89. package/dist/core/task-registry-agent.d.ts +14 -2
  90. package/dist/core/task-registry-agent.js +23 -8
  91. package/dist/core/task-registry-shared.d.ts +6 -0
  92. package/dist/core/task-registry.d.ts +16 -0
  93. package/dist/core/task-registry.js +19 -6
  94. package/dist/core/task-spec.d.ts +10 -0
  95. package/dist/core/tool-face.d.ts +3 -2
  96. package/dist/core/tool-name-aliases.d.ts +38 -12
  97. package/dist/core/tool-name-aliases.js +16 -0
  98. package/dist/core/tool-name-readings.d.ts +6 -2
  99. package/dist/core/tool-name-readings.js +6 -4
  100. package/dist/core/tool-policy.d.ts +2 -2
  101. package/dist/core/tool-registry.d.ts +1 -4
  102. package/dist/core/tool-registry.js +0 -3
  103. package/dist/core/tool-spec.d.ts +11 -13
  104. package/dist/core/tools.d.ts +1 -1
  105. package/dist/core/trace.d.ts +15 -0
  106. package/dist/core/types.d.ts +1 -1
  107. package/dist/engine/harness/agent-harness.d.ts +12 -1
  108. package/dist/engine/harness/agent-harness.js +17 -13
  109. package/dist/engine/loop/agent-loop.d.ts +16 -1
  110. package/dist/engine/loop/agent-loop.js +9 -15
  111. package/dist/engine/loop/types.d.ts +2 -0
  112. package/dist/index.d.ts +9 -4
  113. package/dist/index.js +4 -2
  114. package/dist/stores/file/adoption/adopt.js +23 -16
  115. package/dist/stores/file/index.d.ts +8 -2
  116. package/dist/stores/file/index.js +2 -2
  117. package/dist/stores/file/mailbox-store.js +2 -2
  118. package/dist/stores/file/permission-rule-adopt.js +10 -1
  119. package/dist/stores/file/permission-rule-store.d.ts +6 -4
  120. package/dist/stores/file/permission-rule-store.js +127 -59
  121. package/dist/stores/file/session-policy-store.d.ts +86 -14
  122. package/dist/stores/file/session-policy-store.js +135 -118
  123. package/dist/stores/file/task-list-store.d.ts +5 -0
  124. package/dist/stores/file/task-list-store.js +18 -4
  125. package/dist/stores/file/usage-window-store.d.ts +9 -1
  126. package/dist/stores/file/usage-window-store.js +47 -33
  127. package/dist/tools/fs/safety.d.ts +1 -1
  128. package/package.json +1 -1
  129. package/test/export-surface.snapshot.json +45 -5
  130. package/wire-changes/core@7.41.0.json +1865 -0
  131. package/dist/core/runner/active-skill-scope.d.ts +0 -58
  132. package/dist/core/runner/active-skill-scope.js +0 -133
  133. package/dist/core/skill-tool-specifier.d.ts +0 -80
  134. package/dist/core/skill-tool-specifier.js +0 -57
@@ -2,25 +2,13 @@ import type { ExecutionEnv } from "../../internal/harness-types.js";
2
2
  import type { ToolEffect } from "../types.js";
3
3
  import { type NamedToolPolicy } from "../tool-policy.js";
4
4
  import { type SessionPermissionRules, type SessionPolicyStore, type StoredSessionRules } from "../session-policy-store.js";
5
- /** Tool names whose write target is the `file_path`/`path` arg ({@link fileArgPath}).
6
- *
7
- * REF-B3 — the ONE definition of that narrower set. Production consumer today:
8
- * `createActiveSkillScopePolicy` (runner/active-skill-scope.ts, manifest `allowPaths`). The
9
- * `allowDirs`/fs-write faces consume the superset {@link PATH_CONFINABLE_WRITE_TOOLS} instead.
10
- *
11
- * BOUNDARY: this set stays `file_path`-keyed — its members' write target is read by {@link fileArgPath}.
12
- * `NotebookEdit` (whose target is `notebook_path`) lives in the superset
13
- * {@link PATH_CONFINABLE_WRITE_TOOLS} below, NOT here: `createActiveSkillScopePolicy` reads THIS set and
14
- * deliberately keeps fail-closed-denying notebooks under a manifest `allowPaths` fence (widening the
15
- * skill face is a separate capability decision). */
16
- export declare const PATH_WRITE_TOOLS: ReadonlySet<string>;
17
- /** The FULL path-confinable write-tool set: {@link PATH_WRITE_TOOLS} plus `NotebookEdit`, whose write
18
- * target is `notebook_path` and is extracted by the shared tool-aware {@link writeTargetPath} (
19
- * notebook_path outranks a decoy `file_path` for that tool).
5
+ /** The FULL path-confinable write-tool set: every catalog tool declaring a write path target — `Write` / `Edit`
6
+ * (target `file_path`) and `NotebookEdit`, whose write target is `notebook_path` and is extracted by the shared
7
+ * tool-aware {@link writeTargetPath} (notebook_path outranks a decoy `file_path` for that tool).
20
8
  *
21
9
  * REF-B3 — the ONE spelling of "tools this codebase can confine by path". Consumers:
22
10
  * {@link createSessionRulePolicy} (below, `allowDirs`) and `createFsWriteGatePolicy`
23
- * (core/fs-write-gate-policy.ts), which used to rebuild `[...PATH_WRITE_TOOLS, "NotebookEdit"]` inline.
11
+ * (core/fs-write-gate-policy.ts), which used to rebuild `["Write", "Edit", "NotebookEdit"]` inline.
24
12
  * A fourth spelling survives as a LITERAL in core/tool-policy.ts (the transcript-dir gate) because THIS
25
13
  * module imports tool-policy.ts — that one is a documented coordinate held in sync by a source-scan pin
26
14
  * (SW-10), not a silent copy.
@@ -38,10 +26,10 @@ export declare const PATH_CONFINABLE_WRITE_TOOLS: ReadonlySet<string>;
38
26
  * remote and of the OTHER family than the host).
39
27
  *
40
28
  * REF-B3 — exported as the single source for the policy modules that fence WRITES by an allowlist of
41
- * directories: active-skill-scope.ts (manifest `allowPaths`) and fs-write-gate-policy.ts (`acceptDirs`
29
+ * directories: fs-write-gate-policy.ts (`acceptDirs`
42
30
  * /`exemptDirs`), plus the K-PLATFORM sweep tests. The line here used to read "exported ONLY for the
43
31
  * K-PLATFORM sweep tests", which was already false when written (fs-write-gate-policy.ts imports it in
44
- * production), and active-skill-scope.ts carried a byte-identical private twin — comment included — so
32
+ * production), and a since-retired skill fence carried a byte-identical private twin — comment included — so
45
33
  * a platform-family fix had to be remembered at two coordinates.
46
34
  *
47
35
  * The sibling implementation is `within()` in tools/fs/safety.ts (the fs-tool root fence). Those stay
@@ -125,7 +113,7 @@ export declare function createLiveSessionRules(store: SessionPolicyStore, floor:
125
113
  * listed and the un-parseable (a parseable command absent from the deny list stays allowed).
126
114
  * - `allowDirs` (if set) → a WRITE tool must land inside one of the dirs (resolved via `canonicalizeTarget`,
127
115
  * so a symlink can't carry across a write out); a write-capable tool that cannot be path-confined (e.g. `bash`)
128
- * is DENIED while `allowDirs` is set (fail-closed, mirrors active-skill-scope MAJOR-2).
116
+ * is DENIED while `allowDirs` is set (fail-closed).
129
117
  * Returns `allow` when nothing constrains the call. `env`/`rootPath` are the task's real exec env + root, so
130
118
  * matching sees the actual filesystem (a remote/E2B task resolves in its container). A relative
131
119
  * TARGET prefers the call's live tracked cwd ({@link ToolCallRequest.cwd}) when the engine stamps one;
@@ -5,9 +5,8 @@ import { thrownText } from "../thrown-value.js";
5
5
  import { heldBy, wireOf } from "../tool-name-readings.js";
6
6
  import { ruleDenialSentence } from "../rule-denial-sentence.js";
7
7
  import { DEFAULT_GATE_AWAIT_TIMEOUT_MS, gateWaitFailureOf, withGateTimeout } from "../gate-bounded-await.js";
8
- import { declaredPathTargetOf, isProtectedWrite, pathConfinableWriteToolNames, skillScopeWriteToolNames } from "../tool-registry.js";
8
+ import { declaredPathTargetOf, isProtectedWrite, pathConfinableWriteToolNames } from "../tool-registry.js";
9
9
  import { pathTargetBaseOf } from "../effective-path-target.js";
10
- export const PATH_WRITE_TOOLS = skillScopeWriteToolNames();
11
10
  export const PATH_CONFINABLE_WRITE_TOOLS = pathConfinableWriteToolNames();
12
11
  export function isWithin(root, p) {
13
12
  if (!root)
@@ -48,6 +48,16 @@ export declare function substituteSkillRoot(text: string, baseDir: string): stri
48
48
  * receive rather than what the caller typed. Equal to `text.length` when no directory is stated, and the one place the seat's value is SCREENED
49
49
  * ({@link skillBaseDirOf}) — the gate that budgets on this number is the first reader of every skill on every leg. */
50
50
  export declare function substitutedSkillLength(text: string, baseDir: unknown): number;
51
+ /** The load gate's ONE reading: does a leg LOAD this skill — is the body it would deliver ({@link substitutedSkillLength}) within
52
+ * `limit`? Prepare's gate keeps the skills that load (announcing the rest), and a parked Skill approval's binding reads a
53
+ * skill that does not load as absent — so the binding and the activation judge one view. */
54
+ export declare function skillLoadReading(skill: {
55
+ readonly content: string;
56
+ readonly baseDir?: unknown;
57
+ }, limit: number): {
58
+ readonly loads: boolean;
59
+ readonly delivered: number;
60
+ };
51
61
  export declare function substituteSkillRootBounded(text: string, baseDir: string, maxChars: number): {
52
62
  text: string;
53
63
  totalChars: number;
@@ -19,6 +19,10 @@ export function substitutedSkillLength(text, baseDir) {
19
19
  return text.length;
20
20
  return substituteSkillRootBounded(text, dir, 0).totalChars;
21
21
  }
22
+ export function skillLoadReading(skill, limit) {
23
+ const delivered = substitutedSkillLength(skill.content, skill.baseDir);
24
+ return { loads: delivered <= limit, delivered };
25
+ }
22
26
  export function substituteSkillRootBounded(text, baseDir, maxChars) {
23
27
  const parts = [];
24
28
  let totalChars = 0;
@@ -1,9 +1,8 @@
1
1
  import { type TSchema } from "typebox";
2
2
  import type { AgentTool } from "../../internal/harness-types.js";
3
- import type { SkillManifest } from "../types.js";
4
3
  import type { TerminalBlockedDetail } from "../terminal-cause.js";
5
4
  import { type ResolvedSkillListingBudget } from "../skill-listing-budget.js";
6
- import type { ActiveSkillScope } from "./active-skill-scope.js";
5
+ import { type SkillGrantTable } from "../skill-grant.js";
7
6
  /** Reserved name of the injected structured-output tool (CC 2.1.187 `StructuredOutput`; batch-A rename — old
8
7
  * "submit_output" is canonicalized for pre-rename durable state). */
9
8
  export declare const OUTPUT_TOOL_NAME = "StructuredOutput";
@@ -71,8 +70,9 @@ interface SkillLike {
71
70
  name: string;
72
71
  description: string;
73
72
  content: string;
74
- /** Optional Gate-3 runtime capability manifest — carried through so a successful `skill` load can push its scope frame; skills without one stay unscoped. */
75
- manifest?: SkillManifest;
73
+ /** The skill's two lists (`SkillSpec.allowedTools` / `disallowedTools`) — carried so a successful activation writes its rows into the run's grant table. */
74
+ allowedTools?: string[];
75
+ disallowedTools?: string[];
76
76
  /** Optional attachments (SkillSpec.files): paths in the block, delivered inline on invoke under {@link SKILL_ATTACHMENTS_MAX_CHARS}. Same caller-trusted
77
77
  * contract as `content` — core never writes them to disk (deployment's half). */
78
78
  files?: Array<{
@@ -110,7 +110,7 @@ export interface SkillListingEntry {
110
110
  * and the tool, so what the prompt promises is exactly what the tool serves. Each field is READ ONCE, BY NAME, and
111
111
  * both consumers see the copy: the gate screens this list while the invoke substitutes from it, so a `get baseDir()`
112
112
  * answering those two reads differently passed a screen that measured a value the model never received — and a rest
113
- * copy would silently drop an INHERITED or non-enumerable `manifest`, i.e. widen a skill's capability restriction. */
113
+ * copy would silently drop an INHERITED or non-enumerable list, i.e. lose a skill's grant or its refusals. */
114
114
  export declare function normalizeSkills(skills: SkillLike[] | undefined): SkillLike[];
115
115
  /** [c209-C] The skills listing's lead line — single-sourced so the run loop's durable-resume
116
116
  * transcript probe (MED-3② skills arm, runtask) matches EXACTLY what the initial frame carries
@@ -167,17 +167,11 @@ export declare function renderSkillLinesWithin(skills: ReadonlyArray<SkillListin
167
167
  /**
168
168
  * The injected `skill` tool: returns a skill's full body as a tool result.
169
169
  *
170
- * When a {@link ActiveSkillScope} is supplied (Gate-3) and the loaded skill carries a
171
- * `manifest`, a successful load PUSHES a scope frame onto the per-task LIFO stack, so the rest of the
172
- * invocation is deny-narrowed to the manifest's tools/paths. Nested loads (a skill whose body loads
173
- * another skill) STACK and compose MONOTONICALLY as the INTERSECTION of every active frame's
174
- * `allowTools` — a child can only narrow further, never re-grant. Frames are popped at task end (and
175
- * the test/lifecycle may pop explicitly via `scope.pop()` to model a skill frame closing). Skills with
176
- * NO manifest push nothing (unscoped, backward-compatible).
177
- *
178
- * v1 lifetime is heuristic (a residual): the model controls its own turns, so push-on-load
179
- * is the trigger and the worst case (a frame never popped) is simply continued narrowing — never a
180
- * widening, so it stays safe-by-construction.
170
+ * A SUCCESSFUL activation writes the skill's two lists into the run's grant table (`grants`, origin `model`): its
171
+ * `allowed-tools` then run without asking and its `disallowed-tools` are refused for the rest of the run; an unknown skill
172
+ * throws first and writes nothing. Activating a skill that declares a list was itself a question (the gate reads this
173
+ * tool's seat off the call's arguments — `Execute skill: <name>`), so a write here always follows a statement. The tool
174
+ * runs ALONE in its batch: a call after it in the same message is judged after the rows exist.
181
175
  */
182
- export declare function createSkillTool(skills: SkillLike[], scope?: ActiveSkillScope): AgentTool;
176
+ export declare function createSkillTool(skills: SkillLike[], grants?: SkillGrantTable): AgentTool;
183
177
  export {};
@@ -2,6 +2,7 @@ import { Type } from "typebox";
2
2
  import { defineTool } from "../tools.js";
3
3
  import { sanitizeUntrustedText, SHELLED_BODY_ENVELOPE_TAGS } from "../untrusted-text.js";
4
4
  import { SKILL_LISTING_DEFAULT_BUDGET, SKILL_LISTING_DESC_MAX_CHARS_DEFAULT } from "../skill-listing-budget.js";
5
+ import { skillNamedBy } from "../skill-grant.js";
5
6
  import { toolFace } from "../tool-catalog-entries.js";
6
7
  import { substituteSkillRoot, substituteSkillRootBounded } from "./skill-base-dir.js";
7
8
  import { foldListingWithin } from "./listing-fold.js";
@@ -113,9 +114,9 @@ export function normalizeSkills(skills) {
113
114
  for (const s of skills ?? []) {
114
115
  if (!seen.has(s.name)) {
115
116
  seen.add(s.name);
116
- const { name, description, content, manifest, files, baseDir, ...rest } = s;
117
+ const { name, description, content, allowedTools, disallowedTools, files, baseDir, ...rest } = s;
117
118
  void rest;
118
- out.push({ name, description, content, ...(manifest !== undefined ? { manifest } : {}), ...(files !== undefined ? { files } : {}), ...(baseDir !== undefined ? { baseDir } : {}) });
119
+ out.push({ name, description, content, ...(allowedTools !== undefined ? { allowedTools: [...allowedTools] } : {}), ...(disallowedTools !== undefined ? { disallowedTools: [...disallowedTools] } : {}), ...(files !== undefined ? { files } : {}), ...(baseDir !== undefined ? { baseDir } : {}) });
119
120
  }
120
121
  }
121
122
  return out.toSorted((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
@@ -158,12 +159,12 @@ export function renderSkillLinesWithin(skills, opts) {
158
159
  frame: opts.frame,
159
160
  });
160
161
  }
161
- export function createSkillTool(skills, scope) {
162
- const byName = new Map(skills.map((s) => [s.name, s]));
162
+ export function createSkillTool(skills, grants) {
163
163
  const names = skills.map((s) => s.name).join(", ");
164
164
  return defineTool({
165
165
  name: SKILL_TOOL_NAME,
166
166
  ...toolFace("skill"),
167
+ executionMode: "sequential",
167
168
  description: `Invoke a skill.\n\n` +
168
169
  `A skill is a packaged set of instructions the user or project has set up for a particular kind of task (deploy steps, a review checklist, a repo-specific workflow). ` +
169
170
  `Available skills appear in a system-reminder listing with one-line descriptions. ` +
@@ -181,15 +182,13 @@ export function createSkillTool(skills, scope) {
181
182
  execute: (rawArgs) => {
182
183
  const a = (rawArgs ?? {});
183
184
  const name = String(a.skill ?? "");
184
- const skill = byName.get(name);
185
+ const skill = skillNamedBy(a, skills);
185
186
  if (!skill) {
186
187
  throw new Error(`Unknown skill "${name}". Available skills: ${names}. ` +
187
188
  `(Only skills registered with this deployment are loadable — other products' skills directories are not read. ` +
188
189
  `If the user wants one of those here, read its source file and recreate its content for this deployment instead.)`);
189
190
  }
190
- if (scope && skill.manifest) {
191
- scope.push({ kind: "manifest", manifest: skill.manifest });
192
- }
191
+ grants?.activate(skill, "model");
193
192
  const baseDir = skill.baseDir;
194
193
  let body = baseDir !== undefined ? substituteSkillRoot(skill.content, baseDir) : skill.content;
195
194
  if (skill.files !== undefined && skill.files.length > 0) {
@@ -31,7 +31,7 @@ export declare const SAFETY_AXIS_VOCABULARY: {
31
31
  readonly permissionDecision: readonly ["allow", "ask", "deny"];
32
32
  };
33
33
  /** The mark tighten's sentence — the ONE spelling of each safety-mark frame (an egress-marked tool = an external write; an
34
- * always-irreversible tool), read by the gate's two tighten arms (`gate-fold.ts`) and by the inherited lane's card for a
34
+ * always-irreversible tool; a tool whose server declared that a call must be put to a person), read by the gate's two tighten arms (`gate-fold.ts`) and by the inherited lane's card for a
35
35
  * marked tool (`judgeInheritedClassifier`): a person who answers a card for a marked call is told what the mark means,
36
36
  * whichever lane minted the card. */
37
- export declare function markTightenMessage(toolName: string, mark: "egress" | "irreversible"): string;
37
+ export declare function markTightenMessage(toolName: string, mark: "egress" | "irreversible" | "interaction"): string;
@@ -10,6 +10,8 @@ export const SAFETY_AXIS_VOCABULARY = {
10
10
  const _toolEffectExhaustive = true;
11
11
  void _toolEffectExhaustive;
12
12
  export function markTightenMessage(toolName, mark) {
13
+ if (mark === "interaction")
14
+ return `tool "${toolName}" asks for a person's confirmation (server-declared) — explicit approval required`;
13
15
  return mark === "egress"
14
16
  ? `tool "${toolName}" performs an external write (egress) — explicit approval required`
15
17
  : `tool "${toolName}" performs an irreversible action — explicit approval required`;
@@ -19,18 +19,19 @@
19
19
  * - **NOT A SANDBOX**: command rules inherit the coarse argv[0] matching (wrappers like `sh -c`/`sudo` circumvention);
20
20
  * the real isolation boundary is the executionEnv, never these rules.
21
21
  */
22
+ import { type RuleToolNameVerdict } from "./tool-name-aliases.js";
22
23
  /** The structured per-session rules. All optional; an absent field imposes no constraint of that kind. */
23
24
  export interface SessionPermissionRules {
24
25
  /** If set, ONLY these tool names are allowed (others denied). A narrowing allowlist. */
25
26
  toolAllow?: string[];
26
27
  /** Tool names always denied (deny wins). */
27
28
  toolDeny?: string[];
28
- /** If set, WRITE tools (`write_file`/`edit_file`) may only target paths within these dirs; a write-capable
29
- * tool that can't be path-confined (e.g. `bash`) is denied while this is set. Stored RAW; resolved at run time. */
29
+ /** If set, WRITE tools (`Write`/`Edit`) may only target paths within these dirs; a write-capable
30
+ * tool that can't be path-confined (e.g. `Bash`) is denied while this is set. Stored RAW; resolved at run time. */
30
31
  allowDirs?: string[];
31
- /** If set, ONLY these `bash` command names (argv[0]) are allowed. */
32
+ /** If set, ONLY these `Bash` command names (argv[0]) are allowed. */
32
33
  commandAllow?: string[];
33
- /** `bash` command names (argv[0]) always denied. */
34
+ /** `Bash` command names (argv[0]) always denied. */
34
35
  commandDeny?: string[];
35
36
  }
36
37
  /** {@link SessionPermissionRules} plus the monotonic `rev` the store stamps (the OCC key). */
@@ -51,13 +52,54 @@ export interface PutRulesOptions {
51
52
  * this from a verified, role-checked principal (mirrors `steer.trusted`). */
52
53
  operator?: boolean;
53
54
  }
55
+ /** The {@link SessionPolicyError.detail} of a tool-name refusal: which bucket, which entry, the judgment's word, and the
56
+ * 1:1 successor when the retired name has one. */
57
+ export interface SessionRuleNameRefusal {
58
+ field: SessionRuleToolNameField;
59
+ name: string;
60
+ verdict: Exclude<RuleToolNameVerdict["kind"], "current">;
61
+ currentName?: string;
62
+ }
54
63
  /** Typed error from {@link SessionPolicyStore.putRules}. `conflict` = stale `expectedRev`; `loosen_forbidden`
55
64
  * = a non-operator write tried to relax the rules; `corrupt` = a backend could not read the erase-surviving
56
- * rev mark ({@link SessionPolicyStore.deleteBySession}) and refuses rather than stamp an unproven rev. */
65
+ * rev mark ({@link SessionPolicyStore.deleteBySession}) and refuses rather than stamp an unproven rev; the fourth
66
+ * code = a tool-name bucket names a retired or prefix-less name ({@link assertSessionRuleNamesCurrent}) — `detail` is
67
+ * present on that code and only on it. */
57
68
  export declare class SessionPolicyError extends Error {
58
- readonly code: "conflict" | "loosen_forbidden" | "corrupt";
59
- constructor(code: "conflict" | "loosen_forbidden" | "corrupt", message: string);
69
+ readonly code: "conflict" | "loosen_forbidden" | "corrupt" | "legacy_tool_name";
70
+ readonly detail?: SessionRuleNameRefusal | undefined;
71
+ constructor(code: "conflict" | "loosen_forbidden" | "corrupt" | "legacy_tool_name", message: string, detail?: SessionRuleNameRefusal | undefined);
72
+ }
73
+ /** Which buckets hold TOOL names (the ones a run's policy audit reads as names); a new bucket is given a role here. */
74
+ declare const HOLDS_TOOL_NAMES: {
75
+ readonly toolAllow: true;
76
+ readonly toolDeny: true;
77
+ readonly allowDirs: false;
78
+ readonly commandAllow: false;
79
+ readonly commandDeny: false;
80
+ };
81
+ type SessionRuleToolNameField = {
82
+ [K in keyof typeof HOLDS_TOOL_NAMES]: (typeof HOLDS_TOOL_NAMES)[K] extends true ? K : never;
83
+ }[keyof typeof HOLDS_TOOL_NAMES];
84
+ /** Construction options both bundled stores take. */
85
+ export interface SessionPolicyStoreOptions {
86
+ /** The names this deployment mounts its OWN tools under — the roster the store judges a rule's tool names against
87
+ * ({@link judgeRuleToolName}): a retired name declared here is an ordinary name to the write and to the loosening
88
+ * check alike. Absent = none. A name carrying `__` is refused, as it is for a deployment tool. */
89
+ mountedToolNames?: Iterable<string>;
60
90
  }
91
+ /** A store's declared roster from {@link SessionPolicyStoreOptions.mountedToolNames} — the one reader both bundled stores
92
+ * (and so `FileStorageBackendOptions.sessionPolicy`) construct through, refusing loudly when it is not a list of plain
93
+ * tool names (a bare string would iterate by character). */
94
+ export declare function readMountedToolNames(value: unknown): ReadonlySet<string>;
95
+ /**
96
+ * The write judge for session rules, against `mountedToolNames` (the store's declaration; empty when it has none): every
97
+ * entry of a tool-name bucket must be `current` ({@link judgeRuleToolName}), or the write is refused (the fourth
98
+ * {@link SessionPolicyError} code) — such a name matches no tool, and a run's policy audit refuses to start with it.
99
+ * Operator writes included. Both bundled stores call it after normalizing and before {@link loosenReasons}; a durable
100
+ * backend calls it at the same point. The message names the entry and the way out, never a storage location.
101
+ */
102
+ export declare function assertSessionRuleNamesCurrent(rules: SessionPermissionRules, mountedToolNames?: ReadonlySet<string>): void;
61
103
  /**
62
104
  * The seam a deployment implements for DURABLE, cross-replica per-session rules. Unset on `RunnerDeps` ⇒ the
63
105
  * feature is OFF (no rules read, zero behavior change). Keyed on `(sessionId, principal-owner)`: core passes
@@ -72,6 +114,10 @@ export interface SessionRulesRecord {
72
114
  }
73
115
  export interface SessionPolicyStore {
74
116
  getRules(sessionId: string, principal?: string): Promise<StoredSessionRules | null>;
117
+ /** Write the rules for a key: CAS on `expectedRev`, then the name judge ({@link assertSessionRuleNamesCurrent} —
118
+ * operator writes included), then, for a non-operator write over an existing row, the
119
+ * tighten-only check ({@link loosenReasons}) — both against the names the deployment declares it mounts its own
120
+ * tools under. */
75
121
  putRules(sessionId: string, principal: string | undefined, rules: SessionPermissionRules, opts?: PutRulesOptions): Promise<StoredSessionRules>;
76
122
  /**
77
123
  * 2c session-sync: every (principal, rules) record for `sessionId` across ALL principals — `getRules`
@@ -126,11 +172,14 @@ declare function lexWithin(root: string, p: string): boolean;
126
172
  export declare function normalizeRules(rules: SessionPermissionRules): SessionPermissionRules;
127
173
  /**
128
174
  * Pure tighten-only diff: the reasons `next` LOOSENS `prior` (empty ⇒ `next` is a pure tightening or no-op).
129
- * Direction per field — deny-lists: removing an entry loosens. allowlists: removing the list (→ everything
130
- * allowed) or adding an entry outside the prior set loosens; prior-undefined → any defined next only narrows.
131
- * allowDirs: removing confinement, or adding a dir not within some prior dir, loosens (coarse raw containment).
175
+ * Direction per field — deny-lists: removing an entry loosens, except a `toolDeny` entry that is not `current` against
176
+ * `mountedToolNames` ({@link judgeRuleToolName}: it matches no tool, so removing it relaxes nothing — the way a session's owner
177
+ * takes out a stale entry that stops every run). allowlists: removing the list (→ everything allowed) or adding an
178
+ * entry outside the prior set loosens; prior-undefined → any defined next only narrows. allowDirs: removing
179
+ * confinement, or adding a dir not within some prior dir, loosens (coarse raw containment). `mountedToolNames` = the
180
+ * store's declaration ({@link SessionPolicyStoreOptions.mountedToolNames}; empty when it has none) — the write judge's too.
132
181
  */
133
- export declare function loosenReasons(prior: SessionPermissionRules, next: SessionPermissionRules): string[];
182
+ export declare function loosenReasons(prior: SessionPermissionRules, next: SessionPermissionRules, mountedToolNames?: ReadonlySet<string>): string[];
134
183
  /**
135
184
  * Does row `a` belong to the SAME lineage as row `b` — the one question every reader asks before it compares
136
185
  * two rows' `rev`, asked in ONE place so a `gen` that is ABSENT cannot mean three different things at three
@@ -158,6 +207,9 @@ export declare function stripRev(rules: SessionPermissionRules): SessionPermissi
158
207
  */
159
208
  export declare class InMemorySessionPolicyStore implements SessionPolicyStore {
160
209
  private readonly map;
210
+ /** {@link SessionPolicyStoreOptions.mountedToolNames}, read once at construction. */
211
+ private readonly mountedToolNames;
212
+ constructor(opts?: SessionPolicyStoreOptions);
161
213
  /**
162
214
  * The per-key mark {@link SessionPolicyStore.deleteBySession} may not erase: the largest `rev` this key has
163
215
  * ever been stamped with, kept outside the row so removing the row cannot roll the counter back. Written by
@@ -1,13 +1,52 @@
1
1
  import { posix } from "node:path";
2
2
  import { isWinFormPath } from "../tools/fs/safety.js";
3
+ import { NAMESPACED_NAME_SHAPES, carriesNamespaceSeparator } from "./protocol-table.js";
4
+ import { judgeRuleToolName } from "./tool-name-aliases.js";
3
5
  export class SessionPolicyError extends Error {
4
6
  code;
5
- constructor(code, message) {
7
+ detail;
8
+ constructor(code, message, detail) {
6
9
  super(message);
7
10
  this.code = code;
11
+ this.detail = detail;
8
12
  this.name = "SessionPolicyError";
9
13
  }
10
14
  }
15
+ const HOLDS_TOOL_NAMES = { toolAllow: true, toolDeny: true, allowDirs: false, commandAllow: false, commandDeny: false };
16
+ const TOOL_NAME_FIELDS = Object.keys(HOLDS_TOOL_NAMES).filter((f) => HOLDS_TOOL_NAMES[f]);
17
+ const NO_DECLARED_TOOLS = new Set();
18
+ export function readMountedToolNames(value) {
19
+ if (value === undefined)
20
+ return NO_DECLARED_TOOLS;
21
+ const refuse = (why) => {
22
+ throw new TypeError(`session rules store option mountedToolNames ${why} — it lists the names this deployment mounts its own tools under`);
23
+ };
24
+ if (typeof value === "string" || typeof value?.[Symbol.iterator] !== "function")
25
+ refuse("must be an iterable of tool names (an array or a Set), not a single value");
26
+ const names = new Set();
27
+ for (const n of value) {
28
+ if (typeof n !== "string" || n.length === 0)
29
+ refuse(`holds ${JSON.stringify(n) ?? String(n)}, which is not a non-empty tool name`);
30
+ if (carriesNamespaceSeparator(n))
31
+ refuse(`holds ${JSON.stringify(n)}: "__" is reserved for protocol tool names and no deployment tool may carry it`);
32
+ names.add(n);
33
+ }
34
+ return names;
35
+ }
36
+ export function assertSessionRuleNamesCurrent(rules, mountedToolNames = NO_DECLARED_TOOLS) {
37
+ for (const field of TOOL_NAME_FIELDS) {
38
+ for (const name of rules[field] ?? []) {
39
+ const verdict = judgeRuleToolName(name, mountedToolNames);
40
+ if (verdict.kind === "current")
41
+ continue;
42
+ const quoted = JSON.stringify(name);
43
+ const successor = verdict.kind === "retired" ? verdict.currentName : undefined;
44
+ throw new SessionPolicyError("legacy_tool_name", verdict.kind === "retired"
45
+ ? `session rules ${field} names ${quoted}, a retired tool name (${verdict.guidance}) — with it in place no run of this session could start, so nothing was written.${successor !== undefined ? ` Use the current name ${JSON.stringify(successor)}.` : ""} If this deployment mounts its own tool under ${quoted}, declare it in the store's mountedToolNames.`
46
+ : `session rules ${field} names ${quoted}, which carries the "__" namespace separator but no protocol prefix — no tool is named that way (protocol tools are named ${NAMESPACED_NAME_SHAPES}), and with it in place no run of this session could start, so nothing was written.`, { field, name, verdict: verdict.kind, ...(successor !== undefined ? { currentName: successor } : {}) });
47
+ }
48
+ }
49
+ }
11
50
  function lexWithin(root, p) {
12
51
  const fold = (x) => {
13
52
  let n = isWinFormPath(x) ? x.replace(/\\/g, "/") : x;
@@ -35,13 +74,15 @@ export function normalizeRules(rules) {
35
74
  out.allowDirs = rules.allowDirs.map((d) => posix.normalize(d).replace(/\/+$/, "") || "/");
36
75
  return out;
37
76
  }
38
- export function loosenReasons(prior, next) {
77
+ export function loosenReasons(prior, next, mountedToolNames = NO_DECLARED_TOOLS) {
39
78
  const reasons = [];
40
79
  for (const field of ["toolDeny", "commandDeny"]) {
41
80
  const n = new Set(next[field] ?? []);
42
- for (const x of prior[field] ?? [])
43
- if (!n.has(x))
44
- reasons.push(`removed ${field} "${x}"`);
81
+ for (const x of prior[field] ?? []) {
82
+ if (n.has(x) || (field === "toolDeny" && judgeRuleToolName(x, mountedToolNames).kind !== "current"))
83
+ continue;
84
+ reasons.push(`removed ${field} "${x}"`);
85
+ }
45
86
  }
46
87
  for (const field of ["toolAllow", "commandAllow"]) {
47
88
  const p = prior[field];
@@ -79,6 +120,10 @@ export function stripRev(rules) {
79
120
  }
80
121
  export class InMemorySessionPolicyStore {
81
122
  map = new Map();
123
+ mountedToolNames;
124
+ constructor(opts) {
125
+ this.mountedToolNames = readMountedToolNames(opts?.mountedToolNames);
126
+ }
82
127
  highWater = new Map();
83
128
  key(sessionId, principal) {
84
129
  return JSON.stringify([sessionId, principal ?? null]);
@@ -94,8 +139,9 @@ export class InMemorySessionPolicyStore {
94
139
  throw new SessionPolicyError("conflict", `session rules rev mismatch (expected ${opts.expectedRev}, have ${prior?.rev ?? 0})`);
95
140
  }
96
141
  const clean = normalizeRules(stripRev(rules));
142
+ assertSessionRuleNamesCurrent(clean, this.mountedToolNames);
97
143
  if (prior && opts?.operator !== true) {
98
- const loosens = loosenReasons(prior, clean);
144
+ const loosens = loosenReasons(prior, clean, this.mountedToolNames);
99
145
  if (loosens.length > 0) {
100
146
  throw new SessionPolicyError("loosen_forbidden", `a non-operator session-rule write may only tighten; refused: ${loosens.join("; ")}`);
101
147
  }