@sema-agent/core 7.51.1 → 7.51.2

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.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,49 @@
1
+ ## 7.51.2 — 2026-10-14
2
+
3
+ ### A stored session-rules row carries the rule grammar it was written in; a row from before keeps the reading it had (#1761; security; behavior narrowing open to veto; hard-breaking: none; closed sets: +1 member `AllowEntryInertReason` `earlier_grammar`; exports: +1 const, +1 function, +1 type) (@server)
4
+
5
+ **Behavior narrowing (open to veto).** A rules row written before this version has no grammar stamp. In such a row, a parenthesized `toolAllow` entry **no longer admits anything**. That covers `Tool(content)` (`Bash(rm:*)`, `Edit(src/**)`, `WebFetch(domain:x)`) and also `Tool(*)` / `Tool()`, which the entry reader reads as the bare tool. That was the entry's meaning when it was written (7.50.x compared session entries as tool names as written, so `Bash(rm:*)` and `Bash(*)` named no tool), and since 7.51.0 the same stored string had been read as an allow rule, or for `Bash(*)` as the whole shell. Upgrading turned it into a grant with no signal: the real Runner lane ran `rm` under a 7.50.x row whose allowlist admitted nothing. Rows written by 7.51.0 / 7.51.1 carry no stamp either, so they read the same way until rewritten.
6
+
7
+ - **What changed.**
8
+ - **The stamp.** `judgeSessionRulesWrite` is the one write judge every store calls. It now returns the rules with `grammar: SESSION_RULES_GRAMMAR` (`1`). Both bundled stores, and a durable twin that stores what the judge returns, keep the stamp on the row with no change of their own. `StoredSessionRules.grammar?: number` is new.
9
+ - **The reading.** `readSessionRulesShape` is the one shape reading, and every station reads through it: the write, a store's read-back, the tighten comparison's prior, the run-time layer and a parked checkpoint's ancestor snapshot. It now also answers the grammar, as `rules.grammar`, always present. A value carrying a store's `rev` and no stamp is grammar 0. A value no store stamped (no `rev`) is written now, in this engine's grammar.
10
+ - **Grammar 0.** An allow-direction entry whose row the one entry reader reads as speaking for a name other than the entry as written reads as nothing, with reason `earlier_grammar`. That is a `Tool(content)` rule, or `Tool(*)` / `Tool()` read as the bare tool; the test is the reader's own `compiledEntryName(row) !== entry`. A **deny** entry of that form refuses in every grammar, because reading it as the rule it spells only ever refuses more than the name it was. Bare names, namespaces and `allowDirs` read the same in both grammars.
11
+ - **The announcement.** The engine announces grammar-0 entries once per distinct set per layer, through `onError` with code `config.session_rules_earlier_grammar` (phase `config`, the same channel as `config.session_rules_unreadable`). The message names the session and the entries, and how to rewrite them.
12
+ - **Refusals.** A stamp above the engine's, or one that is not a whole number ≥ 0, is a shape fault (field `grammar`). The file store refuses that row as `corrupt` (the existing damaged-row path). The run-time layer refuses every call. A write refuses it as `invalid_shape`.
13
+ - **New list reading.** `compileSessionRuleBucket(rules, field, roster?)` is the list reading of one bucket in its row's grammar. The run-time layer and the tighten comparison both read through it.
14
+ - **The way out (the rewrite).** An operator write of the rules **afresh** stamps them. That means a value without the stored row's `rev`, through `putRules`. After it, the entry is the allow rule it spells. A non-operator write of the same entry is a widening (`loosen_forbidden`, `widened toolAllow with "Bash(rm:*)"`), because the stored entry admitted nothing. A stored row handed back to a write is refused by name (`inert_entry`, reason `earlier_grammar`, nothing written). That covers a read-modify-write and a session-sync replay of an export, so neither can turn the entry into a grant.
15
+ - **Who breaks.**
16
+ - Rows written before this version whose `toolAllow` holds a parenthesized entry. 7.51.0 / 7.51.1 rows meant as allow rules are included. They admit less until an operator rewrites them; the advisory names each.
17
+ - Exact-equality on rule rows. `getRules` / `putRules` / `listBySession` values and `readSessionRulesShape(…).rules` now carry `grammar`.
18
+ - A switch over `AllowEntryInertReason` has one more word.
19
+ - Rollback or a mixed-version fleet (KL-1761-c). 7.51.1 and earlier read `grammar` as a key that is no bucket, so every row this version writes is a malformed row there: the file store refuses it as `corrupt`, the layer refuses every call, and the SQL twin judges it the same way. The way out on such an engine is to delete the row. A fleet should not let an older replica read a stamped row (server: take this in the same version as M17).
20
+ - *server*: the SQL twin stores `JSON.stringify(judge result)` and serves `{ ...readSessionRulesShape(body).rules, rev, gen }`, so the stamp rides both ways with no code change. A body read through the shape reading carries no `rev`, so the twin's rows read in this grammar. The twin's own one-column-one-grammar migration (M17) remains its fence for rows written before 7.51.0. A session-sync import of an export holding an unstamped row is now refused by name (400 `rules.inert_entry`, reason `earlier_grammar`) instead of granting. Pins that deep-equal the judge's or the shape reading's output gain `grammar: 1`.
21
+ - **How to migrate.** For each entry the advisory names, either write the session's rules afresh as an operator (the entry becomes the allow rule it spells) or leave it (it keeps admitting nothing). Nothing to do for rows without such entries.
22
+ - **Pins.** `test/backlog1761-rule-row-grammar-version.test.ts` (P0 red on 7.51.1: the lane ran `rm`). It covers the real-lane refusal and advisory, the operator rewrite (stamped; `rm` then runs), the non-operator widening, the handed-back row and the export → import replay refused, an old row's deny still refusing, a later stamp and malformed stamps refused at the store, the layer and the write, a stamped row read as written (and, over the published safety-merge corpus, every tighten verdict and bucket compile unchanged by the stamp), `Bash(*)` / `Bash()` / `Read(*)` / `mcp__srv(*)` read as nothing in grammar 0 (lane, write, widening), and the checkpoint face: a parked unstamped ancestor snapshot resumes in grammar 0, and a parking leg records the stamped row it enforces. Re-pinned: the row key set in `backlog1003-1000` / `file-session-policy-store` (+ `grammar`) and the reason-set arrays in `backlog1358` / `backlog1352` (+ `earlier_grammar`).
23
+
24
+ ### `BUILTIN_SUBAGENT_TYPES` is exported from the package root (#1771; additive; hard-breaking: none; closed sets: +1 set `BuiltinSubagentType`; exports: +1 constant, +1 type) (@client-core @cli @desktop)
25
+
26
+ - **What changed.** The `subagent_type` words a default deployment accepts without defining an agent of its own — the two built-in definitions `Explore` / `Plan` and the two built-in routes `fork` / `general-purpose` — are one closed set on the root, `BUILTIN_SUBAGENT_TYPES` (frozen; type `BuiltinSubagentType`; registered in `docs/CLOSED-SETS.md`). A deployment definition of the same name shadows the built-in, as before; the Agent tool's accepted domain per deployment is this set ∪ the roster, less shadowed built-ins.
27
+ - **Who breaks.** No one: additive. client-core's generated table (CC-748) and desktop's / cli's local copies retire onto it.
28
+ - **How to migrate.** `import { BUILTIN_SUBAGENT_TYPES } from "@sema-agent/core"`; render pickers from it and keep no copy. `worker` is not a core word (an agent-teams role name lives with the shells).
29
+
30
+ ### `McpServerSpec.toolAxes` has one reader, `readMcpToolAxes`, exported from the package root; the preparation door asks it of every MCP entry (#1763; additive export; behavior narrowing at the door, open to veto; hard-breaking: none; closed sets: none; exports: +1 function, +1 type; members: none) (@server @web-admin)
31
+
32
+ **Behavior narrowing (open to veto).** (1) **What changed:** a task whose `mcp[i].toolAxes` does not fit now fails at prepare with `config.invalid_argument`, before any server is dialed. (2) **Who is hurt:** a host that declared an axis the type never allowed. It ran before, with the declaration folded as written; it is refused now. (3) **Compensation:** the refusal sentence names the server, the tool and the member, and lists the legal members or words; a host fixes the one declaration it names and resubmits, or asks `readMcpToolAxes` first and never submits the bad one. The compensating alternative is the same reader at the host's own door, so a refusal there and a refusal here are one answer.
33
+
34
+ - **Before (7.51.1).** `McpServerSpec.toolAxes` was a TypeScript type with no reader behind it. The connect folded `spec.toolAxes?.[remoteName]` as it arrived (`src/core/mcp.ts:4098` into `applyCallerAxisOverride`, `:2573-2593`): an off-vocabulary word, a wrong-case word, a string `egress` or an unknown member went into the fold unjudged. A host that assembled the field from JSON wrote its own reader (server `src/mcp-tool-axes.ts`; web-admin `src/lib/config/mcp-tool-axes.ts` as a transitional copy), so one fact had three readers.
35
+ - **Now.** `readMcpToolAxes(declared: unknown): McpToolAxesRead` (`src/core/mcp-server-spec.ts`, beside the type) answers `{ ok: true, toolAxes? }` or `{ ok: false, problem }`.
36
+ - **Accepted.** Absent. Otherwise a plain record of per-tool objects whose members are exactly `effect` / `egress` / `irreversibility`: `effect` one of the published vocabulary's tool effects, `egress` a boolean, `irreversibility` one of the vocabulary's words less `maybe` (the word the engine derives from a tool's own declaration; a caller can vouch only for the two poles). The words and the member set are derived from `SAFETY_AXIS_VOCABULARY` and the `toolAxes` type, so a word or member the engine gains moves the reader with it (a member the type gains is a compile error in the reader; the irreversibility words are held to the vocabulary by a compile-time lockstep).
37
+ - **Refused, whole.** A declaration that is not a plain data record (null, an array, a scalar, a map, a date, a class instance, or an object whose members are inherited from a prototype or are not enumerable: the connect used to read `toolAxes[name]` through the whole chain, so reading only own enumerable keys would have dropped a tightening mark without a word), an empty tool name, a tool whose declaration is not an object, an unknown member, a word outside the vocabulary, a non-boolean `egress`. A null-prototype record and a record built in another realm are plain. One bad tool refuses the whole declaration: the good tools are not kept and no word is defaulted, because a caller axis is two-way (it can vouch a tool down as well as mark it up) and a lost half is a trust root that is not in force. Present `null` is a refusal, not absence.
38
+ - **The sentence** names the tool and the member and never a value; names are quoted and cut at 80 characters. The reader is total: a getter, a proxy trap or a tag that throws while the declaration is read comes back as a refusal (without the thrown text), never as a throw.
39
+ - **The copy.** `ok: true` carries a plain copy of what was judged. Empty per-tool objects are dropped and `toolAxes` is absent when nothing is declared; the engine read those as absent already (`applyCallerAxisOverride` folds `{}` to the server's hint unchanged). A tool named `__proto__` is an ordinary key.
40
+ - **The door.** `snapshotAndValidateMcpEntries` (`src/core/locked-config.ts`) asks the reader once per entry, after the content-class question. The entry then carries the reader's copy, frozen with the entry, so what the door approved is what connect and every refresh read: a caller writing its own `toolAxes` object after prepare no longer changes a later refresh, a tool that shallow-copies a server from `mountedMcp()` cannot rewrite the parent's axes (a write throws), and a getter is read once. It runs on every prepare leg (fresh, resume, child), before any side effect.
41
+ - **The entrance.** `materializeMcpTools` (`src/core/mcp.ts`, right after the snapshot) asks the same question per server through the same function (`judgeEntryToolAxes`, `src/core/locked-config.ts`), because the library entrance and the no-run probe reach it without the door. A declaration the reader cannot read fails THAT server's dial: never spawned, a `failed` status of class `invalid_config`, a warning naming the tool and member (`config.invalid_argument`); its siblings dial normally, and a re-dial of it is refused the same way. A passing entry carries the judged frozen copy, so a caller writing its own object after the call does not reach a refresh. `probeMcpServers` dials through this entrance, so a refused declaration is a failed row of class `invalid_config` there.
42
+ - **Not covered, stated.** `A2aServerSpec.toolAxes` has the same shape and no reader yet (A2A peers take neither the door nor the entrance). On a resume leg the door runs after the checkpoint claim, as the content-class question does: the resume's pre-claim screen (`refuseResumeDoors`) does not carry the MCP door, so a resume whose re-supplied `mcp` entry is refused here consumes the row the way those doors already do.
43
+ - **Differs from the server's reader.** It refuses the layouts above (the server's reader returned an empty declaration for a map, a date, and a record whose tools are inherited or not enumerable, and took the inherited members of one tool), and its sentences differ in wording and add the legal members and words. Cell for cell on the server suite's own samples the verdicts are the same (the pin's B group).
44
+ - **Consumers named.** *server* S-1581: retire `readMcpToolAxes` in `src/mcp-tool-axes.ts` and call this one (`web_admin_mcp_tool_axes_invalid.problem` takes the new wording). *web-admin*: delete the transitional copy and the copy in `scripts/check-mcp-header-owner.mjs`.
45
+ - **Pins.** `test/backlog1763-mcp-tool-axes-judge.test.ts`: A (the reader: accepts, each refusal arm, whole refusal, no value in the sentence, bounded names, `__proto__`, one read per property, every vocabulary word), B (the server suite's own cells), C (the door: code and sentence, whole-prepare refusal, the plain-copy hand-off and its freeze, absent and empty mint no key), D (the public task lane: a refused declaration spawns no server, a legal one mounts, and the axes the door approved are the axes the fold carries), E (a resume leg: the MCP door sits after the claim, characterised against a screened door as the control), F (the library entrance, no door: a refused declaration fails that server's dial and is never spawned, siblings dial, a re-dial is refused, the probe's row is `invalid_config`, a passing entry carries the judged copy).
46
+
1
47
  ## 7.51.1 — 2026-10-14
2
48
 
3
49
  ### `inheritEnv: "all"` is defined once, in core: the whole environment less the deployment's own credentials (#1753; security; behavior narrowing where a deployment declares its list, open to veto; closed sets: +1 set `EngineCapability`, +1 member `SecretEnvFindingKind` `own-credential`; exports: +6, none removed; members: +1 optional notice detail key)
@@ -144,6 +144,11 @@ export declare const FORK_SUBAGENT_TYPE = "fork";
144
144
  * override the built-in entry).
