@sema-agent/core 7.51.0 → 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 +97 -1
- package/dist/agents/subagent.d.ts +5 -0
- package/dist/agents/subagent.js +1 -0
- package/dist/core/allow-entry.d.ts +4 -2
- package/dist/core/allow-entry.js +1 -1
- package/dist/core/engine-capabilities.d.ts +15 -0
- package/dist/core/engine-capabilities.js +1 -0
- package/dist/core/engine-notice.d.ts +8 -5
- package/dist/core/locked-config.d.ts +26 -5
- package/dist/core/locked-config.js +31 -6
- package/dist/core/mcp-server-spec.d.ts +37 -7
- package/dist/core/mcp-server-spec.js +84 -1
- package/dist/core/mcp.js +16 -4
- package/dist/core/runner/prepare-policy-chain.d.ts +1 -1
- package/dist/core/runner/prepare-policy-chain.js +4 -2
- package/dist/core/runner/session-rule-policy.d.ts +3 -1
- package/dist/core/runner/session-rule-policy.js +11 -9
- package/dist/core/runner/tool-output-projection.d.ts +5 -0
- package/dist/core/runner/tool-output-projection.js +3 -3
- package/dist/core/secret-env.d.ts +46 -15
- package/dist/core/secret-env.js +84 -18
- package/dist/core/session-policy-store.d.ts +54 -14
- package/dist/core/session-policy-store.js +83 -20
- package/dist/core/spec-contract.d.ts +5 -3
- package/dist/core/types.d.ts +2 -1
- package/dist/core/types.js +1 -0
- package/dist/engine/execution-env/kill-tree.js +4 -4
- package/dist/engine/execution-env/node-execution-env.d.ts +11 -4
- package/dist/engine/execution-env/node-execution-env.js +9 -4
- package/dist/engine/lsp/node-lsp-manager.d.ts +2 -1
- package/dist/engine/lsp/node-lsp-manager.js +2 -2
- package/dist/index.d.ts +10 -2
- package/dist/index.js +7 -2
- package/dist/stores/file/fs-atomic.js +2 -2
- package/dist/stores/file/session-policy-store.d.ts +3 -3
- package/package.json +1 -1
- package/test/export-surface.snapshot.json +33 -1
- package/wire-changes/core@7.51.1.json +259 -0
- package/wire-changes/core@7.51.2.json +293 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,91 @@
|
|
|
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
|
+
|
|
47
|
+
## 7.51.1 — 2026-10-14
|
|
48
|
+
|
|
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)
|
|
50
|
+
|
|
51
|
+
**Behavior narrowing (open to veto).** (1) **What changed:** a deployment declares its own credential names once per process, with `declareWithheldEnv(names)`. After that, no child spawned on this host inherits one of them, whatever its `inheritEnv` says. That covers the tool shell, background shells, oracle gates, the language server, and the engine's own helper programs looked up on PATH (shell discovery `which` / `where`, the tree kill's `ps` / `taskkill` / Git Bash kill, the lock probe's `ps`). The MCP stdio child is the SDK's own spawn: it receives the SDK's default inherited set plus the server entry's configured `env` (the host's explicit grant), never this process's whole environment — so the declaration has nothing to subtract there. `"all"` is the whole environment less the declared names, and `"scrub"` is the scrub's result less them. An allow list naming one is refused. One function says all of this, `inheritedHostEnv(policy, findings?)`. Every core spawn site goes through it (`scrubSecretEnv(` now has exactly one caller in src), and it is now a root export, so a host's own spawn site calls it instead of reading the policy a second time. (2) **Who breaks:** a deployment that declares names and relied on `"all"` handing them to the model's shell. That reliance is the defect being closed: an engine's approval bearer reached a model-driven shell under `"all"`, and one `curl` could approve its own card (the inheritance arm; the engine process's own initial environment stays readable to same-user processes on the host through `ps -E` / `/proc/<pid>/environ` — KL-1753-a — so a credential that never enters the environment remains the root fix). A deployment that declares nothing sees no change. (3) **How to migrate:** at start-up, before anything spawns, call `declareWithheldEnv` with the deployment's own credential names (every `type: secret` key of its configuration directory), and call `inheritedHostEnv(policy, findings)` at every host spawn site. A child that genuinely needs one of those values gets it through an explicit grant (`shellEnv`, a per-exec `env`, `NodeLspManagerOptions.env`), never by inheritance.
|
|
52
|
+
|
|
53
|
+
- **Before (7.51.0).** `inheritedHostEnv("all")` was `{ ...process.env }` and an allow list was handed out as written. The function was not exported, so every host re-read the three policies itself (server `src/plugins/remote-env-host.ts` `childEnv`: `all ⇒ { ...process.env }`). A malformed policy that reached the function was read as `"scrub"`.
|
|
54
|
+
- **Now.** `inheritedHostEnv(policy, findings?)` subtracts the declared names from every policy's result. Each name it subtracts is a finding `{ kind: "own-credential", confidence: "high", source: "secret-env" }`. A name the scrub already withheld stays one finding, the scrub's. Names are compared the way the platform compares them: exactly, except case-blind on Windows. `declareWithheldEnv` copies the list, and a later call replaces it whole (a configuration reload), taking effect from the next spawn. A malformed list throws `declareWithheldEnv: …` and leaves the previous declaration standing. Malformed means not an array, or an entry that is not a variable name (empty, or holding `=`, NUL, white space or an unpaired surrogate — a name written "almost right" is refused, never silently unmet), or a name some layer of the host hands every child whatever env the spawn passes, which therefore cannot be withheld. Those names are `NODE_V8_COVERAGE` (Node), the MCP stdio transport's default inherited set (`HOME LOGNAME PATH SHELL TERM USER`; its Windows list there), and on Windows libuv's required variables. A name holding an unpaired UTF-16 surrogate is not a variable name: the platform stores it as U+FFFD. This is refused in a declaration and, newly, in an `inheritEnv` allow list.
|
|
55
|
+
- **When an allow list naming a declared name is refused (fixed timing).** ① **At every spawn, always.** `inheritedHostEnv` reads the declaration as it stands and throws `inheritEnv[i] names X, which is this deployment's own credential and cannot be inherited by a child (a configuration error: no retry succeeds until the policy or the declaration changes)`. That is the invariant, whenever the declaration was made: a tool-shell exec answers `spawn_error`, a background shell `spawn_failed`, no child starts, and the `homeDir` / `cdPath` reads say "none" rather than throw. ② **Also early, when the declaration came first.** `NodeExecutionEnv` refuses at construction (`NodeExecutionEnv: …`), and the oracle gate refuses per gate (an `error` gate that starts nothing), both through `inheritEnvPolicyProblem(value)`, which reads the declaration. A host that declares after construction gets only ①. No new refusal code. `inheritedHostEnv` also throws for a value that is not a policy at all. It used to read such a value as `"scrub"`.
|
|
56
|
+
- **Disclosure.** `config.secret_env_scrubbed` still fires once per process. `detail.names` now includes the withheld own credentials, and the new `detail.ownCredentials` (present only when one was withheld) names exactly them. The raw `onSecretEnvScrub` seat now also fires on an `"all"` leg that withheld one.
|
|
57
|
+
- **One way in.** The language server's default environment and the five helper programs looked up on PATH now go through `inheritedHostEnv("scrub")`; they used to call `scrubSecretEnv(process.env)` directly. With nothing declared, every result is the same as before. `gate:spawn-sites` now lets one named data export of a process-starting module through as a non-site: the MCP SDK's `DEFAULT_INHERITED_ENV_VARS` (`INERT_SPAWN_MODULE_EXPORTS`), which the declaration reads instead of keeping a copy.
|
|
58
|
+
- **Capability word.** `ENGINE_CAPABILITIES` (`src/core/engine-capabilities.ts`, frozen; type `EngineCapability`, registered in `docs/CLOSED-SETS.md`) is the engine build's capability words. Its first word, `inherit_env.own_credentials_withheld`, means the behaviour above. A shell gates on the word, not on a version number, and the server projects it onto `/v1/capabilities` once it passes its list.
|
|
59
|
+
- **Consumers named.** *server*: declare once at boot (`declareWithheldEnv`, the directory's `type: secret` keys). `src/plugins/remote-env-host.ts` `childEnv` (`all` / allow-list / scrub arms, `:1936-1944`) and `src/server-secret-env.ts` `hostSubprocessEnv` move to `inheritedHostEnv(policy, findings)`. `src/request-value-closure.ts:164` `inheritEnvFieldIssue` asks `inheritEnvPolicyProblem(value)` for the list-naming refusal (early, at the request door). `src/boot/execution-env.ts:694` (the git base env under `"all"`) is covered by the same declaration. The bit on `/v1/capabilities` comes from `ENGINE_CAPABILITIES`. *cli*: `src/sema/inheritEnvWire.ts:40-46` gates sending `"all"` on that capability bit instead of the version (L-1408). *client-core*: `src/inheritEnvWire.ts:61` `inheritEnvDeclaration` takes the bit. *desktop*: `docs/W-WIRE-D8-DECISION.md` (B, does not send `inheritEnv`) can revisit once the bit is true.
|
|
60
|
+
- **Who is hurt / compensation.** A TypeScript consumer that switches exhaustively over `SecretEnvFindingKind` gets a compile error on `own-credential` (one new arm). A caller of the previously internal `inheritedHostEnv` with a malformed policy gets a throw where it used to get the scrub; no such caller existed outside core. A host that declares a name after building an environment whose allow list names it sees that environment's spawns refused, loudly, from then on.
|
|
61
|
+
- **Pins.** `test/backlog1753-own-credentials-withheld.test.ts`, W1–W11:
|
|
62
|
+
- `"all"` less the declaration, through a unit call and through a real shell's `printenv`; the user's undeclared token is still handed on.
|
|
63
|
+
- `"scrub"` unioned with the declaration.
|
|
64
|
+
- An allow list refused at the validator and in `inheritedHostEnv`. Declared before construction ⇒ `NodeExecutionEnv` refuses. Declared after ⇒ exec `spawn_error` and background `spawn_failed`, no child starts, the reads say "none". The oracle gate refuses it too.
|
|
65
|
+
- A malformed declaration throws and the previous one stands; the declaration is a copy and a later one replaces it.
|
|
66
|
+
- The disclosure and the raw seat.
|
|
67
|
+
- The oracle gate, through `runOracle` and the public `runSpec` path.
|
|
68
|
+
- The `shellEnv` grant.
|
|
69
|
+
- The Windows case fold, with the platform stubbed.
|
|
70
|
+
- The capability word against its CLOSED-SETS row.
|
|
71
|
+
- A planted PATH `ps` that sees no declared name, both on a tree kill and on a lock probe, with a control that sees it when nothing is declared.
|
|
72
|
+
- The AST pin: `scrubSecretEnv(` has one caller in src.
|
|
73
|
+
|
|
74
|
+
`test/backlog1753-lsp-withheld.test.ts` covers the language server's inherited environment, a declaration made after the manager was built, and the host's own `env` grant. Re-pinned: `test/backlog1181-spawn-site-env.test.ts` (a value that is not a policy now throws instead of reading as the scrub) and `test/public-knob-liveness.test.ts` (the three helper files no longer read the environment whole).
|
|
75
|
+
- **Known limits.** KL-1753-a: another local process reading the engine's initial environment (`ps -E`, `/proc/<pid>/environ`) is an OS concern, out of this reach. KL-1753-b: the Windows case fold is pinned with the platform stubbed; Windows `process.env` behaviour itself is inferred. KL-1753-c: what the host hands a child itself (`shellEnv`, a per-exec `env`, `NodeLspManagerOptions.env`, an MCP server entry's `env`) is not read against the declaration, and no notice names such a grant. A credential value the engine holds that the deployment did not name is the separate value arm (#1754), not covered here.
|
|
76
|
+
|
|
77
|
+
### `toolOutputBlockChars` is exported from the package root; two catalog sentences corrected (#1758, #1762; additive; hard-breaking: none; closed sets: none; exports: +1 function) (@server @client-core)
|
|
78
|
+
|
|
79
|
+
- **What changed.** `toolOutputBlockChars(block)` — the one per-block character counter that `tool_end.totalChars` sums over a tool result's blocks (`src/core/runner/tool-output-projection.ts`) — is exported from the root, so a consumer that re-renders a truncated result counts with the same function instead of a copy. Two sentences are corrected: the `permission.entry_mode_clamped` catalog detail is `{ requested, effective, ceiling, sessionId }` — the minter sits at intake, before any run exists, so the `runId` the 7.51.0 entry listed cannot be minted (erratum noted on that entry); the session-rule bucket refusal's example now reads "Bash(rm:*) matches a command whose first word is rm" — a rule matches the command's first word, `/bin/rm` is not matched, and the earlier "refuses every run of rm" overstated it.
|
|
80
|
+
- **Who breaks.** No one: an additive export and wording only.
|
|
81
|
+
- **How to migrate.** `import { toolOutputBlockChars } from "@sema-agent/core"` and sum it over the result's `content` blocks to reproduce `totalChars`.
|
|
82
|
+
|
|
83
|
+
### `headerOwnershipCollision` and `ProtocolHttpAuth` are exported from the package root (#1757; additive; hard-breaking: none; closed sets: none; exports: +1 function, +1 type) (@web-admin @server)
|
|
84
|
+
|
|
85
|
+
- **What changed.** The engine's one header-ownership judge — "a header name has exactly one owner in a protocol declaration" (7.51.0 #1491, `src/core/protocol-http-auth.ts`), until now reachable only at dial time as `config.protocol_header_collision` — is exported, with its input type, so a host console's write face and a server's deployment intake refuse a colliding declaration at the door by calling the same function the engine calls, instead of keeping their own copies of the rule.
|
|
86
|
+
- **Who breaks.** No one: additive. web-admin's two hand-written copies and server's intake (S-1571) retire onto this export.
|
|
87
|
+
- **How to migrate.** `import { headerOwnershipCollision } from "@sema-agent/core"`; `undefined` = no collision, else `{ first, second }` are the two spellings that collide.
|
|
88
|
+
|
|
1
89
|
## 7.51.0 — 2026-10-14
|
|
2
90
|
|
|
3
91
|
### A tool list naming a tool nobody answers to no longer fails the run — the entry is kept and told once; only a spelling no tool name can have is refused (#1736; **BREAKING**: behavior change, the reversal of 7.49.0 hard-breaking ①; hard-breaking: one, open to veto (C-R449); closed sets: none; exports: none; members: none)
|
|
@@ -47,7 +135,7 @@
|
|
|
47
135
|
**Now.**
|
|
48
136
|
- The sentence names the route taken at the fork (never whether a park then commits): "handed on to this run's live approver", "handed on to this run's durable approval park, for a person to answer later", or "this run has no approver and no park facility, so the call was refused" (`that layer's …` at the inherited station). A run that declared `approvalReach: "none"` takes the `none` route only when no park lane is armed: an armed durable park is the deployment's own statement that an answer can wait for a person, so such a run parks (`durable_park`) — the declaration silences the live callback, not the park. A print / SDK-stdio lane whose approver callback cannot reach a person declares `approvalReach: "none"` (client-core `approvalReachDeclaration`); the route is then `none` and the engine — not the lane — says the call was refused.
|
|
49
137
|
- `InheritedGate.autoModeFace` (new optional type key; opaque half, never serialized; present only when the ancestor's deps — or its own chain — carry an auto-mode face): `autoModeFaceOf(deps, chain)` is the one reading of "which face does this leg arm from" — the leg's own `RunnerDeps.autoMode`, else its ancestor's. A child whose runner wires none arms its own classifier (its own transcript, model and denial tracker) from the ancestor's deployment face; its own face wins where it has one; `RuntimeCaps.autoMode: false` still folds it.
|
|
50
|
-
- `permission.entry_mode_clamped` joins the notice catalog (closed-set addition; audience `user`, `NOTICE_MINTER` `"server"`, `detail: { requested, effective, ceiling, sessionId
|
|
138
|
+
- `permission.entry_mode_clamped` joins the notice catalog (closed-set addition; audience `user`, `NOTICE_MINTER` `"server"`, `detail: { requested, effective, ceiling, sessionId }`, once per run; erratum 7.51.1: this line first listed a `runId` the minter cannot have — the clamp happens at intake, before a run exists): the serving layer mints it where its entry floor clamps (server S-1306); this engine never mints it — its own folds stay `config.permission_mode_unavailable`. Readers: client-core / cli generated notice tables.
|
|
51
139
|
- C-R138 (bypass: a card nobody answers parks on a durable seat, else is refused) holds on every K-ASK cell, including an approver that answers `unavailable` (`approver_unavailable`): pinned, no behaviour change.
|
|
52
140
|
|
|
53
141
|
**Behavior change (narrowing of the person's reach).** A delegated child whose own runner wires no auto-mode face is classified under its ancestor's face (#1493 c): calls that went to a person in v7.50.0 because the child folded to `default` are now answered by the classifier like the parent's own calls; the deployment's deny bit (`RuntimeCaps.autoMode: false`) still wins. **Who is hurt.** A host that hand-wires the gate's classifier seat or reads the observer's `disposition` (hard-breaking 1). A deployment that relied on a face-less child runner refusing `auto` now gets a classifying child — the parent's face is the deployment's own trust gate; deny it per principal with `RuntimeCaps.autoMode: false`. **Compensation.** Fail-closed screen on the route word; the wire `disposition` keeps its two words; the parent stream now carries every child fold.
|
|
@@ -177,6 +265,14 @@
|
|
|
177
265
|
- **Pins.** `test/backlog1573-egress-tiers.test.ts` (A loop station × thrown Error / string, and the returned-content control; B child value × cause / details / name / AggregateError / identity / cycle / accessor / depth / JSON reading / proxy / a getter or `toJSON` answering twice / a self-returning `toJSON`, a bound getter, a `Date` delegating to its own `toISOString`, a credential-named member serializing to `Basic …`, a `DOMException` re-minted, a subclass whose prototype `toJSON` / `toString` hands back raw text (top level, nested, through the loop's station); C activity summary × four fields / signed URL / field choice / authentication names; D quote spellings × faces; E nested JSON × depth 1–3; H header schemes × faces, Bearer control, shape kept, white space on either side of `=`, a quoted parameter at 1 024 / 1 025 / 1 500 characters inside one and two JSON layers with the fields after it kept, a bound cutting through the value; F new rows + namespace words; G guard + type pins); `test/fixtures/secret-detect-fixtures.json` (+26 fixtures: npm, ya29, ABIA / ACCA / A3T, encoded quotes, header schemes); `test/backlog1099-sema-namespace-secret-env.test.ts` (oracle = the derived words); `test/backlog1508-subagent-failure-redaction.test.ts` ③ / ⑨ re-pinned as "same class, same own members, sentence byte-identical" (a snapshot, not the live value); the `scrubSecrets` face retired from the #532 / #954 / #958 / #1583 / #1675 / #1691 / RB-209 pins.
|
|
178
266
|
- **Known limits.** A credential carried by a PROTOTYPE accessor of a thrown class (`get message()` reading a closure) is not a member and is read only by the text face; KL-1717-b: white space after a parameter's `=` continues a header value, so a token68 that ends in `=` padding reads the next word as its value (`Authorization: Basic <…=> was sent` → `Authorization: [redacted] sent`) — the cost of not leaving `response= <value>` in clear; a field whose lower-case name glues a credential word to another word (`licensekey`) is no credential name to the record-key view (the old activity-summary filter matched substrings); a string member named with a credential word that holds no credential (`tokenCount: "5"`) reads the marker; the resume leg's thrown-error station (`run-leg.ts` — the `Error:` text and the marks of a thrown tool, both through the same-principal tier) and the compaction failure reason (`run-turn-boundary.ts`) are wired at the wave merge, so the live loop and the resumed leg mint the same redacted result; KL-1717-a: a credential passed as a program's flag argument (`curl -u alice:<password>`, `--password <value>`) is no text shape the vocabulary reads (it reads header grammar, not per-program flag grammar) — which flag takes a credential is a per-program grammar, the shell-argument model's, not the text vocabulary's.
|
|
179
267
|
|
|
268
|
+
### The gate's sentences built from a thrown value are redacted before they are contained (#1728; P1; fix; security; behaviour change — more is redacted; hard-breaking: none; closed sets: none; exports: +0 / −0) (@server @test) — *disclosed 2026-10-14 13:1x, after the v7.51.0 tag: both fixes (`7172aff05`, `56dceceb5`) are ancestors of the tag; this section, criterion G18 / flip F32 and the ship-post erratum were added together*
|
|
269
|
+
|
|
270
|
+
- **Before (v7.50.2; pre-existing — the base reads the same; found by the 7.51.0 stage review leg, probe R2).** `describeThrown` (`src/core/tool-policy.ts`), the one containment point for a thrown value's text at the gate, contained the text but did not redact it: a PreToolUse hook that crashed with a credential in its message, a live approver that threw ("approval errored for …", two sites), an approver outcome whose members threw on read ("could not be read", two sites) and the session-policy store's re-read failure notice put the credential on `tool_end.output`, into the model's next context, or onto the operator line.
|
|
271
|
+
- **Now.** `describeThrown` redacts at the `same-principal` tier BEFORE it contains, so every reader of the one containment point is redacted at once; the empty-message fallback (the structural description — a thrown value's own key names are its text too) is redacted on the same terms; the session-rule store's notice reads `egressThrownText` directly. The 7.50.1 policy-throw mint already redacted its own line; this closes the siblings.
|
|
272
|
+
- **Who is hurt.** A host or test that pinned one of those sentences with a credential-shaped string in it now reads the marker. No member, code, closed-set word or export changes.
|
|
273
|
+
- **Compensation.** None needed (over-redaction on egress is the declared direction, as for #1573).
|
|
274
|
+
- **Pins.** `test/backlog1728-gate-throw-texts.test.ts` (R2a the hook crash on the block reason; R2b the throwing approver on the model's next context; R2c the unreadable approver outcome in the refusal sentence; R2d `describeThrown` itself — redacted then contained, never empty, a non-Error value; R2e the empty-message fallback). Base (stage before the change): R2a–R2d red. Mutation: `describeThrown` contains `thrownText(err)` again ⇒ R2a–R2c red; the store notice reads `thrownText` ⇒ R2d red.
|
|
275
|
+
|
|
180
276
|
### A read station's answer is read one way by every reader: the resumed leg's belt refuses a returned silence (#1708; fix; security; behaviour narrowing; hard-breaking: none; exports: +0 / −0) (@server @test)
|
|
181
277
|
|
|
182
278
|
- **Before (v7.50.1; the same in v7.49.5).** The durable-resume leg re-judges an approved shell call on its own read station before it runs. It refused when the station's judgment THREW or timed out (the `unanswered()` fallback), but a judgment that RETURNED `{ mandated, unanswered, environmentSilent }` — a resolver answering an error result — passed on an already-seen call and the call ran: a link re-pointed onto a deny-listed file while the call was parked, with the resolver giving no answer on the resumed leg, put that file's content in the transcript under a person's yes to a different question. The gate's fold read the same silence before its arms (7.50.0); the belt read it twice.
|
|
@@ -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,
|
package/dist/agents/subagent.js
CHANGED
|
@@ -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
|
-
|
|
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
|
package/dist/core/allow-entry.js
CHANGED
|
@@ -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
|
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The engine build's capability words — the one home for "this engine does X". A shell gates on a word, never on a
|
|
3
|
+
* version number; the server projects the words it also wires onto its `/v1/capabilities`.
|
|
4
|
+
*
|
|
5
|
+
* A word is added in the release whose code makes it true and is never re-spelt; a word whose behaviour is retired
|
|
6
|
+
* leaves the set in a breaking release. Each word:
|
|
7
|
+
* - `inherit_env.own_credentials_withheld` — a child spawned on this host never inherits a name the deployment
|
|
8
|
+
* declared as its own credential (`declareWithheldEnv`): `inheritedHostEnv` subtracts the list from every policy's
|
|
9
|
+
* result (`"all"` and `"scrub"` alike), and an allow list naming one is refused (`inheritEnvPolicyProblem`; every
|
|
10
|
+
* spawn throws it). The word speaks of INHERITANCE only: the engine process's own environment stays readable to
|
|
11
|
+
* same-user processes on the host (`ps -E`), so a credential that never enters it remains the root fix.
|
|
12
|
+
*/
|
|
13
|
+
export declare const ENGINE_CAPABILITIES: readonly ["inherit_env.own_credentials_withheld"];
|
|
14
|
+
/** One word of {@link ENGINE_CAPABILITIES}. */
|
|
15
|
+
export type EngineCapability = (typeof ENGINE_CAPABILITIES)[number];
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const ENGINE_CAPABILITIES = Object.freeze(["inherit_env.own_credentials_withheld"]);
|
|
@@ -379,10 +379,13 @@ export interface EngineNotice {
|
|
|
379
379
|
* - `"config.execution_env_capability_invalid"` — a remote env DECLARES a boolean capability key
|
|
380
380
|
* (`isolation`, `suspendable`, `canonicalPathAuthoritative`) with a non-boolean value; ABSENCE is a
|
|
381
381
|
* supported reading of each and stays silent. Once per key per run; `detail: { key, valueType }`.
|
|
382
|
-
* - `"config.secret_env_scrubbed"` (#1081) — the D1 fail-closed `scrub` policy
|
|
383
|
-
*
|
|
382
|
+
* - `"config.secret_env_scrubbed"` (#1081) — the D1 fail-closed `scrub` policy, or the deployment's
|
|
383
|
+
* declared own credentials (`declareWithheldEnv`), withheld one or more keys from a spawned child's
|
|
384
|
+
* environment (a tool's shell, an oracle gate, or a language server).
|
|
384
385
|
* Once per PROCESS, shared by every scrub site ({@link import("./secret-env.js").announceSecretEnvScrub});
|
|
385
|
-
* `detail: { names }` — every withheld key's name, never a value
|
|
386
|
+
* `detail: { names, ownCredentials? }` — every withheld key's name, never a value; `ownCredentials`, present
|
|
387
|
+
* only when one was withheld, the subset withheld as declared own credentials (under any policy, `"all"`
|
|
388
|
+
* included).
|
|
386
389
|
* - `"config.permission_mode_unavailable"` — the permission mode a task requested is not the mode the leg runs
|
|
387
390
|
* under: the deployment denies it (`by: "operator_deny"` — `RuntimeCaps.bypassPermissions` / `autoMode` is
|
|
388
391
|
* `false`), a requested `auto` has no classifier face (`by: "face_absent"` — neither the leg's deps nor its chain carry
|
|
@@ -879,8 +882,8 @@ export interface EngineNotice {
|
|
|
879
882
|
* `clampPermissionMode`), so the run starts under another mode. The disclosure is minted where the clamp happens:
|
|
880
883
|
* a fold this engine makes itself (a parent's ceiling, the deployment's deny bits, a missing classifier face) is
|
|
881
884
|
* `config.permission_mode_unavailable`, never this row. Audience `"user"`; `detail: { requested, effective,
|
|
882
|
-
* ceiling, sessionId
|
|
883
|
-
*
|
|
885
|
+
* ceiling, sessionId }` — `requested` the caller's word, `effective` the word the run starts under, `ceiling`
|
|
886
|
+
* the floor that clamped it; no run id — the clamp happens at intake, before any run exists. Once per run.
|
|
884
887
|
* - `"instructions.source_changed"` — the project instruction file this run injects is not the one the same
|
|
885
888
|
* session's previous run injected (a file added, removed or renamed). Audience `"user"`; `detail: { previous,
|
|
886
889
|
* current, sessionId }` — `previous` / `current` are chain-member relative paths, `null` = that run had none.
|
|
@@ -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
|
|
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).
|
|
88
|
-
*
|
|
89
|
-
*
|
|
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) =>
|
|
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
|
-
|
|
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 ${
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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) =>
|
|
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
|
|
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
|
|
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";
|