@sema-agent/core 7.9.0 → 7.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/dist/core/ask-origin.d.ts +7 -3
  3. package/dist/core/checkpoint-store.d.ts +2 -7
  4. package/dist/core/effective-path-target.d.ts +43 -0
  5. package/dist/core/effective-path-target.js +56 -0
  6. package/dist/core/engine-notice.d.ts +8 -0
  7. package/dist/core/fs-write-gate-policy.js +2 -1
  8. package/dist/core/gate-lanes.d.ts +1 -0
  9. package/dist/core/gate-lanes.js +11 -5
  10. package/dist/core/governance-codes.d.ts +1 -1
  11. package/dist/core/governance-codes.js +2 -0
  12. package/dist/core/hooks.d.ts +10 -1
  13. package/dist/core/mcp-injection-drop.d.ts +74 -0
  14. package/dist/core/mcp-injection-drop.js +27 -0
  15. package/dist/core/permission-rule-model.d.ts +91 -75
  16. package/dist/core/permission-rule-model.js +90 -111
  17. package/dist/core/permission-rule-org.d.ts +12 -6
  18. package/dist/core/permission-rule-org.js +9 -3
  19. package/dist/core/permission-rules.d.ts +3 -2
  20. package/dist/core/permission-rules.js +38 -15
  21. package/dist/core/persisted-rule-arms.d.ts +7 -2
  22. package/dist/core/persisted-rule-arms.js +3 -1
  23. package/dist/core/runner/active-skill-scope.js +2 -1
  24. package/dist/core/runner/permission-rule-lanes.d.ts +12 -4
  25. package/dist/core/runner/permission-rule-lanes.js +13 -15
  26. package/dist/core/runner/session-rule-policy.js +2 -1
  27. package/dist/core/runner/tool-face-overlay.js +22 -3
  28. package/dist/core/sensitive-path-policy.js +5 -3
  29. package/dist/core/shell-lexer.d.ts +47 -0
  30. package/dist/core/shell-lexer.js +478 -0
  31. package/dist/core/shell-scan.d.ts +60 -0
  32. package/dist/core/shell-scan.js +183 -0
  33. package/dist/core/shell-wrapper-table.d.ts +297 -0
  34. package/dist/core/shell-wrapper-table.js +58 -0
  35. package/dist/core/tool-catalog-entries.js +6 -6
  36. package/dist/core/tool-face.d.ts +80 -4
  37. package/dist/core/tool-face.js +10 -0
  38. package/dist/core/tool-policy.d.ts +1 -6
  39. package/dist/core/tool-registry.d.ts +8 -11
  40. package/dist/core/tool-registry.js +5 -2
  41. package/dist/core/tool-roster.d.ts +11 -2
  42. package/dist/core/tool-roster.js +21 -3
  43. package/dist/index.d.ts +6 -3
  44. package/dist/index.js +5 -2
  45. package/dist/tools/fs/fs-search-tools.d.ts +3 -2
  46. package/dist/tools/fs/fs-search-tools.js +17 -9
  47. package/dist/tools/fs/index.js +1 -1
  48. package/dist/tools/fs/search.d.ts +0 -8
  49. package/dist/tools/fs/search.js +0 -23
  50. package/package.json +1 -1
  51. package/test/export-surface.snapshot.json +57 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,28 @@
1
1
  # Changelog
2
2
 