145
145
  */
146
146
  export declare const GENERAL_PURPOSE_SUBAGENT_TYPE = "general-purpose";
147
+ /** The `subagent_type` words a default deployment accepts with no agent of its own: the two built-in definitions' names
148
+ * and the two built-in routes (a same-named deployment definition shadows the built-in); hosts render pickers from it. */
149
+ export declare const BUILTIN_SUBAGENT_TYPES: readonly ["Explore", "Plan", "fork", "general-purpose"];
150
+ /** One word of {@link BUILTIN_SUBAGENT_TYPES}. */
151
+ export type BuiltinSubagentType = (typeof BUILTIN_SUBAGENT_TYPES)[number];
147
152
  /**
148
153
  * design/136 §6 盲点① (1.256) + §2.1.a (BREAKING batch) — the fork-GOVERNANCE predicate for the
149
154
  * `Agent(subagent_type:"fork")` route (the ONLY fork face since the standalone `Fork` tool was retired,
@@ -256,6 +256,7 @@ function createToolStatsCounter(delegationToolName) {
256
256
  }
257
257
  export const FORK_SUBAGENT_TYPE = "fork";
258
258
  export const GENERAL_PURPOSE_SUBAGENT_TYPE = "general-purpose";
259
+ export const BUILTIN_SUBAGENT_TYPES = Object.freeze(["Explore", "Plan", "fork", "general-purpose"]);
259
260
  export function forkGovernanceDenial(enableFork, allowFork) {
260
261
  if (enableFork === false)
261
262
  return "task";
@@ -7,8 +7,10 @@ import { type ToolNameListReading } from "./tool-name-list.js";
7
7
  import { type EngineNotice } from "./engine-notice.js";
8
8
  /** Why an entry grants (or refuses) nothing — the closed set the start-of-run notice names, for every list. `tool_name`: the
9
9
  * entry's tool name is one the name judgment reads as stale against the run's roster (`verdict` says which); `near_miss`: the
10
- * run mounts no tool of that exact name but mounts one that differs only by case (`currentName` is its spelling). */
11
- export declare const ALLOW_ENTRY_INERT_REASONS: readonly ["wildcard_all", "namespace_wildcard", "content_unsupported", "grammar", "tool_name", "near_miss"];
10
+ * run mounts no tool of that exact name but mounts one that differs only by case (`currentName` is its spelling);
11
+ * `earlier_grammar`: a session's stored rules are written in an earlier rule grammar, in which the entry named no tool (the
12
+ * session store's list reading, `compileSessionRuleBucket` — this reader never answers it). */
13
+ export declare const ALLOW_ENTRY_INERT_REASONS: readonly ["wildcard_all", "namespace_wildcard", "content_unsupported", "grammar", "tool_name", "near_miss", "earlier_grammar"];
12
14
  export type AllowEntryInertReason = (typeof ALLOW_ENTRY_INERT_REASONS)[number];
13
15
  /** The facts beside a reason: for `tool_name` / `near_miss` the judgment's word, the judgment's own one-line sentence when it has
14
16
  * one (`guidance`, byte for byte — `retired` and `unmintable_segment` carry one, `unprefixed_namespace` none) and the spelling to
@@ -11,7 +11,7 @@ import { mandateDisclosureOf } from "./ask-origin.js";
11
11
  import { inlineUntrusted } from "./untrusted-text.js";
12
12
  import { valueTypeLabel } from "./tool-name-list.js";
13
13
  import { deliverEngineNotice } from "./engine-notice.js";
14
- export const ALLOW_ENTRY_INERT_REASONS = Object.freeze(["wildcard_all", "namespace_wildcard", "content_unsupported", "grammar", "tool_name", "near_miss"]);
14
+ export const ALLOW_ENTRY_INERT_REASONS = Object.freeze(["wildcard_all", "namespace_wildcard", "content_unsupported", "grammar", "tool_name", "near_miss", "earlier_grammar"]);
15
15
  export function nonStringMemberLabel(value) {
16
16
  return `<not a string: ${valueTypeLabel(value)}>`;
17
17
  }
@@ -39,8 +39,8 @@ export interface LockedPreflight {
39
39
  /** The validated lock set (empty when the deployment declares none). */
40
40
  lockedKeys: ReadonlySet<LockedKey>;
41
41
  /** Effective MCP server list for this task: a validated, frozen SNAPSHOT of `spec.mcp` when the
42
- * key is unlocked (see {@link snapshotAndValidateMcpEntries} — the content-class declaration is read once
43
- * and judged here, so no mint site downstream can be handed a different value than the door
42
+ * key is unlocked (see {@link snapshotAndValidateMcpEntries} — the content-class and `toolAxes` declarations
43
+ * are read once and judged here, so no mint site downstream can be handed a different value than the door
44
44
  * approved); with `mcp` locked a spec-supplied value has already been refused, so this is always
45
45
  * `undefined` (core has no deployment-level MCP seat — a locked deployment mounts no
46
46
  * task-supplied servers). */