3
+ ## 7.9.1 — 2026-09-08
4
+
5
+ ### Wire (additive) — `mcp.injection_dropped`: the request-lane MCP drop reaches the user (L-167; @server @cli @client-core)
6
+ - **One mint, host facts.** A host that drops an entry of the user's own request-lane MCP injection (a `.mcp.json` row) now hands the FACTS to core — `mcpInjectionDroppedNotice({ sessionId, server, reason, field? })` — and core composes the notice: code `mcp.injection_dropped`, audience `user`, one sentence per reason naming the recovery. The host spells no code, audience or text (`@contract mcp.injection_dropped.single_mint`); a fact outside the vocabulary (an unlisted reason, an empty session or server name) is refused at the mint with a `TypeError`, never shaped into a notice that routes nowhere.
7
+ - **Closed set** `McpInjectionDropReason` = `malformed_entry` / `name_reserved_by_deployment` / `gate_closed` / `over_cap` (`MCP_INJECTION_DROP_REASONS`, registered in CLOSED-SETS with its text table's fence); `detail: { sessionId, server, reason, field? }` — `sessionId` is REQUIRED (the routing half of a user row) and lifted onto the typed key by the one delivery throat; `field` present only for a non-empty host-supplied key. Dedup unit is the host's: per leg per server name.
8
+ - **Consumers:** `ENGINE_NOTICE_CODES` +1 (`mcp.injection_dropped`, `NOTICE_AUDIENCE` row `user`) — a downstream audience mirror diffing against the catalog gains one row; server's `engine_notice` whitelist +1; cli renders one user-level transcript line off `detail.reason`. Exports +5 (`MCP_INJECTION_DROP_REASONS`, `MCP_INJECTION_DROP_TEXT`, `mcpInjectionDroppedNotice`, `McpInjectionDropReason`, `McpInjectionDropFacts`).
9
+
10
+ ### Narrowing — a path rule is judged against the path the tool really opens (#635)
11
+ - **One effective-target resolver, shared with tool execution.** `ToolPathTarget` gains three resolution members — `base` (`"cwd" | "root"`, absent ⇒ `cwd`), `absent` (`"none" | "base"`, absent ⇒ `none`) and `patternParam` (the parameter whose ABSOLUTE glob pattern carries its own base directory) — declared by Read/Edit/Write/NotebookEdit (`cwd`/`none`) and Grep/Glob (`root`/`base`; Glob + `pattern`). `effectivePathTargetOf` (new export, with `pathTargetBaseOf`, `pathTargetSlotOnly` and the closed sets `TOOL_PATH_BASES` / `TOOL_PATH_ABSENCES`) is the one reader: both permission-rule lanes judge a call with it, and the four canonicalizing write fences plus the two search tools share the BASE SELECTION (and, for Glob, the absolute-pattern split) before applying their own identity to it — lexical-normal for a rule, canonical for a tool. One declaration, one selection, so a fence and the tool it fences can no longer resolve one call to two files. **Three shapes now REACH the rules that name them** (each was silently unreachable before): `Grep(//proj/**)` vs a Grep with the path omitted (the tool searches the root); `Glob(//proj/secret/**)` vs `{pattern:"/proj/secret/**"}` (the absolute pattern is the base); and a relative Grep `path`, which resolves against the ROOT as the tool does rather than against the live `cd`. The file tools are byte-unchanged (their declared `cwd` base is the old guess). One honesty change: a relative spelling with NO usable base is now "no verdict" instead of being fabricated into `/<spelling>`.
12
+ - **Wire (additive, @server @cli @client-core @web-admin):** `ToolRosterEntry.pathTarget` carries optional `base` / `absent` / `patternParam` (present only when the definition declares them), and so do `ToolCallFace.pathTarget` and the parked row's face — without them the gate's stamped face loses the facts that say which file a call is about. A mirrored row schema with `additionalProperties: false` must admit the three keys. `ToolPathTarget` / `ToolCallFace.pathTarget` / `ToolCallLike.face.pathTarget` / the checkpoint parked-face row now all name ONE shape: `ToolCallPathTarget` is the READER type (both spellings satisfy it) and `ToolRosterPathTarget` its NORMALIZED spelling (`aliases` and `skillScopeEligible` always present, as the mint has always filled them) — both new exports; the call face and the parked row keep the normalized one, so a consumer already reading `face.pathTarget.aliases` compiles unchanged. `@server` `pathBases` is unchanged.
13
+ - **The declaration door** (`toolFaceProblem`, caller faces and MCP `toolFaces` alike) refuses a `base` or `absent` word outside its closed set, a `patternParam` that is not a top-level schema property, a `patternParam` declared while `absent` is not `"base"` (it could never be read), **`base: "root"` on a face whose `access` is not `"read"`** — two write guards not taught by this change (the session-transcript directory guard, which is handed no root at all, and the frozen-spec deny) resolve a write target against the call's working directory alone, so a write face declaring a root base would be judged on two different files; refused until #638 threads the base through both, with no built-in affected (the three write tools declare `base: "cwd"`, the two search tools are reads) — and **`absent: "base"` on a face whose `access` is not `"read"`** — that word says "with no path, the call is about the whole base directory", which is a search SCOPE; a write with no path names no file, and a write face declaring it would be judged on its base by the rule lanes and on nothing by the containment fences (a writer could then evade the sensitive-path guard by omitting its path). A refused face does not mount, as before.
14
+ - **An EMPTY path slot is absent on both sides.** Grep and Glob read their scope argument with the engine's own slot reader (first NON-EMPTY string under the declared param) instead of `!== undefined`. Behavior change, small and in the closing direction: `Glob({pattern:"/proj/secret/**", path:""})` now decomposes the absolute pattern exactly as an omitted `path` does (it used to fold `""` to the root and match the absolute pattern there, while every fence judged the pattern's own base — one call, two directories, which is how a root deny could be walked past). `Grep` with `path:""` still searches the whole root, as it did.
15
+ - **A win-family spelling lands where the env puts it.** `canonicalizeTarget` never joins a spelling it calls absolute (a drive letter, a UNC root) to the base a tool hands it — it passes the spelling through and the execution env resolves it against its own working directory. So the resolver does not apply the DECLARED base to such a spelling either; it uses the tracked cwd with the root as the floor, which is what the tool's env will do. Without this, `Grep({path:"C:/secret.txt"})` under root `/` with a live `cd` to `/proj` was judged `/C:/secret.txt` while the tool read `/proj/C:/secret.txt`, and `Grep(//proj/**)` — a deny that fired in 7.9.0 — missed. The source-side fix is #637 (the canonicalizer's absoluteness is family-blind); this branch retires with it.
16
+ - **The protective floor hands over the slot, not the resolution.** A call whose face declares no path target of its own is judged against the catalog's declaration for the NAME (design/388 B6) — a floor whose whole justification is "naming a slot can only tighten a deny". Naming a slot does; claiming how a FOREIGN tool of the same name resolves that slot does not, so the floor now supplies `param`/`aliases`/`access` and lets the resolution members default (`pathTargetSlotOnly`, new export). Net effect versus 7.9.0 for a call that carries a face: none — this keeps the floor where it was while the declarations moved. A BARE request (no face at all — a policy invoked outside a Runner) does read the catalog whole, resolution members included, which is the point: there the catalog IS the tool being judged.
17
+
18
+ ### Narrowing — the tightening (deny/ask) reach reads a real shell lexer (#631)
19
+ - **Reach.** `programRunReachOf(rule, command)` answers the closed set `ProgramRunReach` = `reached` / `not_reached` / `unreadable` (registered in CLOSED-SETS); `ruleReachesProgramRun` is its boolean face (unchanged signature, `reached` only). The command is read by `src/core/shell-lexer.ts` (no grammar library): quotes and escapes, every expansion form, the full redirection operator set (IO_NUMBER, `&>`/`&>>`, `N>&M-`, `>&-`, `<>`, `<<<`, here-documents whose bodies are skipped), every connector (`&` and a newline bound segments), subshell/group boundaries, leading keywords (`if rm -r x; then …` runs `rm`), leading assignments, and the closed wrapper table `SHELL_WRAPPER_TABLE` (`command`/`builtin`/`exec`/`env`/`sudo`/`doas`/`pkexec`/`nohup`/`time`/`nice`/`timeout`/`stdbuf`/`xargs`/`watch`/`ionice`/`setsid`/`taskset`/`chrt`/`strace`/`ltrace`/`flock`/`script`/`unshare`/`nsenter`/`noglob`/`nocorrect` — upstream's three strips as one table). Peeling is ADDITIVE (`ShellSegment.peeled`): `sudo rm -r x` is a run of `sudo …` and of `rm -r x`, so a deny on either reaches, and a rule body is never peeled (`Bash(sudo rm -r x)` denies exactly that). A command/process substitution anywhere in a segment (`$(…)`, backticks, `<(…)`, inside `${…}`/`$((…))`, a redirection operand, an unquoted here-document body) marks the segment `peelUnreadable`: the runs it spells are still compared (`echo "$(rm -r x)"` reaches `Bash(echo:*)`), and every other deny/ask reads `unreadable` — the commands INSIDE a substitution are deliberately not read (an approval prompt, never a guessed boundary). Words are compared argv-wise with quotes removed on BOTH sides: `rm "-r" x`, `sudo rm -r x`, `command rm -r x`, `rm -r x; echo "$HOME"`, `rm -r x &> log`, `rm -r x 2>&-`, `npm test & curl x`, `do curl x; done`, `timeout -- 5 rm -r x` now REACH a `Bash(rm -r:*)` / `Bash(curl:*)` deny or ask (7.9.0: silence on every one of them). A `#` comment and a quoted-delimiter here-document body are not runs (7.9.0 judged `echo ok # ; curl x` as a curl run — a limitation, not over-approximation). A leading `~` is an expansion of one word (an exact deny on the expanded path asks; a prefix deny reaches). Module split beside the lexer: `shell-scan.ts` (the byte-walking scanners) and `shell-wrapper-table.ts` (the table).
20
+ - **Unreadable is a fail-closed ask, never silence — and only when a standing deny/ask rule could not be judged** (zero tightening rules ⇒ the lane stays silent, as before). Unreadable = an expansion where the rule reads a word (`rm $X` vs `Bash(rm -r:*)`; `"$@"` / `"${a[@]…}"` after an exact body — they may be zero words), an unterminated quote/substitution, a syntax error (`> ;`, a dangling `&&`), a `for`/`case`/`function` header, a wrapper handed its command as one string (`env -S`, `flock -c`, `script -c`) or to a shell (`watch`, `script`), a wrapper option outside the row's CLOSED option sets (short or long — the table names every option it reads; an unknown one asks rather than guessing its arity), an expanding option value or operand, a positional that does not match (`taskset zz`), more than 8 wrapper layers, a here-document delimiter spelled with an expansion, a command over 10 000 characters (`MAX_SHELL_READ_CHARS`, upstream's own bound). Per lane: the PERSISTED store answers `{ unreadable: true, reason }` — the same shape as an unreadable store (origin `rule_store_unavailable`, real-approval, the classifier never answers; the message says the reason instead of "the store could not be read"); the ORG verdict carries `unreadable: <reason>` beside the first rule it could not judge (`behavior: "ask"`, origin `org_rule`, message names rule + reason); the rule DSL mints `{ action: "ask", matchedAskRule, requiresRealApproval: true }`. Precedence: a deny that cannot be judged outranks an ask that can; within one behavior a reach settles it. **No new AskOrigin word** — `rule_store_unavailable`'s meaning is widened to "the person's deny/ask rows cannot be enforced for this call" (store unreadable OR call unreadable against them; JSDoc amended).
21
+ - **Wire (additive):** `PersistedRuleUnreadable.reason?: string`; `OrgGateVerdict.verdict.unreadable?: string` (only beside `behavior: "ask"`; both screened by the normalizers, a non-string dropped); `PersistedRuleVerdict.unreadable?: string`; `orgRuleVerdictFor` returns `OrgRuleVerdict` (the same two members plus the optional third). New exports: `programRunReachOf`, `PROGRAM_RUN_REACHES`, `ProgramRunReach`, `ProgramRunReachOutcome`, `readShellCommand`, `isFullyReadable`, `MAX_SHELL_READ_CHARS`, `SHELL_WRAPPER_TABLE`, `ShellCommandShape`/`ShellSegment`/`ShellWord`/`ShellWrapperName`, `OrgRuleVerdict`. Retired (internal, never exported): `stripRedirections`, `stripFdDuplications`, `ruleLaneSegmentsOf`, `ruleAdmitsProgramRun`.
22
+ - **The allow side is byte-untouched:** `ruleAdmitsCommand` keeps its whole-string refusal of every shell construct (`rm -r x > f` is admitted by nothing; a quoted spelling is not an allow's equal) — and the deny now reads the quoted spelling, so an exact allow row minted for `rm "-r" x` no longer outruns a `Bash(rm -r:*)` deny.
23
+ - **Merged-tree round (four `not_reached` blind spots, each now `unreadable` or reached at all three lanes, the classifier never consulted):** ① an extended glob's INTERIOR is read for substitutions (`echo @($(rm -r x))` — the pattern's interior runs a program). ② a clustered `xargs -tI R` records the replacement token by the resolved letter (`-tI R rm -R x` no longer reads `rm -R x` as literal). ③ **executors are rows of the same closed table**: `viaShell` now means "evaluates its operands as shell input" (a command string, a script file, an expression) and covers `eval`, `source`, `.`, `trap`, `let`, `sh`/`bash`/`zsh`/`dash`/`ksh`/`fish`/`csh`/`tcsh` beside `watch`/`script` — the string is not split and judged (upstream splits on whitespace; the strict word stands), so `bash -c 'rm -r x'`, `eval 'rm -r x'`, `bash cleanup.sh`, `sudo bash -c …` ask once when a deny/ask rule stands (7.9.0: silence); a new row member `executes` names the words after which a NON-wrapper runs a command anywhere in its argv — `find` with `-exec`/`-execdir`/`-ok`/`-okdir` (such a word, or any expansion among `find`'s arguments, asks; `find . -name '*.ts'` stays silent). The executor's own run is still read (`Bash(eval:*)`, `Bash(find:*)` reach). ④ indirect arithmetic through a parameter SUBSCRIPT (`${arr[a]}`, `${arr[$i]}`, `${#arr[n]}` — bash evaluates an indexed subscript recursively, so a stored `arr[$(…)]` runs; `[@]`/`[*]`/a number name nothing) and through the `(( … ))` command (read as the arithmetic command — a run-less piece — instead of two boundaries around a phantom program) are unreadable, the same law as `$((a))`. `isFullyReadable` (exported; not read by the offer lane, which mints from its own reader) answers false for every shape above. **The round's two adversarial passes (all findings adopted):** a `$…` inside arithmetic or a subscript is a name too (`(( $1 ))`, `${arr[$1]}`); the arithmetic command is read after `time`/`time -p`/`{`/an adjacent `!`, and its interior is judged safe only when it is digits, operators and spaces — `((:);)` (nested subshells bash re-reads after the arithmetic parse fails) asks instead of vanishing; an `xargs -I` token carrying an expansion stops the peel; `$[…]` (legacy arithmetic) and a slice offset/length naming a variable (`${x:a}`, `${x:0:a}`) are hidden runs like a subscript; **builtins whose operands NAME variables** (`VARIABLE_OPERAND_BUILTINS` in the table module: `printf`, `unset`, `read`, `mapfile`/`readarray`, `declare`/`typeset`/`local`/`export`/`readonly`, `test`/`[`/`[[`) mark a hidden run when an operand spells an array element with a non-numeric subscript (`printf -v 'arr[a]'`, `-v'arr[a]'`, `unset arr[a]`, `declare arr[a]=1`, `[[ -v arr[a] ]]`) or an integer-attribute assignment (`declare -i x=a`) — judged at every peel candidate (`command printf -v …`); `compgen`/`complete` with `-C`/`-F` join the `executes` rows; an unclosed `((` is an unreadable tail in linear time. Not judged (documented, deliberate — the price would be a prompt on every `export FOO="$BAR"` / `[[ $n -gt 0 ]]`): an operand that is WHOLLY an expansion to one of those builtins, and an arithmetic comparison's operands.
24
+ - **@cli** (card copy): an ask of origin `rule_store_unavailable` may now be "your command could not be read against your deny/ask rules (<reason>)", not only "the rule store could not be read" — the decision's `message` carries the reason; a card that hard-codes store wording for the origin should read the message. An org ask's message has a second form (`… may reach this call, which could not be read against it (<reason>)`). **@server**: zero required changes (both new members are optional and screened; `OrgGateVerdict` producers may add `unreadable` on an ask verdict).
25
+
3
26
  ## 7.9.0 — 2026-09-08
4
27
 
5
28
  The resume decision ladder leaves runtask.ts, and the two `runtask.ts` seams design/388 slice 1 shipped unwired in 7.8.0 are wired.
@@ -42,9 +42,13 @@ import type { PermissionResult } from "./tool-policy.js";
42
42
  * - `org_rule` — an organization policy rule asked (or stamped an existing ask): "no configuration
43
43
  * clears this, a person nods".
44
44
  * - `rule_store_unavailable` — the deployment wired a persisted-rule store and could not READ it for
45
- * this call (a read failure, a timeout): the person's own deny/ask rows cannot be enforced, so the
46
- * call fails closed the way an unreadable org snapshot does — an allow tightens to an ask a person
47
- * must clear, and no automatic lane may answer ("does one of my rules forbid this?" is unanswered).
45
+ * this call (a read failure, a timeout), OR could not read the CALL against the person's standing
46
+ * deny/ask rows (the tightening lexer's `unreadable` word: an expansion where a rule reads a word, an
47
+ * unterminated quote, a syntax error the answer's `reason`): the person's own deny/ask rows cannot
48
+ * be enforced for this call, so it fails closed the way an unreadable org snapshot does — an allow
49
+ * tightens to an ask a person must clear, and no automatic lane may answer ("does one of my rules
50
+ * forbid this?" is unanswered). One word for both, deliberately: the AUTHORITY semantics (who may
51
+ * answer, what clears it, what the evidence records) are identical, and only the mechanism differs.
48
52
  * - `hook` — a deployment's PreToolUse screening face raised or co-raised the ask; the word is
49
53
  * engine-stamped at the fold and is the deployment's own per-call adjudication.
50
54
  * - `ask_rule` — an EXPLICIT `ask` permission rule matched: a person's standing "ask me each time",
@@ -843,15 +843,10 @@ export type PendingAction = {
843
843
  * display `family` + declared `pathTarget` — off the same roster row at the same mint. The pre-CAS resume-edit
844
844
  * recheck has no roster yet; it rebuilds the call face from these (`parkedCallFaceOf`) and judges the edit as
845
845
  * the live gate judged the original (merge-seam H1: without them a caller slot fell to the catalog's slot for
846
- * the NAME). Any member absent ⇒ pre-388 row ⇒ the recheck falls to the catalog, never a widened grant.
846
+ * the NAME). Any member absent ⇒ pre-388 row ⇒ the recheck falls to the catalog, never a widened grant — including #635's resolution members, which a row written before them simply lacks (they then read as their documented defaults).
847
847
  */
848
848
  family?: import("./tool-face.js").ToolFamily;
849
- pathTarget?: {
850
- param: string;
851
- aliases: string[];
852
- access: "read" | "create" | "edit";
853
- skillScopeEligible: boolean;
854
- };
849
+ pathTarget?: import("./tool-face.js").ToolRosterPathTarget;
855
850
  /**
856
851
  * #438 (additive; no checkpoint-version bump — the `reminderMark` precedent: an optional field an
857
852
  * older reader ignores. Unlike that precedent seat — which resume DOES read, through its
@@ -0,0 +1,43 @@
1
+ import type { ToolCallPathTarget } from "./tool-face.js";
2
+ import { type ToolCallLike } from "./tool-registry.js";
3
+ /** The bases of ONE CALL a target resolves against: the task root, and the live tracked working directory
4
+ * (absent when no tracker moved or the caller keeps none). Facts of the call, never of the tool. */
5
+ export interface ToolPathBases {
6
+ readonly root?: string | undefined;
7
+ readonly cwd?: string | undefined;
8
+ }
9
+ /**
10
+ * WHICH base this target's relative spellings resolve against, as SPELLED by the caller (no normalization:
11
+ * the tool hands this straight to `canonicalizeTarget`, which resolves win-form and symlinked spellings the
12
+ * fences must not pre-fold). `undefined` ⇒ the deployment gave no such base, so a relative spelling in this
13
+ * slot names nothing this reader can locate.
14
+ */
15
+ export declare function pathTargetBaseOf(pt: Pick<ToolCallPathTarget, "base"> | undefined, bases: ToolPathBases): string | undefined;
16
+ /**
17
+ * design/116 批④ #8 (CC 2.1.198 RWp, bundle :332987) — decompose an ABSOLUTE glob pattern into a base
18
+ * directory + relative pattern: cut at the last separator before the first glob metachar (`* ? [ {`); a
19
+ * pattern with no metachar splits dirname/basename. `undefined` for a relative pattern (the caller keeps it
20
+ * as-is) or when no usable base can be derived.
21
+ *
22
+ * Lives HERE rather than beside the search engine because two readers need the identical cut: Glob's own
23
+ * scope resolution and the path fences that must know which directory an absolute pattern really searches.
24
+ */
25
+ export declare function splitAbsoluteGlobPattern(pattern: string): {
26
+ baseDir: string;
27
+ relativePattern: string;
28
+ } | undefined;
29
+ /**
30
+ * The absolute, LEXICAL-NORMAL path this call is about under ONE reading of its path slot, or `undefined`
31
+ * when that reading locates none. `pt` is the reading (design/388 B6/B17: the PROTECTIVE target for a
32
+ * tightening fence, the DECLARED target for a grant) — this function never picks between the two.
33
+ *
34
+ * The order is the tool's own:
35
+ * ① a spelling in the slot wins — absolute as spelled, relative against {@link pathTargetBaseOf}'s base;
36
+ * ② otherwise, when the declaration says an absent slot means `none`, the call is about no path;
37
+ * ③ otherwise a `patternParam`'s ABSOLUTE pattern supplies its own base directory;
38
+ * ④ otherwise the base itself (the tool searches all of it).
39
+ *
40
+ * A base the deployment did not supply yields `undefined`: a relative spelling with nothing to resolve
41
+ * against names no file, and claiming it names `/<spelling>` was a fabrication no fence should judge.
42
+ */
43
+ export declare function effectivePathTargetOf(call: ToolCallLike, pt: ToolCallPathTarget | undefined, bases: ToolPathBases): string | undefined;
@@ -0,0 +1,56 @@
1
+ import { isAbsolutePathForm } from "../tools/fs/safety.js";
2
+ import { lexicalNormalAbsolutePathOf } from "./permission-rule-model.js";
3
+ import { pathTargetValue } from "./tool-registry.js";
4
+ export function pathTargetBaseOf(pt, bases) {
5
+ return (pt?.base ?? "cwd") === "root" ? bases.root : (bases.cwd ?? bases.root);
6
+ }
7
+ export function splitAbsoluteGlobPattern(pattern) {
8
+ if (!isAbsolutePathForm(pattern))
9
+ return undefined;
10
+ const m = /[*?[{]/.exec(pattern);
11
+ if (!m) {
12
+ const i = Math.max(pattern.lastIndexOf("/"), pattern.lastIndexOf("\\"));
13
+ if (i <= 0)
14
+ return undefined;
15
+ return { baseDir: pattern.slice(0, i), relativePattern: pattern.slice(i + 1) };
16
+ }
17
+ const prefix = pattern.slice(0, m.index);
18
+ const cut = Math.max(prefix.lastIndexOf("/"), prefix.lastIndexOf("\\"));
19
+ if (cut === -1)
20
+ return undefined;
21
+ let baseDir = pattern.slice(0, cut);
22
+ if (baseDir === "" && cut === 0)
23
+ baseDir = "/";
24
+ if (/^[A-Za-z]:$/.test(baseDir))
25
+ baseDir = baseDir + pattern[cut];
26
+ if (!baseDir)
27
+ return undefined;
28
+ return { baseDir, relativePattern: pattern.slice(cut + 1) };
29
+ }
30
+ function under(base, rest) {
31
+ return `${base === "/" ? "" : base}/${rest}`;
32
+ }
33
+ export function effectivePathTargetOf(call, pt, bases) {
34
+ if (pt === undefined)
35
+ return undefined;
36
+ const declaredBase = pathTargetBaseOf(pt, bases);
37
+ const spelled = pathTargetValue(call.args, pt);
38
+ if (spelled !== undefined)
39
+ return resolveSpelling(spelled, declaredBase, bases);
40
+ if ((pt.absent ?? "none") === "none")
41
+ return undefined;
42
+ if (pt.patternParam !== undefined) {
43
+ const pattern = pathTargetValue(call.args, { param: pt.patternParam });
44
+ const split = pattern === undefined ? undefined : splitAbsoluteGlobPattern(pattern);
45
+ if (split !== undefined)
46
+ return resolveSpelling(split.baseDir, declaredBase, bases);
47
+ }
48
+ return declaredBase === undefined ? undefined : lexicalNormalAbsolutePathOf(declaredBase);
49
+ }
50
+ function resolveSpelling(spelled, declaredBase, bases) {
51
+ if (spelled.startsWith("/"))
52
+ return lexicalNormalAbsolutePathOf(spelled);
53
+ const landsOn = isAbsolutePathForm(spelled) ? pathTargetBaseOf(undefined, bases) : declaredBase;
54
+ const normalBase = landsOn === undefined ? undefined : lexicalNormalAbsolutePathOf(landsOn);
55
+ return normalBase === undefined ? undefined : lexicalNormalAbsolutePathOf(under(normalBase, spelled));
56
+ }
@@ -61,6 +61,14 @@ export interface EngineNotice {
61
61
  * its explicit {@link NOTICE_AUDIENCE} row (#433 made the registry total over the catalog —
62
62
  * no engine-minted code is audience-defaulted any more). The refusal itself
63
63
  * (`mcp.server_revoked`) is a tool RESULT code, not a notice.
64
+ * - `"mcp.injection_dropped"` (L-167) — an entry of the user's own request-lane MCP injection
65
+ * was NOT mounted for the leg. The HOST owns the drop sites and supplies the facts; CORE composes
66
+ * the notice ({@link import("./mcp-injection-drop.js").mcpInjectionDroppedNotice}, the one mint).
67
+ * `detail: { sessionId, server, reason, field? }` — `reason` is the closed set
68
+ * `malformed_entry` / `name_reserved_by_deployment` / `gate_closed` / `over_cap`
69
+ * (`MCP_INJECTION_DROP_REASONS`, CLOSED-SETS), `field` names the bad key for `malformed_entry`
70
+ * when known. `"user"` audience; `sessionId` REQUIRED at the mint (a user row with no session
71
+ * routes nowhere — refused, never fabricated). Dedup unit: per leg per server name, the host's.
64
72
  * - `"config.models_swapped"` — `Runner.swapModels` replaced the model catalog generation
65
73
  * (zero-restart model switching). `detail: { models, tiers, occurrenceId }` — key COUNTS only,
66
74
  * never the
@@ -1,6 +1,7 @@
1
1
  import { canonicalizeTarget, writeTargetPath } from "../tools/fs/safety.js";
2
2
  import { isWithin } from "./runner/session-rule-policy.js";
3
3
  import { declaredPathTargetOf, isProtectedWrite } from "./tool-registry.js";
4
+ import { pathTargetBaseOf } from "./effective-path-target.js";
4
5
  const ask = (message) => ({ action: "ask", message, decisionReason: "rule" });
5
6
  export function createFsWriteGatePolicy(opts) {
6
7
  const { env, rootPath, defaultWrite } = opts;
@@ -26,7 +27,7 @@ export function createFsWriteGatePolicy(opts) {
26
27
  if (path === undefined) {
27
28
  return ask(`write tool "${req.toolName}" requires approval: the call has no resolvable path target to confine${declared === undefined ? " (the tool declares no path target)" : ""}${advisory}`);
28
29
  }
29
- const canon = await canonicalizeTarget(env, path, signal, req.cwd ?? rootPath);
30
+ const canon = await canonicalizeTarget(env, path, signal, pathTargetBaseOf(declared, { root: rootPath, cwd: req.cwd }));
30
31
  if (!canon.ok) {
31
32
  return ask(`write to "${path}" requires approval: its real target could not be resolved (${canon.message})${advisory}`);
32
33
  }
@@ -45,6 +45,7 @@ export declare function normalizeOrgGateVerdict(answer: unknown, unreadable: str
45
45
  export declare function normalizePersistedRuleHit(hit: PersistedRuleAnswer): {
46
46
  hit?: PersistedRuleHit;
47
47
  unreadable?: true;
48
+ reason?: string;
48
49
  coverage?: readonly import("./permission-rule-model.js").SegmentCoverage[];
49
50
  };
50
51
  /**
@@ -70,7 +70,9 @@ export function normalizeOrgGateVerdict(answer, unreadable) {
70
70
  const rule = ownDataValue(verdict, "rule");
71
71
  if ((behavior !== "deny" && behavior !== "ask") || typeof rule !== "string")
72
72
  return clean({ status: "unavailable", disclosures: [unreadable] });
73
- return clean({ status: "available", verdict: clean({ behavior: behavior, rule }), ...revisionCell });
73
+ const reason = ownDataValue(verdict, "unreadable");
74
+ const unreadableCell = behavior === "ask" && typeof reason === "string" ? { unreadable: reason } : {};
75
+ return clean({ status: "available", verdict: clean({ behavior: behavior, rule, ...unreadableCell }), ...revisionCell });
74
76
  }
75
77
  function copiedUsableDots(dots) {
76
78
  let copied;
@@ -93,8 +95,10 @@ function copiedUsableDots(dots) {
93
95
  export function normalizePersistedRuleHit(hit) {
94
96
  if (!isPlainOwnRecord(hit))
95
97
  return {};
96
- if (ownDataValue(hit, "unreadable") === true)
97
- return { unreadable: true };
98
+ if (ownDataValue(hit, "unreadable") === true) {
99
+ const reason = ownDataValue(hit, "reason");
100
+ return typeof reason === "string" ? { unreadable: true, reason } : { unreadable: true };
101
+ }
98
102
  const rulesRaw = ownDataValue(hit, "rules");
99
103
  if (rulesRaw === undefined) {
100
104
  const coverageRaw = ownDataValue(hit, "segmentCoverage");
@@ -219,7 +223,7 @@ export async function runGateLanes(pass) {
219
223
  orgEvidence = { ...revisionCell, ruleAbsent: "no_match" };
220
224
  return { decision: decided, org: undefined };
221
225
  }
222
- const { behavior, rule } = answer.verdict;
226
+ const { behavior, rule, unreadable } = answer.verdict;
223
227
  orgEvidence = { ...revisionCell, rule };
224
228
  if (behavior === "deny") {
225
229
  orgTightenCount += 1;
@@ -235,7 +239,9 @@ export async function runGateLanes(pass) {
235
239
  ? { ...decided, requiresRealApproval: true }
236
240
  : {
237
241
  action: "ask",
238
- message: `an organization policy rule (${rule}) requires approval for this call`,
242
+ message: unreadable !== undefined
243
+ ? `an organization policy rule (${rule}) may reach this call, which could not be read against it (${unreadable}) — a person must decide`
244
+ : `an organization policy rule (${rule}) requires approval for this call`,
239
245
  decisionReason: ORG_RULE_DECISION_REASON,
240
246
  requiresRealApproval: true,
241
247
  },
@@ -103,7 +103,7 @@ export type NoticeAudience = "user" | "operator";
103
103
  * src/ for notice mint shapes and names any code that is minted but unregistered, or registered but
104
104
  * no longer minted.
105
105
  */
106
- export declare const ENGINE_NOTICE_CODES: readonly ["config.autocompact_window_clamped", "config.env_timeout_discarded", "config.materialize_env_discarded", "config.models_swapped", "config.read_face_deployment_clamped", "config.tool_model_gate_removed", "config.tool_model_gate_unknown_class", "config.tool_model_gate_env_invalid", "config.tool_card_undeclared", "config.tool_face_undeclared", "config.tool_face_invalid", "config.durable_gate_unavailable", "config.peer_admission_out_of_range", "config.peer_lane_unmounted", "peer.inbound_disposition", "peer.held_settled", "peer.idle_subscription", "classifier.denial_limit", "checkpoint.execution_outcome_unrecorded", "delegation.transcript_integrity", "mcp.revocation_probe_failed", "workflow.governance_key_stripped", "workflow.agent_option_ignored", "memory.session_polluted", "memory.harvest_quarantined", "memory.delegation_static_mark_waived", "memory.content_class_declared", "memory.hold_opened", "memory.hold_released", "memory.hold_disposed", "memory.consolidation_recommended", "memory.consolidation_committed", "memory.consolidation_conflict", "memory.consolidation_incomplete", "memory.consolidation_refused", "memory.consolidation_withheld", "route.fallback_to_primary", "route.base_url_changed_key_unchanged", "task.user_steer_undrained", "task.user_followup_undrained", "steering.parked_input_blocked", "task.turn_interrupted", "task.halt_unconsumed", "task.late_approval", "memory.capture_opted_out", "memory.capture_optout_unpersisted", "tool_result.offload_put_failed"];
106
+ export declare const ENGINE_NOTICE_CODES: readonly ["config.autocompact_window_clamped", "config.env_timeout_discarded", "config.materialize_env_discarded", "config.models_swapped", "config.read_face_deployment_clamped", "config.tool_model_gate_removed", "config.tool_model_gate_unknown_class", "config.tool_model_gate_env_invalid", "config.tool_card_undeclared", "config.tool_face_undeclared", "config.tool_face_invalid", "config.durable_gate_unavailable", "config.peer_admission_out_of_range", "config.peer_lane_unmounted", "peer.inbound_disposition", "peer.held_settled", "peer.idle_subscription", "classifier.denial_limit", "checkpoint.execution_outcome_unrecorded", "delegation.transcript_integrity", "mcp.injection_dropped", "mcp.revocation_probe_failed", "workflow.governance_key_stripped", "workflow.agent_option_ignored", "memory.session_polluted", "memory.harvest_quarantined", "memory.delegation_static_mark_waived", "memory.content_class_declared", "memory.hold_opened", "memory.hold_released", "memory.hold_disposed", "memory.consolidation_recommended", "memory.consolidation_committed", "memory.consolidation_conflict", "memory.consolidation_incomplete", "memory.consolidation_refused", "memory.consolidation_withheld", "route.fallback_to_primary", "route.base_url_changed_key_unchanged", "task.user_steer_undrained", "task.user_followup_undrained", "steering.parked_input_blocked", "task.turn_interrupted", "task.halt_unconsumed", "task.late_approval", "memory.capture_opted_out", "memory.capture_optout_unpersisted", "tool_result.offload_put_failed"];
107
107
  /** A code this engine mints (see {@link ENGINE_NOTICE_CODES}). NOT the type of
108
108
  * `EngineNotice.code`, which stays `string` — a host forwarding its own notices through the same
109
109
  * sink is a supported shape, and narrowing that field would break it. */
@@ -113,6 +113,7 @@ export const ENGINE_NOTICE_CODES = [
113
113
  "classifier.denial_limit",
114
114
  "checkpoint.execution_outcome_unrecorded",
115
115
  "delegation.transcript_integrity",
116
+ "mcp.injection_dropped",
116
117
  "mcp.revocation_probe_failed",
117
118
  "workflow.governance_key_stripped",
118
119
  "workflow.agent_option_ignored",
@@ -177,6 +178,7 @@ const NOTICE_AUDIENCE_TABLE = {
177
178
  "peer.idle_subscription": "user",
178
179
  "delegation.transcript_integrity": "operator",
179
180
  "checkpoint.execution_outcome_unrecorded": "operator",
181
+ "mcp.injection_dropped": "user",
180
182
  "mcp.revocation_probe_failed": "operator",
181
183
  "workflow.governance_key_stripped": "operator",
182
184
  "workflow.agent_option_ignored": "operator",
@@ -993,10 +993,13 @@ export interface ParkAttemptFailed {
993
993
  */
994
994
  export type OrgGateVerdict = {
995
995
  status: "available";
996
- /** The org rule that speaks for this call, if any. Deny outranks ask; there is no allow bucket. */
996
+ /** The org rule that speaks for this call, if any. Deny outranks ask; there is no allow bucket. An
997
+ * `unreadable` member (the lexer's reason) says the call could not be READ against `rule` — an
998
+ * org rule that may reach it — so `behavior` is `ask` and the message names the reason. */
997
999
  verdict?: {
998
1000
  behavior: "deny" | "ask";
999
1001
  rule: string;
1002
+ unreadable?: string;
1000
1003
  };
1001
1004
  /** design/252 G-2 (additive): the REVISION of the snapshot this answer was read off — the
1002
1005
  * published-policy version an auditor reconciles the resulting decision against. Present on the
@@ -1057,6 +1060,12 @@ export interface PersistedRuleHit {
1057
1060
  */
1058
1061
  export interface PersistedRuleUnreadable {
1059
1062
  readonly unreadable: true;
1063
+ /** WHY the lane could not read: absent for a store read failure; for a call the lane could not READ
1064
+ * against the person's standing deny/ask rows (an expansion where a rule reads a word, an unterminated
1065
+ * quote, a syntax error — `programRunReachOf`'s `unreadable` word), the lexer's reason. The DECISION
1066
+ * is the same fail-closed one either way (the question "does one of my rules forbid this?" is
1067
+ * unanswered); the reason is what the ask's message says instead of "the store could not be read". */
1068
+ readonly reason?: string;
1060
1069
  }
1061
1070
  /**
1062
1071
  * design/375 §5.2② — the CLEAN NEGATIVE that still carries the per-segment coverage table: no rule
@@ -0,0 +1,74 @@
1
+ /**
2
+ * `mcp.injection_dropped` — the one mint of the notice a host raises when an entry of the user's own
3
+ * MCP injection (a `.mcp.json` row handed to the engine through the host's request lane) was NOT
4
+ * mounted for the leg. L-167: three drop sites in the host's intake and one cap site each produced
5
+ * their own wording and none reached the user; the engine did not see the entry at all (it never
6
+ * reached `prepareTask`), so no core-side notice could stand for it.
7
+ *
8
+ * Division of labour, the settlement pattern (`settlement.single_mint`): the HOST supplies FACTS
9
+ * (which session, which server name, which of four reasons, optionally which field was bad) and
10
+ * CORE composes the notice — the code word, the audience row, the message text per reason. A host
11
+ * never spells the code or the text itself: a second wording is a second source that drifts, and a
12
+ * host-spelled code is not in {@link import("./governance-codes.js").ENGINE_NOTICE_CODES}'s
13
+ * mint scan, so its audience row could silently rot.
14
+ *
15
+ * Audience `"user"`: the dropped entry is the user's own configuration and the recovery (rename the
16
+ * server, fix the field, trim the list) is in their hands — an operator channel does not reach them.
17
+ * `sessionId` is therefore REQUIRED at the mint (the routing half of a user row: a user disclosure
18
+ * that cannot name its session has nowhere to be delivered) — a host with no session has no user to
19
+ * tell and must not mint. Dedup unit: per leg per server name — the HOST's obligation (it owns the
20
+ * leg's intake loop); this module is pure.
21
+ */
22
+ import type { AssertAllKeysHandled } from "./ask-origin.js";
23
+ import type { EngineNotice } from "./engine-notice.js";
24
+ /**
25
+ * The closed set of drop reasons ({@link McpInjectionDropFacts.reason}) — the host's intake
26
+ * enumerates exactly these, and a consumer rendering the notice keys on the word:
27
+ * · `malformed_entry` — the entry did not parse as a server declaration (a missing
28
+ * `command`/`url`, a `toolFaces` value of the wrong shape, …);
29
+ * `field` names the offending key when the host knows it;
30
+ * · `name_reserved_by_deployment` — the entry's server name collides with one the deployment
31
+ * itself mounts, and the deployment's wins;
32
+ * · `gate_closed` — the deployment does not accept request-lane MCP injection on
33
+ * this leg at all (a multi-tenant posture): the whole list is
34
+ * ignored, one notice per entry;
35
+ * · `over_cap` — the entry sits beyond the deployment's injection cap.
36
+ */
37
+ export declare const MCP_INJECTION_DROP_REASONS: readonly ["malformed_entry", "name_reserved_by_deployment", "gate_closed", "over_cap"];
38
+ export type McpInjectionDropReason = (typeof MCP_INJECTION_DROP_REASONS)[number];
39
+ /**
40
+ * The user-facing sentence per reason — the disposition table over {@link MCP_INJECTION_DROP_REASONS}
41
+ * (one reason, one wording; a consumer that wants its own phrasing keys on `detail.reason`, never on
42
+ * this text). Each names the recovery, because the reader is the person who can perform it.
43
+ */
44
+ export declare const MCP_INJECTION_DROP_TEXT: {
45
+ readonly malformed_entry: "its declaration could not be read — fix the entry and start a new session";
46
+ readonly name_reserved_by_deployment: "the deployment mounts a server of that name and its own wins — rename the entry";
47
+ readonly gate_closed: "this deployment does not accept MCP servers from the request on this leg — ask the operator, or use the deployment's servers";
48
+ readonly over_cap: "the request named more MCP servers than this deployment mounts per leg — trim the list";
49
+ };
50
+ export type DropTextCoversEveryReason = AssertAllKeysHandled<Exclude<McpInjectionDropReason, keyof typeof MCP_INJECTION_DROP_TEXT>>;
51
+ /** What a host knows at the drop site — facts only, no words of the engine's vocabulary composed. */
52
+ export interface McpInjectionDropFacts {
53
+ /** The session whose injection carried the entry — the notice's routing key, required (see module note). */
54
+ readonly sessionId: string;
55
+ /** The entry's server name as the user spelled it (the message quotes it verbatim). */
56
+ readonly server: string;
57
+ readonly reason: McpInjectionDropReason;
58
+ /** `malformed_entry` only, optional: the key the host found bad, so the user knows what to edit. */
59
+ readonly field?: string;
60
+ }
61
+ /**
62
+ * Compose the `mcp.injection_dropped` notice from a host's facts.
63
+ *
64
+ * @contract mcp.injection_dropped.single_mint — the `mcp.injection_dropped` notice is composed by CORE
65
+ * from a host's drop FACTS (session, server name, one of four reasons, optionally the bad field); a
66
+ * host never spells the code, the audience or the sentence, and a fact outside the vocabulary (an
67
+ * unlisted reason word, an empty session or server name) is refused at the mint with a `TypeError`
68
+ * rather than shaped into a notice that would route nowhere or read as an unknown word downstream.
69
+ *
70
+ * `detail: { sessionId, server, reason, field? }` — `sessionId` is lifted onto the typed key by
71
+ * {@link import("./engine-notice.js").deliverEngineNotice}, the one throat, so the two spellings
72
+ * cannot disagree; `field` is present only when the host supplied a non-empty one.
73
+ */
74
+ export declare function mcpInjectionDroppedNotice(facts: McpInjectionDropFacts): EngineNotice;
@@ -0,0 +1,27 @@
1
+ export const MCP_INJECTION_DROP_REASONS = ["malformed_entry", "name_reserved_by_deployment", "gate_closed", "over_cap"];
2
+ export const MCP_INJECTION_DROP_TEXT = {
3
+ malformed_entry: "its declaration could not be read — fix the entry and start a new session",
4
+ name_reserved_by_deployment: "the deployment mounts a server of that name and its own wins — rename the entry",
5
+ gate_closed: "this deployment does not accept MCP servers from the request on this leg — ask the operator, or use the deployment's servers",
6
+ over_cap: "the request named more MCP servers than this deployment mounts per leg — trim the list",
7
+ };
8
+ const isReason = (x) => typeof x === "string" && MCP_INJECTION_DROP_REASONS.includes(x);
9
+ export function mcpInjectionDroppedNotice(facts) {
10
+ const { sessionId, server, reason, field } = facts;
11
+ if (typeof sessionId !== "string" || sessionId.length === 0) {
12
+ throw new TypeError("mcp.injection_dropped: sessionId is required — a user-audience notice with no session routes nowhere");
13
+ }
14
+ if (typeof server !== "string" || server.length === 0) {
15
+ throw new TypeError("mcp.injection_dropped: server name is required");
16
+ }
17
+ if (!isReason(reason)) {
18
+ throw new TypeError(`mcp.injection_dropped: reason ${JSON.stringify(reason)} is not one of ${MCP_INJECTION_DROP_REASONS.join("/")}`);
19
+ }
20
+ const fieldSeat = typeof field === "string" && field.length > 0 ? { field } : {};
21
+ const where = reason === "malformed_entry" && typeof field === "string" && field.length > 0 ? ` (field \`${field}\`)` : "";
22
+ return {
23
+ code: "mcp.injection_dropped",
24
+ message: `MCP server "${server}" from this request was not mounted: ${MCP_INJECTION_DROP_TEXT[reason]}${where}.`,
25
+ detail: { sessionId, server, reason, ...fieldSeat },
26
+ };
27
+ }