@@ -84,15 +84,36 @@ export declare function resolveLockedKeys(config: LockedConfig | undefined): Rea
84
84
  * Boundary, stated: the copy is one level deep — nested option objects (transport, headers, env,
85
85
  * toolAxes) stay shared with the caller's entry exactly as before, because the axis this owns is the
86
86
  * content class; readers of the nested shapes normalize their own values (the transport DISCRIMINATOR
87
- * is derived, never echoed raw). Own ENUMERABLE properties only: an entry whose fields live on a
88
- * prototype was never the plain configuration record this type describes, and its absent class reads
89
- * fail-closed like any other absence.
87
+ * is derived, never echoed raw). `toolAxes` is the one nested shape both entrances replace with its
88
+ * reader's plain copy ({@link judgeEntryToolAxes}), and the one member this snapshot reads BY NAME rather
89
+ * than by spread, once: a declaration an entry carries as a non-enumerable or inherited property would
90
+ * otherwise vanish before any reader sees it, and an absent caller axis is a tightening lost, not a
91
+ * fail-closed absence. Every other member is taken as own ENUMERABLE properties only: an entry whose
92
+ * fields live on a prototype was never the plain configuration record this type describes, and its
93
+ * absent class reads fail-closed like any other absence.
90
94
  *
91
95
  * Exported because the SECOND boundary needs the identical guarantee: `materializeMcpTools` is public
92
96
  * and a library caller reaches it without passing any door, so it snapshots its own input through
93
97
  * this same function (idempotent over an already-frozen entry). One spelling, both entrances.
94
98
  */
95
99
  export declare function snapshotMcpEntries(entries: readonly McpServerSpec[]): McpServerSpec[];
100
+ /**
101
+ * The `toolAxes` question for ONE snapshot entry — asked by the preparation door (a refusal ends the prepare) and by the materialization
102
+ * entrance (a refusal fails that server's dial), so there is one reading, one copy and one sentence whichever time it is asked.
103
+ * `ok: true` carries the entry with the reader's PLAIN COPY of the axes in place of the caller's object: what was judged is the very value
104
+ * the connect and every refresh read, not a live object that could answer differently later. The copy is frozen with the entry — a tool
105
+ * that shallow-copies a server (`mountedMcp()`) to customise its axes must not rewrite what the parent reads. `ok: false` carries the
106
+ * coded refusal (`config.invalid_argument`) naming the server, the tool and the member, never a value.
107
+ */
108
+ export declare function judgeEntryToolAxes(copy: McpServerSpec): {
109
+ ok: true;
110
+ entry: McpServerSpec;
111
+ } | {
112
+ ok: false;
113
+ error: Error & {
114
+ code: string;
115
+ };
116
+ };
96
117
  /**
97
118
  * The single preflight resolver: validate the lock declaration, refuse a spec that tries to occupy
98
119
  * a locked slot (`config.locked_key`, two-state — the whole prepare is rejected, nothing is
@@ -1,3 +1,4 @@
1
+ import { readMcpToolAxes } from "./mcp-server-spec.js";
1
2
  import { isToolContentOrigin, TOOL_CONTENT_ORIGINS } from "./memory-engine/content-origin.js";
2
3
  import { inlineUntrusted } from "./untrusted-text.js";
3
4
  import { assertNoRetiredToolPolicyKeys } from "./retired-keys.js";
@@ -27,7 +28,10 @@ export function resolveLockedKeys(config) {
27
28
  return keys;
28
29
  }
29
30
  export function snapshotMcpEntries(entries) {
30
- return entries.map((entry) => Object.freeze({ ...entry }));
31
+ return entries.map((entry) => {
32
+ const { toolAxes, ...rest } = entry;
33
+ return Object.freeze((toolAxes === undefined ? rest : { ...rest, toolAxes }));
34
+ });
31
35
  }
32
36
  function describeDeclared(value) {
33
37
  try {
@@ -42,20 +46,41 @@ function describeDeclared(value) {
42
46
  }
43
47
  }
44
48
  }
49
+ const serverNameOf = (copy) => inlineUntrusted(typeof copy.name === "string" ? JSON.stringify(copy.name) : describeDeclared(copy.name), 80);
50
+ export function judgeEntryToolAxes(copy) {
51
+ const { toolAxes: declaredAxes, ...rest } = copy;
52
+ const axes = readMcpToolAxes(declaredAxes);
53
+ if (!axes.ok) {
54
+ return {
55
+ ok: false,
56
+ error: codedError("config.invalid_argument", `MCP server ${serverNameOf(copy)} declares toolAxes the engine cannot read: ${inlineUntrusted(axes.problem, 240)}. A per-tool safety-axis declaration is the ` +
57
+ `caller's trust root for the approval gate, so it is refused rather than partly applied — dropping one key or defaulting one word ` +
58
+ `would leave a declaration that reads as in force while it is not.`),
59
+ };
60
+ }
61
+ if (axes.toolAxes !== undefined) {
62
+ for (const perTool of Object.values(axes.toolAxes))
63
+ Object.freeze(perTool);
64
+ Object.freeze(axes.toolAxes);
65
+ }
66
+ return { ok: true, entry: Object.freeze(axes.toolAxes === undefined ? rest : { ...rest, toolAxes: axes.toolAxes }) };
67
+ }
45
68
  function snapshotAndValidateMcpEntries(entries) {
46
- const copies = snapshotMcpEntries(entries);
47
- for (const copy of copies) {
69
+ return snapshotMcpEntries(entries).map((copy) => {
48
70
  const declared = copy.contentOrigin;
49
71
  if (declared !== undefined && !isToolContentOrigin(declared)) {
50
- throw codedError("config.mcp_content_class", `MCP server ${inlineUntrusted(typeof copy.name === "string" ? JSON.stringify(copy.name) : describeDeclared(copy.name), 80)} declares contentOrigin ` +
72
+ throw codedError("config.mcp_content_class", `MCP server ${serverNameOf(copy)} declares contentOrigin ` +
51
73
  `${inlineUntrusted(describeDeclared(declared), 80)}, which is not one of ` +
52
74
  `${TOOL_CONTENT_ORIGINS.map((v) => `"${v}"`).join(" | ")}. The declaration decides whether this server's tools ` +
53
75
  `mark the session's memory externally exposed — a value this engine cannot read is refused at the door rather ` +
54
76
  `than folded to a class, because folding it would let a deployment believe a declaration is in force while every ` +
55
77
  `call keeps writing a one-way durable mark.`);
56
78
  }
57
- }
58
- return copies;
79
+ const judged = judgeEntryToolAxes(copy);
80
+ if (!judged.ok)
81
+ throw judged.error;
82
+ return judged.entry;
83
+ });
59
84
  }
60
85
  export function preflightLockedConfig(spec, deps) {
61
86
  const lockedKeys = resolveLockedKeys(deps.lockedConfig);
@@ -1,10 +1,3 @@
1
- /**
2
- * How a deployment DECLARES an external tool server and what an elicitation round-trip looks like:
3
- * `McpServerSpec` (stdio / HTTP / in-process MCP), `A2aServerSpec` (the agent-to-agent peer) and the
4
- * elicit pair (`McpElicitRequest` / `McpElicitResponse` / `OnElicit`). Named `mcp-server-spec.ts`, not
5
- * `mcp.ts`: `src/core/mcp.ts` is the CLIENT, and two files a letter apart would be a worse hazard than
6
- * a longer name. Layer 0 vocabulary; `types.ts` re-exports every name below.
7
- */
8
1
  import type { ToolContentOrigin, ToolEffect } from "./tool-spec.js";
9
2
  import type { ToolFamily, ToolPathTarget, ToolRenderHints, ToolRuleFace } from "./tool-face.js";
10
3
  /** Definition of an MCP server to connect to for the duration of one task, then discard. */
@@ -67,6 +60,15 @@ export interface McpServerSpec {
67
60
  * BRINGS BACK (memory-write governance, no gate/policy/roster effect). Neither implies the other —
68
61
  * a read-only tool can return third-party text, and a deployment's own writer brings back nothing
69
62
  * external — so vouching on one axis never quietly vouches on the other.
63
+ *
64
+ * THE SHAPE IS JUDGED, NOT TRUSTED: {@link readMcpToolAxes} is the one reader of this declaration. The
65
+ * preparation door runs it on every entry, and a declaration it cannot read refuses the whole prepare
66
+ * (`config.invalid_argument`) — one bad tool refuses the entry, a bad key is never dropped and a bad word
67
+ * never defaults, because a caller's safety axis that silently reads as absent is a trust root that is not
68
+ * in force. The materialization entrance (`materializeMcpTools`, which the no-run probe dials through) asks
69
+ * it again per server: a declaration it cannot read fails THAT server's dial. A host that assembles this
70
+ * field from JSON (a console, a deployment intake) asks the same reader at its own door, so both sides
71
+ * answer one question one way.
70
72
  */
71
73
  toolAxes?: Record<string, {
72
74
  effect?: ToolEffect;
@@ -147,6 +149,33 @@ export interface McpServerSpec {
147
149
  */
148
150
  fingerprint?: string;
149
151
  }
152
+ type ToolAxes = NonNullable<McpServerSpec["toolAxes"]>;
153
+ /**
154
+ * {@link readMcpToolAxes}'s answer. `ok: true` carries the declaration as a PLAIN COPY the reader built from
155
+ * what it judged (never the caller's own object); `toolAxes` is absent when the entry declares nothing — no
156
+ * key at all, or only empty per-tool objects (which the engine reads the same as no declaration). `ok: false`
157
+ * carries one sentence naming the tool and the member that did not fit, never a value.
158
+ */
159
+ export type McpToolAxesRead = {
160
+ ok: true;
161
+ toolAxes?: ToolAxes;
162
+ } | {
163
+ ok: false;
164
+ problem: string;
165
+ };
166
+ /**
167
+ * The one reader of a raw {@link McpServerSpec.toolAxes} declaration — the engine's preparation door and
168
+ * every host that assembles the field from JSON ask this same function. Absent ⇒ `{ ok: true }` (nothing
169
+ * declared). Anything else must be an object of per-tool objects whose members are exactly `effect` /
170
+ * `egress` / `irreversibility` with legal values; a declaration of any other shape, an empty tool name, an
171
+ * unknown member, a word outside the vocabulary or a non-boolean `egress` on ANY one tool refuses the whole
172
+ * declaration (`ok: false`) — the good tools are not kept and nothing is defaulted, since a caller axis is
173
+ * two-way (it can vouch a tool down as well as mark it up) and a silently lost half is a trust root that is
174
+ * not in force. The reader judges SHAPE only; whether the axes contradict each other (`egress` over a
175
+ * non-write effect) is the ingestion's own arithmetic. The reader is total: a getter, a proxy trap or a tag
176
+ * that throws while the declaration is read is a refusal that says so (never the thrown text), not a throw.
177
+ */
178
+ export declare function readMcpToolAxes(declared: unknown): McpToolAxesRead;
150
179
  /** The identity of one mount of a declared server, as the host minted it ({@link McpServerSpec.fingerprint}). */
151
180
  export interface McpMountIdentity {
152
181
  readonly fingerprint: string;
@@ -254,3 +283,4 @@ export interface McpElicitResponse {
254
283
  * over-eager or untrusted server here (the request's `AbortSignal` is forwarded so a deadline can cancel it).
255
284
  */
256
285
  export type OnElicit = (req: McpElicitRequest, signal?: AbortSignal) => Promise<McpElicitResponse>;
286
+ export {};
@@ -1 +1,84 @@
1
- export {};
1
+ import { SAFETY_AXIS_VOCABULARY } from "./safety-axis-vocab.js";
2
+ const TOOL_AXIS_MEMBERS = { effect: true, egress: true, irreversibility: true };
3
+ const TOOL_AXIS_MEMBER_NAMES = Object.keys(TOOL_AXIS_MEMBERS);
4
+ const TOOL_EFFECT_WORDS = SAFETY_AXIS_VOCABULARY.toolEffect;
5
+ const ENGINE_DERIVED_IRREVERSIBILITY = "maybe";
6
+ const IRREVERSIBILITY_POLE_WORDS = SAFETY_AXIS_VOCABULARY.irreversibility.filter((word) => word !== ENGINE_DERIVED_IRREVERSIBILITY);
7
+ const irreversibilityPolesLockstep = true;
8
+ void irreversibilityPolesLockstep;
9
+ function quotedName(name) {
10
+ return JSON.stringify(name.length > 80 ? `${name.slice(0, 80)}…` : name);
11
+ }
12
+ function describeKind(value) {
13
+ return value === null ? "null" : Array.isArray(value) ? "an array" : typeof value === "object" ? "an object that is not a plain data record (a map, a class instance, or members that are inherited or not enumerable)" : `a ${typeof value}`;
14
+ }
15
+ const ROOT_PROTOTYPE_NAMES = Object.getOwnPropertyNames(Object.prototype).sort().join("\n");
16
+ function isRootPrototype(proto) {
17
+ if (proto === Object.prototype)
18
+ return true;
19
+ return Object.getPrototypeOf(proto) === null && Object.getOwnPropertyNames(proto).sort().join("\n") === ROOT_PROTOTYPE_NAMES;
20
+ }
21
+ function isPlainRecord(value) {
22
+ if (typeof value !== "object" || value === null)
23
+ return false;
24
+ const proto = Object.getPrototypeOf(value);
25
+ if (proto !== null && !isRootPrototype(proto))
26
+ return false;
27
+ return Object.getOwnPropertyNames(value).length === Object.keys(value).length;
28
+ }
29
+ function isToolEffectWord(value) {
30
+ return typeof value === "string" && TOOL_EFFECT_WORDS.includes(value);
31
+ }
32
+ function isIrreversibilityPoleWord(value) {
33
+ return typeof value === "string" && IRREVERSIBILITY_POLE_WORDS.includes(value);
34
+ }
35
+ function readToolAxisEntry(raw) {
36
+ if (!isPlainRecord(raw))
37
+ return `the declaration is ${describeKind(raw)}, not an object of axes`;
38
+ for (const member of Object.keys(raw)) {
39
+ if (!TOOL_AXIS_MEMBER_NAMES.includes(member))
40
+ return `unknown member ${quotedName(member)} (the members are ${TOOL_AXIS_MEMBER_NAMES.join(", ")})`;
41
+ }
42
+ const { effect, egress, irreversibility } = raw;
43
+ if (effect !== undefined && !isToolEffectWord(effect))
44
+ return `member "effect" is not one of ${TOOL_EFFECT_WORDS.join(" | ")}`;
45
+ if (egress !== undefined && typeof egress !== "boolean")
46
+ return `member "egress" is not a boolean`;
47
+ if (irreversibility !== undefined && !isIrreversibilityPoleWord(irreversibility))
48
+ return `member "irreversibility" is not one of ${IRREVERSIBILITY_POLE_WORDS.join(" | ")}`;
49
+ return {
50
+ ...(effect !== undefined ? { effect } : {}),
51
+ ...(egress !== undefined ? { egress } : {}),
52
+ ...(irreversibility !== undefined ? { irreversibility } : {}),
53
+ };
54
+ }
55
+ export function readMcpToolAxes(declared) {
56
+ if (declared === undefined)
57
+ return { ok: true };
58
+ let perTool;
59
+ try {
60
+ if (!isPlainRecord(declared))
61
+ return { ok: false, problem: `toolAxes is ${describeKind(declared)}, not an object of per-tool axis declarations` };
62
+ perTool = Object.entries(declared);
63
+ }
64
+ catch {
65
+ return { ok: false, problem: "toolAxes could not be read: a property threw while it was read" };
66
+ }
67
+ const entries = [];
68
+ for (const [tool, raw] of perTool) {
69
+ if (tool.length === 0)
70
+ return { ok: false, problem: "toolAxes has an empty tool name" };
71
+ let entry;
72
+ try {
73
+ entry = readToolAxisEntry(raw);
74
+ }
75
+ catch {
76
+ entry = "a member threw while it was read";
77
+ }
78
+ if (typeof entry === "string")
79
+ return { ok: false, problem: `the axes declared for tool ${quotedName(tool)} do not fit: ${entry}` };
80
+ if (Object.keys(entry).length > 0)
81
+ entries.push([tool, entry]);
82
+ }
83
+ return entries.length > 0 ? { ok: true, toolAxes: Object.fromEntries(entries) } : { ok: true };
84
+ }
package/dist/core/mcp.js CHANGED
@@ -25,7 +25,7 @@ import { MARKED_BLOCK_AFTER_DATA } from "./reminder-mint.js";
25
25
  import { resolveMcpDeclaredResultSize, resolveMcpRequiresUserInteraction } from "./mcp-tool-meta.js";
26
26
  export { resolveMcpDeclaredResultSize } from "./mcp-tool-meta.js";
27
27
  import { withContentOrigin } from "./memory-engine/content-origin.js";
28
- import { snapshotMcpEntries } from "./locked-config.js";
28
+ import { judgeEntryToolAxes, snapshotMcpEntries } from "./locked-config.js";
29
29
  import { validateJsonSchemaShape } from "./runner/strict-output-schema.js";
30
30
  import { schemaKeyBoundProblem } from "./tool-roster.js";
31
31
  import { readMember, thrownIs, thrownName, thrownText } from "./thrown-value.js";
@@ -657,7 +657,15 @@ export function mcpToolSchemaProblem(schema) {
657
657
  return undefined;
658
658
  }
659
659
  export async function materializeMcpTools(specs, principal, onElicit, imageResizer, reminderDisclosure, mcpRevocations, poolOpts, promptSections) {
660
- const entries = snapshotMcpEntries(specs);
660
+ const snapshot = snapshotMcpEntries(specs);
661
+ const axesRefused = new WeakMap();
662
+ const entries = snapshot.map((spec) => {
663
+ const judged = judgeEntryToolAxes(spec);
664
+ if (judged.ok)
665
+ return judged.entry;
666
+ axesRefused.set(spec, judged.error);
667
+ return spec;
668
+ });
661
669
  {
662
670
  const collision = findNamespacePrefixCollision(MCP_NAMESPACE, entries.map((s) => s.name));
663
671
  if (collision) {
@@ -773,6 +781,10 @@ export async function materializeMcpTools(specs, principal, onElicit, imageResiz
773
781
  }
774
782
  };
775
783
  const dialKinds = entries.map(readDialKind);
784
+ const dialServer = (spec, dialKind, dialOpts) => {
785
+ const refusal = axesRefused.get(spec);
786
+ return refusal !== undefined ? Promise.reject(refusal) : connectServer(spec, principal, onElicit, imageResizer, mcpDisclosure, isServerRevoked, dialKind, poolOpts?.pool, dialOpts);
787
+ };
776
788
  const prefixOf = (name) => mintNamespacePrefix(MCP_NAMESPACE, name);
777
789
  const reconcileInstructions = (name, before, after, connected) => {
778
790
  let owedRemoval = false;
@@ -798,7 +810,7 @@ export async function materializeMcpTools(specs, principal, onElicit, imageResiz
798
810
  if (before !== undefined || owedRemoval || hadPendingAdd)
799
811
  instructionsDelta.pendingRemovals.push(name);
800
812
  };
801
- const settled = await Promise.allSettled(entries.map((spec, i) => connectServer(spec, principal, onElicit, imageResizer, mcpDisclosure, isServerRevoked, dialKinds[i], poolOpts?.pool, { sections: promptSections })));
813
+ const settled = await Promise.allSettled(entries.map((spec, i) => dialServer(spec, dialKinds[i], { sections: promptSections })));
802
814
  try {
803
815
  for (let i = 0; i < entries.length; i++) {
804
816
  const r = settled[i];
@@ -855,7 +867,7 @@ export async function materializeMcpTools(specs, principal, onElicit, imageResiz
855
867
  const dialKind = readDialKind(spec);
856
868
  let connected;
857
869
  try {
858
- connected = await connectServer(spec, principal, onElicit, imageResizer, mcpDisclosure, isServerRevoked, dialKind, poolOpts?.pool, { fresh: true, sections: promptSections });
870
+ connected = await dialServer(spec, dialKind, { fresh: true, sections: promptSections });
859
871
  }
860
872
  catch (err) {
861
873
  const { kind, delivered, httpStatus } = classifyMcpFailure(err, { phase: "connect", transport: dialKind });
@@ -25,7 +25,7 @@ import { type AskClass } from "../ask-class.js";
25
25
  import type { AutoModeDecider } from "../auto-mode.js";
26
26
  import { type Hooks } from "../hooks.js";
27
27
  import type { LockedPreflight } from "../locked-config.js";
28
- import type { SessionPermissionRules } from "../session-policy-store.js";
28
+ import { type SessionPermissionRules } from "../session-policy-store.js";
29
29
  import { type OnAsk, type ToolPolicy } from "../tool-policy.js";
30
30
  import type { PathRuleBases } from "../permission-rule-model.js";
31
31
  import type { HeadLeads } from "../path-rule-binding.js";
@@ -7,6 +7,7 @@ import { cloneObserverInput, resolveHookTimeoutMs } from "../hooks.js";
7
7
  import { assertHookSeatsCallable } from "../hook-seats.js";
8
8
  import { isNamespacedCoveringRuleName, namespacedRuleNameCovers } from "../permission-rules.js";
9
9
  import { isIsolated } from "../remote-env.js";
10
+ import { earlierGrammarAdvisory } from "../session-policy-store.js";
10
11
  import { askApproverIdentity, attest, attestationOf, checkToolPolicyProjection, combinePolicies, createTranscriptIntegrityPolicy, createUnverifiableDeletePolicy, durableParkOptInDeclared, isLiveApproverSeat, mergeAttestation, refuseOutOfContractDecision, resolveAsk, toolPolicyNameSets, withAttestation, wrapToolPolicy } from "../tool-policy.js";
11
12
  import { judgeRuleToolName, mountedToolNames } from "../tool-name-aliases.js";
12
13
  import { approvalCardMemberOf } from "../tool-roster.js";
@@ -110,6 +111,7 @@ export async function preparePolicyChain(input) {
110
111
  catch {
111
112
  }
112
113
  };
114
+ const announceEarlierGrammar = (rulesSessionId) => (entries) => deps.onError?.(Object.assign(new Error(earlierGrammarAdvisory(rulesSessionId, entries)), { code: "config.session_rules_earlier_grammar" }), { phase: "config", sessionId });
113
115
  let sessionRulePolicy;
114
116
  if (deps.sessionPolicyStore && spec.sessionId) {
115
117
  const store = deps.sessionPolicyStore;
@@ -125,7 +127,7 @@ export async function preparePolicyChain(input) {
125
127
  rules: row,
126
128
  };
127
129
  };
128
- sessionRulePolicy = createSessionRulePolicy({ seed, read: createLiveSessionRules(store, { ...sessionRuleKey, seed }, { onUnreadable: announceSessionRulesUnreadable, onEnforced: publishOwnRules }) }, { env: executionEnv, rootPath: taskRootFinal, toolEffects, anchors: pathRuleAnchors.leg });
130
+ sessionRulePolicy = createSessionRulePolicy({ seed, read: createLiveSessionRules(store, { ...sessionRuleKey, seed }, { onUnreadable: announceSessionRulesUnreadable, onEnforced: publishOwnRules }) }, { env: executionEnv, rootPath: taskRootFinal, toolEffects, anchors: pathRuleAnchors.leg, onEarlierGrammar: announceEarlierGrammar(sessionRuleKey.sessionId) });
129
131
  if (rules)
130
132
  publishOwnRules(rules);
131
133
  }
@@ -146,7 +148,7 @@ export async function preparePolicyChain(input) {
146
148
  ...(deps.sessionPolicyStore
147
149
  ? { read: createLiveSessionRules(deps.sessionPolicyStore, { sessionId: snap.sessionId, ...(snap.principal !== undefined ? { principal: snap.principal } : {}), seed }, { onUnreadable: announceSessionRulesUnreadable, onEnforced: publishAncestorRules }) }
148
150
  : {}),
149
- }, { env: executionEnv, rootPath: taskRootFinal, toolEffects, anchors: pathRuleAnchors.leg }));
151
+ }, { env: executionEnv, rootPath: taskRootFinal, toolEffects, anchors: pathRuleAnchors.leg, onEarlierGrammar: announceEarlierGrammar(snap.sessionId) }));
150
152
  }
151
153
  const frozenConstraintLayers = (seedInheritedGate?.constraintChain ?? [])
152
154
  .filter((e) => !("opaque" in e))
@@ -111,7 +111,8 @@ export declare function createLiveSessionRules(store: SessionPolicyStore, floor:
111
111
  * (a line it cannot read against the rule is refused too), a path rule over every view of the call's target.
112
112
  * - `toolAllow` (if set) → deny any call no entry admits (the matcher's allow reading in the `narrowing` posture: every
113
113
  * segment of a command line named by an entry; never the allow layer's auto-mode strip), naming the uncovered segment.
114
- * - an entry that reads as nothing → in no row (it guards and grants nothing; the prepare-time audit names it).
114
+ * - an entry that reads as nothing → in no row (it guards and grants nothing; the prepare-time audit names it, or
115
+ * `onEarlierGrammar`, once per distinct set, when its snapshot's own earlier grammar reads it so).
115
116
  * - a snapshot the shape reading refuses → deny every call, saying what it holds (never its text).
116
117
  * - `allowDirs` (if set) → a WRITE tool must land inside one of the dirs (resolved via `canonicalizeTarget`,
117
118
  * so a symlink can't carry across a write out); a write-capable tool that cannot be path-confined (e.g. `bash`)
@@ -131,4 +132,5 @@ export declare function createSessionRulePolicy(rules: SessionPermissionRules |
131
132
  rootPath?: string;
132
133
  toolEffects?: ReadonlyMap<string, ToolEffect>;
133
134
  anchors?: PathRuleBases;
135
+ onEarlierGrammar?: (entries: readonly string[]) => void;
134
136
  }): NamedToolPolicy;
@@ -1,6 +1,6 @@
1
1
  import { canonicalizeTarget, isWinFormPath, pathFamilyOf, writeTargetPath } from "../../tools/fs/safety.js";
2
- import { continuesLineage, readSessionRulesShape } from "../session-policy-store.js";
3
- import { compileAllowList, compiledEntryName, judgeAllowEntries, uncoveredCommandSegment } from "../allow-entry.js";
2
+ import { compileSessionRuleBucket, continuesLineage, earlierGrammarAnnouncer, readSessionRulesShape } from "../session-policy-store.js";
3
+ import { compiledEntryName, judgeAllowEntries, uncoveredCommandSegment } from "../allow-entry.js";
4
4
  import { inlineUntrusted } from "../untrusted-text.js";
5
5
  import { callTargetReadingOf, pathTargetBaseOf, readCallTarget } from "../effective-path-target.js";
6
6
  import { egressThrownText } from "../untrusted-egress.js";
@@ -104,26 +104,27 @@ export function createLiveSessionRules(store, floor, hooks) {
104
104
  return p;
105
105
  };
106
106
  }
107
- function compileSessionRules(rules) {
107
+ function compileSessionRules(rules, announceEarlier) {
108
108
  const shaped = readSessionRulesShape(rules);
109
109
  if ("fault" in shaped) {
110
110
  const { field, got } = shaped.fault;
111
111
  const what = got === "not a rule bucket" ? "a key that is no rule bucket" : field === "" ? `a value that is ${got}, not an object of rule buckets` : `${field} holding ${got}`;
112
112
  return { deny: [], allow: undefined, allowDirs: undefined, pathRows: false, names: { deny: [], allow: [] }, unreadable: `the session rules cannot be read as rules (they hold ${what}) — every call is refused until they are rewritten` };
113
113
  }
114
- const { toolDeny, toolAllow, allowDirs } = shaped.rules;
115
- const denyList = compileAllowList(toolDeny ?? [], "deny");
116
- const allowList = toolAllow === undefined ? undefined : compileAllowList(toolAllow, "allow");
114
+ const { toolAllow, allowDirs } = shaped.rules;
115
+ const denyList = compileSessionRuleBucket(shaped.rules, "toolDeny");
116
+ const allowList = toolAllow === undefined ? undefined : compileSessionRuleBucket(shaped.rules, "toolAllow");
117
117
  const rowsOf = (behavior, list) => list.rows.map((r) => ({ behavior, entry: r.entry, compiled: r.compiled }));
118
118
  const deny = rowsOf("deny", denyList);
119
119
  const allow = allowList === undefined ? undefined : rowsOf("allow", allowList);
120
- const namesOf = (rows, list) => [...rows.map((r) => compiledEntryName(r.compiled)), ...(list?.inert ?? []).map((i) => i.entry)];
120
+ const named = (list, earlier) => (list?.inert ?? []).filter((i) => (i.reason === "earlier_grammar") === earlier).map((i) => i.entry);
121
+ announceEarlier(named(allowList, true));
121
122
  return {
122
123
  deny,
123
124
  allow,
124
125
  allowDirs: allowDirs !== undefined && allowDirs.length > 0 ? allowDirs : undefined,
125
126
  pathRows: [...deny, ...(allow ?? [])].some((r) => r.compiled.kind === "path"),
126
- names: { deny: namesOf(deny, denyList), allow: namesOf(allow ?? [], allowList) },
127
+ names: { deny: [...deny.map((r) => compiledEntryName(r.compiled)), ...named(denyList, false)], allow: [...(allow ?? []).map((r) => compiledEntryName(r.compiled)), ...named(allowList, false)] },
127
128
  };
128
129
  }
129
130
  export function createSessionRulePolicy(rules, opts) {
@@ -132,11 +133,12 @@ export function createSessionRulePolicy(rules, opts) {
132
133
  const source = "seed" in rules ? rules : { seed: rules };
133
134
  const seed = source.seed;
134
135
  const readRules = source.read ?? (() => Promise.resolve(seed));
136
+ const announceEarlier = earlierGrammarAnnouncer(opts.onEarlierGrammar);
135
137
  let compiledFor;
136
138
  let compiled;
137
139
  const compile = (r) => {
138
140
  if (compiled === undefined || compiledFor !== r) {
139
- compiled = compileSessionRules(r);
141
+ compiled = compileSessionRules(r, announceEarlier);
140
142
  compiledFor = r;
141
143
  }
142
144
  return compiled;
@@ -63,6 +63,11 @@ export type InheritEnvPolicy = "all" | "scrub" | string[];
63
63
  * from the next spawn on — a spawn whose environment is already read (one announcing its findings) keeps it. The
64
64
  * list is copied. A malformed list — not an array, an entry that is no variable name, or a name the host hands every
65
65
  * child anyway ({@link runtimeSuppliedEnv}) — throws and leaves the declaration as it was.
66
+ *
67
+ * Two Windows readings, measured on a real machine: a withheld name that the child's own shell or runtime re-creates
68
+ * with a DEFAULT of its own (`ComSpec`, `PATHEXT`, `HOME`, `CommonProgramFiles`, …) shows up in the child's table with
69
+ * that default, never with this process's value — withheld, not leaked; and the disclosure (`detail.ownCredentials`)
70
+ * spells each withheld name as this process's environment spells it, not as the declaration spelled it.
66
71
  */
67
72
  export declare function declareWithheldEnv(names: readonly string[]): void;
68
73
  /** Test seam: never called by production code (a host re-declares `[]`). */