@sema-agent/core 7.35.2 → 7.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/CHANGELOG.md +142 -0
  2. package/dist/brain/anthropic.js +6 -6
  3. package/dist/brain/errors.d.ts +1 -14
  4. package/dist/brain/errors.js +2 -6
  5. package/dist/brain/open-responses.js +7 -7
  6. package/dist/brain/openai.js +7 -7
  7. package/dist/brain/provider-echo-shape.d.ts +26 -0
  8. package/dist/brain/provider-echo-shape.js +12 -0
  9. package/dist/brain/tool-call-id.d.ts +27 -15
  10. package/dist/brain/tool-call-id.js +43 -0
  11. package/dist/core/ask-origin.d.ts +35 -5
  12. package/dist/core/ask-origin.js +23 -4
  13. package/dist/core/ask-question.d.ts +0 -30
  14. package/dist/core/ask-question.js +3 -2
  15. package/dist/core/checkpoint-store.d.ts +18 -6
  16. package/dist/core/checkpoint-store.js +2 -1
  17. package/dist/core/context-edit.d.ts +4 -0
  18. package/dist/core/context-edit.js +5 -2
  19. package/dist/core/decision-receipt.d.ts +21 -0
  20. package/dist/core/decision-receipt.js +63 -0
  21. package/dist/core/engine-notice.d.ts +15 -5
  22. package/dist/core/gate-fold.js +2 -1
  23. package/dist/core/gate-lanes.js +20 -23
  24. package/dist/core/gate-outcome.d.ts +7 -5
  25. package/dist/core/gate-outcome.js +5 -1
  26. package/dist/core/governance-codes.d.ts +1 -1
  27. package/dist/core/governance-codes.js +2 -0
  28. package/dist/core/hooks.d.ts +14 -2
  29. package/dist/core/hooks.js +7 -3
  30. package/dist/core/mode-policy.js +2 -1
  31. package/dist/core/park-selfcheck.js +2 -0
  32. package/dist/core/permission-rule-model.d.ts +2 -2
  33. package/dist/core/permission-rule-model.js +2 -2
  34. package/dist/core/permission-rules.js +2 -5
  35. package/dist/core/runner/active-skill-scope.js +5 -5
  36. package/dist/core/runner/decide-continuation.js +2 -1
  37. package/dist/core/runner/denial-limit-arms.d.ts +28 -18
  38. package/dist/core/runner/denial-limit-arms.js +11 -7
  39. package/dist/core/runner/inherited-ask-grants.d.ts +32 -31
  40. package/dist/core/runner/inherited-ask-grants.js +5 -0
  41. package/dist/core/runner/permission-rule-lanes.d.ts +3 -0
  42. package/dist/core/runner/permission-rule-lanes.js +7 -6
  43. package/dist/core/runner/prepare-context-lane.d.ts +4 -0
  44. package/dist/core/runner/prepare-context-lane.js +7 -5
  45. package/dist/core/runner/prepare-gate-stations.js +17 -12
  46. package/dist/core/runner/prepare-inherited-gate.js +1 -1
  47. package/dist/core/runner/prepare-park-ask.d.ts +4 -2
  48. package/dist/core/runner/prepare-park-ask.js +8 -11
  49. package/dist/core/runner/prepare-policy-chain.d.ts +5 -8
  50. package/dist/core/runner/prepare-policy-chain.js +50 -31
  51. package/dist/core/runner/prepare-safety-scan.js +9 -2
  52. package/dist/core/runner/prepare-task.js +3 -3
  53. package/dist/core/runner/resume-apply.js +2 -1
  54. package/dist/core/runner/run-attachment-seats.js +3 -1
  55. package/dist/core/runner/run-harness-handlers.js +6 -1
  56. package/dist/core/runner/run-leg.js +11 -9
  57. package/dist/core/runner/run-recovery-lanes.js +2 -0
  58. package/dist/core/runner/session-rule-policy.js +5 -4
  59. package/dist/core/runner/tool-call-id-fold-seat.d.ts +13 -0
  60. package/dist/core/runner/tool-call-id-fold-seat.js +31 -0
  61. package/dist/core/sensitive-path-policy.js +2 -2
  62. package/dist/core/side-query.d.ts +5 -1
  63. package/dist/core/side-query.js +5 -1
  64. package/dist/core/store-contracts/checkpoint-store-contract.js +18 -0
  65. package/dist/core/tool-name-readings.d.ts +370 -0
  66. package/dist/core/tool-name-readings.js +137 -0
  67. package/dist/core/tool-policy.d.ts +79 -89
  68. package/dist/core/tool-policy.js +86 -49
  69. package/dist/core/tool-registry.d.ts +1 -31
  70. package/dist/core/tool-registry.js +2 -22
  71. package/dist/core/tool-result-budget.d.ts +3 -0
  72. package/dist/core/tool-result-budget.js +5 -3
  73. package/dist/core/tool-roster.d.ts +30 -9
  74. package/dist/core/tool-roster.js +45 -5
  75. package/dist/engine/llm/types.d.ts +4 -0
  76. package/dist/index.d.ts +1 -1
  77. package/dist/index.js +1 -1
  78. package/dist/tools/fs/file-read-check.d.ts +1 -1
  79. package/dist/tools/fs/file-read-check.js +4 -3
  80. package/dist/tools/fs/shell-read-check.d.ts +15 -1
  81. package/dist/tools/fs/shell-read-check.js +5 -2
  82. package/package.json +2 -1
  83. package/test/export-surface.snapshot.json +7 -1
  84. package/wire-changes/core@7.36.0.json +288 -0
  85. package/wire-changes/core@7.37.0.json +405 -0
@@ -4,10 +4,10 @@ import { type Settlement } from "./gate-outcome.js";
4
4
  /** A tool call presented to a policy before it executes. */
5
5
  /** design/388 B6 — the per-call face (see {@link ToolCallRequest.face}). */
6
6
  export interface ToolCallFace {
7
- /** Every name a tool-name rule may spell this tool by: the tool's wire name first, then each alias that resolves to
8
- * it on the leg's roster (`rosterToolName`). A rule reaches the call iff its tool name is on this list — read it
9
- * through {@link ruleNamesOf}, never off `toolName` alone. Absent on a face rebuilt from a park row that predates it
10
- * (the call's own name is then the list). */
7
+ /** Every name a tool-name rule may spell this tool by: the name the call was made under first (the model's spelling),
8
+ * then the tool's wire name and each other alias that resolves to it on the leg's roster (`rosterToolName`). A rule
9
+ * reaches the call iff its tool name is on this list — read it through `ruleNamesOf`, never off `toolName` alone.
10
+ * Absent on a face rebuilt from a park row that predates it (the call's own name is then the list). */
11
11
  names?: readonly string[];
12
12
  family: import("./tool-face.js").ToolFamily;
13
13
  pathTarget?: import("./tool-face.js").ToolRosterPathTarget;
@@ -18,7 +18,7 @@ export interface ToolCallFace {
18
18
  }
19
19
  export interface ToolCallRequest {
20
20
  /** The tool's wire name — the Runner's gate resolves a call made under an alias to the wire name of the tool it
21
- * dispatches to before any policy runs (`rosterToolName`); the names a rule may use for it are {@link ruleNamesOf}. */
21
+ * dispatches to before any policy runs (`rosterToolName`); the names a rule may use for it are `ruleNamesOf`. */
22
22
  toolName: string;
23
23
  args: unknown;
24
24
  toolCallId: string;
@@ -262,18 +262,9 @@ export declare function screenApproverAttribution(v: unknown): {
262
262
  * or `approver` member on its verdict is putting an unread property on it.
263
263
  * RB-479-B① (ruled 2026-08-02): the legacy `reason` field is REMOVED — one name for one thing.
264
264
  *
265
- * How far the TYPE carries that retirement (measured 2026-08-03, correcting this note's earlier claim
266
- * that a policy returning `{reason}` "compiles red for TS consumers"): TypeScript's excess-property
267
- * check fires only where the decision is a LITERAL in an ANNOTATED position — `const d: PermissionResult
268
- * = {…}`, or a `check` written with an explicit `: PermissionResult` return annotation. The ordinary
269
- * policy shape, `{ check: () => ({…}) }` with an INFERRED return type (also the method-syntax and
270
- * assign-then-return forms), compiles GREEN with a stray `reason`. That is not a hole to plug in the
271
- * type system — it is how contextual typing works — so the enforcement is at RUNTIME instead: every
272
- * point that CONSUMES a decision REFUSES one carrying an own `reason` property, fail-closed (deny, with
273
- * a migration message) — the {@link combinePolicies} fold, the PreToolUse hook face, and the durable
274
- * resume-edit rechecks (see {@link refuseOutOfContractDecision}). Same doctrine as the strict `approve`
275
- * / `onAsk` folds in this file: an out-of-contract value on the permission face is a loud rejection,
276
- * never a best-effort read.
265
+ * The TYPE carries that retirement only where a decision is a literal in an annotated position; an inferred `check`
266
+ * return compiles green with a stray `reason`, so the enforcement is at RUNTIME — every point that consumes a decision
267
+ * refuses one carrying an own `reason` property, fail-closed ({@link refuseOutOfContractDecision}).
277
268
  */
278
269
  export type PermissionResult = {
279
270
  action: "allow";
@@ -387,6 +378,13 @@ export type PermissionResult = {
387
378
  * render the card by it. Optional on the TYPE only because a decision is caller-composable; every ask
388
379
  * the gate hands to its resolver carries it. */
389
380
  origin?: import("./ask-origin.js").AskOrigin;
381
+ /** The ask's CLASS — the first member of {@link classes} (see {@link AskRequest.askClass}). ENGINE-STAMPED at the
382
+ * same single stamp as {@link origin}, from the class set the gate's lanes read; a policy that self-declares it is
383
+ * overwritten there, and no lane reads it back (they read the classification itself). */
384
+ askClass?: import("./ask-origin.js").AskClass;
385
+ /** The ask's whole class set, in {@link import("./ask-origin.js").ASK_CLASSES} order (see
386
+ * {@link AskRequest.classes}). Stamped beside {@link askClass}, under the same rule. */
387
+ classes?: readonly import("./ask-origin.js").AskClass[];
390
388
  /** WHICH integrity guard minted this ask — the unverifiable recursive delete or the session-transcript
391
389
  * write. ENGINE-STAMPED by the two guard policies; a fold-internal fact the class derivation reads
392
390
  * ({@link import("./ask-origin.js").classifyAsk}) so the two guards, which mint the same
@@ -737,63 +735,53 @@ export declare function constraintChainEntryOfSuppliedLayer(layer: ConstraintCha
737
735
  * different frozen deny data now digest differently.
738
736
  */
739
737
  export declare function constraintChainDigest(chain: readonly ConstraintChainEntry[]): string;
738
+ /** Every member of the decision union — a member the union gains and this list lacks is a type error at `satisfies`.
739
+ * The receipt walks it: a contract member not held as an own enumerable data property is refused if a descriptor on the
740
+ * prototype chain holds one (an `undefined` data value spells absence). Not re-exported from `src/index.ts`. */
741
+ export type DecisionMember = PermissionResult extends infer U ? (U extends unknown ? keyof U : never) : never;
742
+ export declare const DECISION_MEMBERS: readonly DecisionMember[];
740
743
  /**
741
- * Refuse a decision that speaks the retired `reason` dialect (RB-479-B①, tripwire ruled 2026-08-03).
742
- *
743
- * Applied at every point a decision is CONSUMED, because no single fold sees them all (ruled
744
- * 2026-08-03): {@link combinePolicies} (the runner's gate path), the PreToolUse hook's result seam
745
- * (`hooks.ts` — a hook result reaches the gate decision without ever passing through a fold), and the
746
- * durable resume-edit rechecks (`runtask.ts` — direct `check()` calls on the caller's own policy). The
747
- * wording is face-neutral for that reason: one construction point, three consumers. The export exists
748
- * for those two engine modules only and is deliberately NOT re-exported from `src/index.ts`: this is an
749
- * internal screen on inputs the engine receives, not a facility deployments call.
750
- *
751
- * Why this is a DENY and not a best-effort read of the old field: the retirement is settled, so the
752
- * value is out-of-contract input to the permission face — the same class as an untyped `approve`
753
- * returning a truthy non-`true`, which this file already refuses fail-closed. Reading the old name
754
- * anyway would re-open the two-names-for-one-thing surface; ignoring it (the pre-tripwire behavior)
755
- * silently dropped the policy's own text and returned the per-site generic default, so a deployment
756
- * whose deny messages had quietly degraded looked healthy. The type system cannot carry this alone —
757
- * excess-property checking does not reach an inferred `check` return (see {@link PermissionResult}).
758
- *
759
- * OWN property only (`hasOwnProperty`, not `in`): a decision whose PROTOTYPE happens to expose
760
- * `reason` never wrote the retired field. A non-object decision is passed through untouched — reading
761
- * `.action` off it is the caller's existing failure, and inventing a second failure mode here would
762
- * change which error a broken policy raises.
763
- *
764
- * Any accumulated `updatedInput` is deliberately NOT carried onto this deny: it came from the same
765
- * out-of-contract decision object, so it is not a rewrite this layer is willing to report as audited.
766
- * (A rewrite from an EARLIER, contract-shaped policy still rides out — {@link combinePolicies} adds it
767
- * on the deny path per the observer contract.)
768
- *
769
- * The second arm screens `decisionReason` against the closed set ({@link DecisionReason}): a
770
- * deployment-authored `ToolPolicy.check` returning a value outside it would otherwise ride silently
771
- * into every consumer that BRANCHES on the word — provenance-keyed exclusions, audit rows, checkpoint
772
- * discriminants — each of which would treat the unclassifiable value as "none of the reasons I know",
773
- * a meaning the producing policy never chose. Same doctrine as the retired-field arm: out-of-contract
774
- * input to the permission face is refused loudly, not passed through as an accidental tenth reason.
775
- * An ABSENT `decisionReason` stays legal (it is optional; `undefined` is the typed spelling of
776
- * absence), and hook results never reach here with a foreign word — the hook seam stamps its own
777
- * (`hooks.ts`) — so this arm's live producers are exactly the deployment policies the tripwire exists
778
- * to screen. Unlike the retired-field arm this one reads the property PLAINLY (prototype chain
779
- * included), because it answers a different question: the retired arm asks whether the AUTHOR wrote
780
- * the old field (own property = authorship), this arm asks what a CONSUMER would read — and every
781
- * consumer branches on a plain `d.decisionReason` read, which the prototype can satisfy.
782
- *
783
- * SCOPE of the carrier check (ruled after three review rounds converged on the same root): it screens
784
- * STRUCTURALLY unstable carriers — getters, prototype-supplied values — i.e. shapes an ordinary
785
- * deployment can write by accident. It does NOT try to defeat a Proxy whose descriptor trap reports a
786
- * data property while its get trap stays stateful: same-process JavaScript has no trust boundary a
787
- * function can enforce (an untrusted party who ships such a Proxy can as easily patch this module), so
788
- * chasing that shape adds complexity without adding a guarantee. The screen's promise is against
789
- * drift and accident, not against a untrusted co-resident.
790
- *
791
- * `reasonIsNonInput` (the HOOK seam's spelling — merged-code scan, 5.28 window): at that seam the
792
- * field is documented as DISCARDED — every mint point downstream unconditionally re-stamps
793
- * `decisionReason:"hook"` (fold, deny arm, delegated twin), and the allow path never reads it — so
794
- * both reason arms are skipped there: refusing a value that cannot travel would fail-close a call
795
- * over a field with no consumer, which inverted the discard contract the stamp exists to enforce.
796
- * The retired-`reason` arm still applies (that one is about the MESSAGE channel, which does travel).
744
+ * The RECEIPT of a permission decision — the one point at which the engine takes a decision in from whoever composed it,
745
+ * on every lane: each layer of {@link combinePolicies}' fold, the gate's adjudication entry and its approved-edit re-check,
746
+ * the inherited lane's receipts of an ancestor policy's decision, the final-form re-check, the direct `check()` rechecks
747
+ * (the resume edit, the park re-projection, the changed-files probe), and the PreToolUse hook's result seam (its own
748
+ * reach, below). NOT re-exported from `src/index.ts`: an internal screen on inputs the engine receives.
749
+ *
750
+ * WHAT COMES BACK is the engine's own frozen copy, never the caller's object. A decision is caller-supplied data: a
751
+ * stateful accessor, a Proxy, or a policy that keeps and mutates an object it returned could otherwise answer two
752
+ * consumers with two values, and which consumer read which value would decide the call. So the caller's object is read
753
+ * ONCE, here, and every station after the receipt (the fold's ranking, the origin stamp, the classifier's eligibility, the
754
+ * mode arbitration, the approval request, the rewrite that executes) reads the copy; a copy handed to the screen again
755
+ * comes back as it is, unread. In order: (1) the CARRIER's type — a decision that is not an object (a string, a function
756
+ * holding the members) has no `action`; (2) the ONE READ — ONE function ({@link receiveContract}) for the decision and for the
757
+ * records it nests ({@link NESTED_CONTRACTS}), on an ORDINARY object (a Proxy, as the carrier or anywhere on its prototype
758
+ * chain, is refused before a trap of its could run): each contract member absent or an own enumerable DATA property, its value
759
+ * taken off the carrier's own descriptor (a member outside the contract is neither read nor carried — a policy's self-declared
760
+ * record on another name finds no reader); any other spelling refused by name — an accessor (never invoked), a member the
761
+ * prototype or a non-enumerable property supplies unless that descriptor spells absence (an `undefined` data value) — judged by
762
+ * descriptor, never read; (3) the retired `reason` field as an OWN property, judged without a read (authorship of the old
763
+ * spelling: a prototype exposing it never wrote it; ignoring it silently dropped the policy's text); (4) the VALUES, off the engine's
764
+ * one-read record, never a second read of the caller's object — the retired window absent, `action` in
765
+ * {@link DECISION_ACTIONS} (a consumer ranks a decision by testing two of its words, so any other value used to rank as an
766
+ * allow; a `canUseTool` result outside its set is a failure in CC) and `decisionReason` in {@link DECISION_REASONS} when
767
+ * present (absence stays legal); (5) the DETACH —
768
+ * the contract members' values structured-cloned ({@link tryCloneArgs}; one that cannot be, a function inside a rewrite,
769
+ * is refused), the engine's identity-keyed records carried across ({@link carryEngineAttestations}), the copy frozen and
770
+ * marked as this module's. A refusal is a deny in the screen's words, one code for every arm (`decisionReason: "rule"`),
771
+ * carrying no `updatedInput` from the refused object (a rewrite from an EARLIER, contract-shaped layer still rides out of
772
+ * {@link combinePolicies}' deny path). `null` / `undefined` carry nothing and take the carrier's refusal (the caller's own
773
+ * read of `.action` would throw instead, and that throw was being reported as an aborted check).
774
+ *
775
+ * SCOPE: the receipt runs NONE of the carrier's code — a Proxy (its traps are the caller's code) is refused unread, and on an
776
+ * ordinary object descriptor looks and `Object.getPrototypeOf` invoke nothing. What the receipt does not reach: code inside a
777
+ * member's VALUE, run by the detach itself (an accessor inside a rewrite is invoked by the structured clone, and what it answers
778
+ * there is what is cloned and what runs — one read, consistent) and by a refusal's rendering of a foreign word (`String(v)` on an
779
+ * out-of-set `action`, after the deny is decided). Same-process JavaScript has no trust boundary a function can enforce.
780
+ *
781
+ * `reasonIsNonInput` (the HOOK seam): a hook's `decisionReason` is DISCARDED there — every mint point downstream re-stamps
782
+ * `"hook"`, and the seam maps a hook's deny and ask and reads anything else as no objection, the policy still adjudicating
783
+ * the call — so that seam takes the two retired-field arms alone and its object back unchanged (`undefined` = no
784
+ * opinion): refusing a value that cannot travel would fail-close a call over a field with no consumer.
797
785
  */
798
786
  export declare function refuseOutOfContractDecision(d: PermissionResult, opts?: {
799
787
  reasonIsNonInput?: boolean;
@@ -911,22 +899,11 @@ export declare function createApprovalPolicy(opts: {
911
899
  */
912
900
  export declare function combinePolicies(...policies: ToolPolicy[]): ToolPolicy;
913
901
  /**
914
- * The shell tools whose `{ command }` arg a coarse command-name policy parses. Other tools are out of scope.
915
- *
916
- * `Monitor` belongs here. It takes a `command` and runs it through the
917
- * SAME `env.spawnBackground` seam as Bash, and prepare-task's own note says so in as many words — "Monitor
918
- * runs an arbitrary shell `command` through the SAME env seam — it must sit behind the SAME shellGate
919
- * tighten (else it becomes a policy side-door around a gated Bash)". The shellGate half was implemented;
920
- * this half — the two ALWAYS-ON, core-owned parity gates that a caller's policy cannot remove — was keyed
921
- * on a list containing only "Bash". The probe drove the whole thing end to end: the identical
922
- * `rm -rf $(cat targets)` was denied as Bash and executed as Monitor, deleting a real directory with no
923
- * gate at all; the same substitution makes `rm -rf ~/.ai-agent/sessions` a way to erase the audit trail.
924
- */
925
- /**
926
- * EXPORTED. A downstream governance layer keeps its own copy of this list so its
927
- * per-command ask-list gates the same tools core does, and it had no way to read the real one — its guard
928
- * had to probe a hand-written candidate list of names, which stays green for any name nobody thought to
929
- * put in it. The list itself is the fact; publishing it removes the guess.
902
+ * The shell tools whose `{ command }` arg a coarse command-name policy parses; other tools are out of scope. `Monitor`
903
+ * belongs here: it runs an arbitrary shell `command` through the same env seam as Bash, so the always-on parity gates
904
+ * keyed on this list reach it too (a list of `Bash` alone let the identical command run ungated as `Monitor`). EXPORTED:
905
+ * a downstream governance layer keeps its own copy of this list so its per-command ask-list gates the same tools core
906
+ * does — the list itself is the fact, and publishing it removes the guess.
930
907
  */
931
908
  export declare const COARSE_SHELL_TOOLS: string[];
932
909
  /**
@@ -1389,6 +1366,19 @@ export interface AskRequest {
1389
1366
  * therefore reading the deployment's own claim about its own ask, which is the direction that cannot
1390
1367
  * loosen anything. */
1391
1368
  readonly origin?: import("./ask-origin.js").AskOrigin;
1369
+ /** The ask's CLASS: the first member of {@link classes} in {@link import("./ask-origin.js").ASK_CLASSES} order — the
1370
+ * row key of {@link import("./ask-origin.js").MODE_ARBITRATION} a permission mode reads. Filled by every station that
1371
+ * fills {@link origin}, from the same classification the gate's lanes arbitrated this ask by; never written by a
1372
+ * caller or worker (a policy's self-declared value is replaced at the stamp), and a policy can move it only through
1373
+ * the tighten-only inputs the classification reads. ABSENT = unclassified: a request from a station that fills no
1374
+ * {@link origin} (the shared-artifact read confirmation), or a request built by a deployment itself. An approver
1375
+ * that answers by class treats absence as a class it will not answer. */
1376
+ readonly askClass?: import("./ask-origin.js").AskClass;
1377
+ /** Every class this ask belongs to, in {@link import("./ask-origin.js").ASK_CLASSES} order, `classes[0] ===
1378
+ * askClass`. One ask can carry several (an org verdict beside a hook's question); an approver that answers by class
1379
+ * reads the whole set — a class it will not answer anywhere in it is a question it does not answer. Present exactly
1380
+ * when {@link askClass} is. */
1381
+ readonly classes?: readonly import("./ask-origin.js").AskClass[];
1392
1382
  /** Engine-judged risk axes of the action awaiting approval, for the human decision surface: a
1393
1383
  * coarse `requiresRealApproval` cannot tell "cannot be undone" from "data leaves the machine",
1394
1384
  * and those call for different scrutiny. Filled by the gate from the SAME resolved axes the
@@ -8,15 +8,18 @@ import { sanitizeAutoModeArmingRecipe } from "./auto-mode-arming.js";
8
8
  import { join, normalize as normalizePath, sep } from "node:path";
9
9
  import { BASH_READONLY_DEFAULT_ALLOW, parseLeadingCommandName } from "../tools/fs/index.js";
10
10
  import { protectivePathTargetOf } from "./tool-registry.js";
11
- import { ruleNamesOf, toolNameSetHas } from "./tool-roster.js";
11
+ import { heldBy } from "./tool-name-readings.js";
12
12
  import { boundInputHashOf } from "./canonical-json.js";
13
13
  import { delimitUntrusted, inlineUntrusted, REVIEWER_NOTE_MAX_BODY } from "./untrusted-text.js";
14
14
  import { describeRejectedValue } from "./tool-search-setting.js";
15
15
  import { thrownText } from "./thrown-value.js";
16
+ import { receiveContract, refusedOutOfContract } from "./decision-receipt.js";
16
17
  import { isNamespacedCoveringRuleName, namespacedRuleNameCovers, parsePermissionRule } from "./permission-rules.js";
17
18
  import { protocolOf } from "./protocol-table.js";
18
19
  import { isAbsoluteForFamily, isAbsolutePathForm, joinForFamily, normalizeAbsPathLexically, pathFamilyOf, writeTargetPath } from "../tools/fs/safety.js";
19
20
  import { ASSIGNMENT_PREFIX, writtenAssignmentPrefix } from "../tools/fs/bash-program-position.js";
21
+ const nameListHolds = (set, req) => heldBy("policy.nameLists", set, req);
22
+ const shellToolsListHolds = (set, req) => heldBy("policy.shellToolsList", set, req);
20
23
  export function advertisedWritableRootsSeat(seat) {
21
24
  return seat === undefined ? {} : { advertisedWritableRoots: Object.freeze([...seat.roots]) };
22
25
  }
@@ -165,21 +168,21 @@ export function namespacedCoveringHit(covering, toolName) {
165
168
  return covering.length > 0 && covering.some((e) => namespacedRuleNameCovers(e, toolName));
166
169
  }
167
170
  export function checkToolPolicyProjection(projection, req) {
168
- const names = ruleNamesOf(req);
171
+ const listed = (list) => heldBy("policy.projectionReplay", { has: (n) => list.includes(n) }, req);
169
172
  for (const c of projection.components) {
170
173
  if (c.kind === "tool_deny") {
171
- if (names.some((n) => c.names.includes(n)) || namespacedCoveringHit(namespacedCoveringEntries(c.names), req.toolName)) {
174
+ if (listed(c.names) || namespacedCoveringHit(namespacedCoveringEntries(c.names), req.toolName)) {
172
175
  return { action: "deny", message: `tool "${req.toolName}" is denied by a frozen inherited policy projection` };
173
176
  }
174
177
  continue;
175
178
  }
176
179
  if (c.kind === "tool_allowlist") {
177
- if (!names.some((n) => c.names.includes(n)) && !namespacedCoveringHit(namespacedCoveringEntries(c.names), req.toolName)) {
180
+ if (!listed(c.names) && !namespacedCoveringHit(namespacedCoveringEntries(c.names), req.toolName)) {
178
181
  return { action: "deny", message: `tool "${req.toolName}" is not in a frozen inherited policy projection's allowlist` };
179
182
  }
180
183
  continue;
181
184
  }
182
- if (!names.some((n) => c.tools.includes(n)))
185
+ if (!listed(c.tools))
183
186
  continue;
184
187
  const command = req.args?.command;
185
188
  if (typeof command !== "string") {
@@ -242,56 +245,90 @@ function stableJson(v) {
242
245
  export function constraintChainDigest(chain) {
243
246
  return `cpv1:${createHash("sha256").update(stableJson(chain)).digest("hex")}`;
244
247
  }
245
- const ALLOW = { action: "allow" };
248
+ const ALLOW = Object.freeze({ action: "allow" });
246
249
  const RETIRED_TEXT_FIELD = "reason";
247
250
  const RETIRED_WINDOW_MEMBER = "autoDenyAfterMs";
248
251
  const RETIRED_WINDOW_MEMBER_DENY_MESSAGE = `a permission decision's "denialLimitFallback" carries "${RETIRED_WINDOW_MEMBER}" — the decision names the counts only; ` +
249
252
  `the auto-deny window is decided by the ask's route (a live approver arms it, every other route runs none) and cannot be ` +
250
253
  `declared by a policy; denied fail-closed rather than silently replacing the declared number`;
254
+ const carriesRetiredWindow = (fallback) => typeof fallback === "object" && fallback !== null && fallback[RETIRED_WINDOW_MEMBER] !== undefined;
255
+ const DECISION_ACTIONS = Object.keys({ allow: 0, deny: 0, ask: 0 });
256
+ const DECISION_ACTION_SET = new Set(DECISION_ACTIONS);
257
+ export const DECISION_MEMBERS = Object.keys({
258
+ action: 0, updatedInput: 0, message: 0, decisionReason: 0, requiresRealApproval: 0, denialLimitFallback: 0, persistedRuleShadowed: 0, probeReason: 0,
259
+ probeCause: 0, probeMandated: 0, mandated: 0, mandate: 0, probeReadRootCandidate: 0, matchedAskRule: 0, ruleEvidence: 0, segmentCoverage: 0,
260
+ origin: 0, askClass: 0, classes: 0, guard: 0, ruleUnreadable: 0, authoredBy: 0, foldedAskClasses: 0,
261
+ });
262
+ const OPERAND_MEMBERS = Object.keys({ shown: 0, total: 0 });
263
+ const NESTED_CONTRACTS = [
264
+ ["", "denialLimitFallback", Object.keys({ consecutive: 0, total: 0, limit: 0, autoDenyAfterMs: 0 })],
265
+ ["", "probeCause", Object.keys({ code: 0, roots: 0, further: 0 })],
266
+ ["probeCause.", "roots", OPERAND_MEMBERS],
267
+ ["probeCause.", "further", OPERAND_MEMBERS],
268
+ ];
269
+ const refusedAction = (what) => refusedOutOfContract(`a permission decision's "action" is ${what} — the closed set is ${DECISION_ACTIONS.join("/")}; denied fail-closed rather than reading a word outside it as an allow`);
270
+ function renderForeignValue(v) {
271
+ try {
272
+ return JSON.stringify(String(v).slice(0, 64));
273
+ }
274
+ catch {
275
+ return `[unprintable ${typeof v}]`;
276
+ }
277
+ }
251
278
  const RETIRED_TEXT_FIELD_DENY_MESSAGE = `a permission decision carries the retired "${RETIRED_TEXT_FIELD}" field — rename it to "message" (the one text field ` +
252
279
  `a decision carries); denied fail-closed rather than executing a decision whose text this layer cannot read`;
280
+ const ENGINE_RECEIPTS = new WeakSet();
253
281
  export function refuseOutOfContractDecision(d, opts) {
282
+ if (opts?.reasonIsNonInput === true) {
283
+ if (d === null || d === undefined)
284
+ return d;
285
+ if (Object.prototype.hasOwnProperty.call(d, RETIRED_TEXT_FIELD))
286
+ return refusedOutOfContract(RETIRED_TEXT_FIELD_DENY_MESSAGE);
287
+ return carriesRetiredWindow(d.denialLimitFallback) ? refusedOutOfContract(RETIRED_WINDOW_MEMBER_DENY_MESSAGE) : d;
288
+ }
254
289
  if (typeof d !== "object" || d === null)
290
+ return refusedAction(`absent (the decision is ${d === null || d === undefined ? String(d) : `a ${typeof d}`}, not an object)`);
291
+ if (ENGINE_RECEIPTS.has(d))
255
292
  return d;
256
- if (Object.prototype.hasOwnProperty.call(d, RETIRED_TEXT_FIELD)) {
257
- return { action: "deny", message: RETIRED_TEXT_FIELD_DENY_MESSAGE, decisionReason: "rule" };
293
+ const decision = receiveContract(d, DECISION_MEMBERS, "");
294
+ if (decision.kind === "refused")
295
+ return decision.refused;
296
+ if (Object.prototype.hasOwnProperty.call(d, RETIRED_TEXT_FIELD))
297
+ return refusedOutOfContract(RETIRED_TEXT_FIELD_DENY_MESSAGE);
298
+ const contract = decision.held;
299
+ const records = Object.assign(Object.create(null), { "": contract });
300
+ for (const [holder, member, members] of NESTED_CONTRACTS) {
301
+ const held = records[holder];
302
+ if (held === undefined || typeof held[member] !== "object" || held[member] === null)
303
+ continue;
304
+ const nested = receiveContract(held[member], members, `${holder}${member}.`);
305
+ if (nested.kind === "refused")
306
+ return nested.refused;
307
+ held[member] = records[`${holder}${member}.`] = nested.held;
258
308
  }
259
- const fb = d.denialLimitFallback;
260
- if (typeof fb === "object" && fb !== null && fb.autoDenyAfterMs !== undefined) {
261
- return { action: "deny", message: RETIRED_WINDOW_MEMBER_DENY_MESSAGE, decisionReason: "rule" };
309
+ if (carriesRetiredWindow(contract.denialLimitFallback))
310
+ return refusedOutOfContract(RETIRED_WINDOW_MEMBER_DENY_MESSAGE);
311
+ if (!DECISION_ACTION_SET.has(contract.action))
312
+ return refusedAction(contract.action === undefined ? "absent" : renderForeignValue(contract.action));
313
+ if (contract.decisionReason !== undefined && !DECISION_REASON_SET.has(contract.decisionReason)) {
314
+ return refusedOutOfContract(`a permission decision carries an unrecognized "decisionReason" value (${renderForeignValue(contract.decisionReason)}) — ` +
315
+ `the closed set is ${DECISION_REASONS.join("/")}; denied fail-closed rather than letting consumers branch on a value this layer cannot classify`);
262
316
  }
263
- if (opts?.reasonIsNonInput === true)
264
- return d;
265
- const dr = d.decisionReason;
266
- if (dr === undefined)
267
- return d;
268
- if (!DECISION_REASON_SET.has(dr)) {
269
- let rendered;
270
- try {
271
- rendered = JSON.stringify(String(dr).slice(0, 64));
272
- }
273
- catch {
274
- rendered = `[unprintable ${typeof dr}]`;
275
- }
276
- return {
277
- action: "deny",
278
- message: `a permission decision carries an unrecognized "decisionReason" value (${rendered}) — ` +
279
- `the closed set is ${DECISION_REASONS.join("/")}; denied fail-closed rather than letting consumers ` +
280
- `branch on a value this layer cannot classify`,
281
- decisionReason: "rule",
282
- };
317
+ const detached = tryCloneArgs(contract);
318
+ if (!detached.ok) {
319
+ return refusedOutOfContract(`a permission decision's members could not be detached from the policy that returned them (${detached.reason}) — ` +
320
+ `the engine keeps its own copy of what it read; denied fail-closed`);
283
321
  }
284
- const carrier = Object.getOwnPropertyDescriptor(d, "decisionReason");
285
- if (carrier === undefined || !("value" in carrier)) {
286
- return {
287
- action: "deny",
288
- message: `a permission decision supplies "decisionReason" through ${carrier === undefined ? "its prototype" : "an accessor"} — ` +
289
- `contract fields must be own data properties, so consumers re-reading the field cannot be shown a ` +
290
- `different value than this screen validated; denied fail-closed`,
291
- decisionReason: "rule",
292
- };
322
+ const receipt = Object.freeze(detached.value);
323
+ const ownRecord = (o, k) => (typeof o === "object" && o !== null && Object.hasOwn(o, k) ? o[k] : undefined);
324
+ for (const [holder, member] of NESTED_CONTRACTS) {
325
+ const record = ownRecord(holder.split(".").filter(Boolean).reduce(ownRecord, receipt), member);
326
+ if (typeof record === "object" && record !== null)
327
+ Object.freeze(record);
293
328
  }
294
- return d;
329
+ carryEngineAttestations(d, receipt);
330
+ ENGINE_RECEIPTS.add(receipt);
331
+ return receipt;
295
332
  }
296
333
  const DEADLINE_ELAPSED = Symbol("approval.deadline_elapsed");
297
334
  function withTimeout(p, ms, onTimeout) {
@@ -388,10 +425,10 @@ export function createAllowDenyPolicy(opts) {
388
425
  nameSets: [{ ...(opts.allow ? { allow: [...opts.allow] } : {}), ...(opts.deny ? { deny: [...opts.deny] } : {}) }],
389
426
  check(req) {
390
427
  const toolName = req.toolName;
391
- if (toolNameSetHas(deny, req) || namespacedCoveringHit(denyCovering, toolName)) {
428
+ if (nameListHolds(deny, req) || namespacedCoveringHit(denyCovering, toolName)) {
392
429
  return { action: "deny", message: `tool "${req.toolName}" is denied by policy` };
393
430
  }
394
- if (allow && !toolNameSetHas(allow, req) && !namespacedCoveringHit(allowCovering, toolName)) {
431
+ if (allow && !nameListHolds(allow, req) && !namespacedCoveringHit(allowCovering, toolName)) {
395
432
  return { action: "deny", message: `tool "${req.toolName}" is not in the allowlist` };
396
433
  }
397
434
  return ALLOW;
@@ -428,10 +465,10 @@ export function createApprovalPolicy(opts) {
428
465
  ],
429
466
  async check(req, signal) {
430
467
  const toolName = req.toolName;
431
- if (toolNameSetHas(deny, req) || namespacedCoveringHit(denyCovering, toolName)) {
468
+ if (nameListHolds(deny, req) || namespacedCoveringHit(denyCovering, toolName)) {
432
469
  return { action: "deny", message: `tool "${req.toolName}" is denied by policy` };
433
470
  }
434
- if (toolNameSetHas(need, req) || namespacedCoveringHit(needCovering, toolName)) {
471
+ if (nameListHolds(need, req) || namespacedCoveringHit(needCovering, toolName)) {
435
472
  const attest = (d, settlement) => attestEngineSettlement(d, { settlement, origin: "policy" }, req);
436
473
  if (signal?.aborted) {
437
474
  return attest({ action: "deny", message: `approval aborted for "${req.toolName}" (run or turn interrupted)` }, settledByNobody("task_aborted"));
@@ -481,7 +518,7 @@ export function createApprovalPolicy(opts) {
481
518
  }
482
519
  return attest({ action: "deny", message: `approval denied for "${req.toolName}"` }, settledByPerson("human_refused", undefined));
483
520
  }
484
- if (opts.denyByDefault && !toolNameSetHas(auto, req) && !namespacedCoveringHit(autoCovering, toolName)) {
521
+ if (opts.denyByDefault && !nameListHolds(auto, req) && !namespacedCoveringHit(autoCovering, toolName)) {
485
522
  return { action: "deny", message: `tool "${req.toolName}" requires explicit allow` };
486
523
  }
487
524
  return ALLOW;
@@ -621,7 +658,7 @@ export function createCoarseCommandNamePolicy(opts) {
621
658
  return brandPolicyAskClass({
622
659
  projection,
623
660
  check(req) {
624
- if (!toolNameSetHas(shellTools, req))
661
+ if (!shellToolsListHolds(shellTools, req))
625
662
  return ALLOW;
626
663
  const command = req.args?.command;
627
664
  if (typeof command !== "string") {
@@ -875,7 +912,7 @@ export function createUnverifiableDeletePolicy(opts) {
875
912
  const shellTools = canonicalToolNameSet(opts?.tools);
876
913
  return brandPolicyAskClass({
877
914
  check(req) {
878
- if (!toolNameSetHas(shellTools, req))
915
+ if (!shellToolsListHolds(shellTools, req))
879
916
  return ALLOW;
880
917
  const command = req.args?.command;
881
918
  if (typeof command !== "string")
@@ -972,7 +1009,7 @@ export function createTranscriptIntegrityPolicy(opts) {
972
1009
  });
973
1010
  return brandPolicyAskClass({
974
1011
  check(req) {
975
- if (toolNameSetHas(shellTools, req)) {
1012
+ if (shellToolsListHolds(shellTools, req)) {
976
1013
  const command = req.args?.command;
977
1014
  if (typeof command !== "string")
978
1015
  return ALLOW;
@@ -52,36 +52,6 @@ export interface ToolCallLike {
52
52
  } | undefined;
53
53
  }
54
54
  type CallPathTarget = ToolCallPathTarget;
55
- /**
56
- * The names a tool-name rule may spell this call's tool by — the face's `names` when they include the call's name,
57
- * else the call's name alone (a bare request, or a face minted for another name). Every name-keyed matcher and the
58
- * protective path floor read a rule's tool name against THIS list, which is the roster's one canonicalization seen
59
- * from the call's side: a rule written against an alias and one written against the wire name reach the same calls.
60
- */
61
- export declare function ruleNamesOf(req: Pick<ToolCallLike, "toolName" | "face">): readonly string[];
62
- /** Does the call answer to `name` — is it one of the call's names ({@link ruleNamesOf})? The engine's TIGHTENS keyed on
63
- * one of its own tool names read this, so a tool that wears the name under any of its names is tightened (the
64
- * fail-safe direction); a grant or a routing keyed on an engine tool name reads the wire name. */
65
- export declare function callAnswersTo(req: Pick<ToolCallLike, "toolName" | "face">, name: string): boolean;
66
- /** Does a set of rule tool names name this call's tool ({@link ruleNamesOf})? */
67
- export declare function toolNameSetHas(set: {
68
- has(name: string): boolean;
69
- }, req: Pick<ToolCallLike, "toolName" | "face">): boolean;
70
- /** Does `set` hold any of the call's names ({@link ruleNamesOf})? The read of a MEMBERSHIP tighten keyed on the engine's
71
- * own tool names (the egress set, a read station's tools, the compliance veto): a tool is held under every name it
72
- * answers to. A grant or a routing keyed on an engine tool name reads the wire name instead. */
73
- export declare function anyNamed(set: {
74
- has(name: string): boolean;
75
- }, names: readonly string[]): boolean;
76
- /** The first of the call's names ({@link ruleNamesOf}, the spelling first) that `map` holds an entry for, with the entry.
77
- * The read of a SEAT keyed on the engine's own tool names (a tier, a mark's sources): every key of one seat is read under
78
- * the ONE name this returns — a seat is never assembled from two names' entries. */
79
- export declare function firstNamed<V>(map: {
80
- get(name: string): V | undefined;
81
- }, names: readonly string[]): {
82
- readonly name: string;
83
- readonly value: V;
84
- } | undefined;
85
55
  /**
86
56
  * design/388 B6/B17 — the path target a PROTECTIVE fence reads (a deny-list judging a write's target: sensitive paths,
87
57
  * frozen paths, write protection, the transcript gate; edit tracking): the object face's own declaration first, the
@@ -89,7 +59,7 @@ export declare function firstNamed<V>(map: {
89
59
  * face that declares none. Reading the catalog for an undeclared same-name tool can only TIGHTEN a deny-list (it adds
90
60
  * a slot to judge — the pre-388 posture, never wider), which is exactly why this floor is wrong for a grant.
91
61
  *
92
- * The floor reads EVERY name the call answers to (`ruleNamesOf`: the face's `names`, else the call's name alone) in
62
+ * The floor is a `member` seat over every name the call answers to (the face's `names`, else the call's name alone) in
93
63
  * the face's order — the name the call was made under first, then the wire name, then the other aliases — and the
94
64
  * first name the catalog declares a target for wins: a call made under a catalog name and resolved to a tool that
95
65
  * declares none keeps THAT name's slot, also when the tool answers to a second catalog name with another slot.
@@ -1,5 +1,6 @@
1
1
  import { TOOL_MOUNT_TAGS, pathTargetSlotOnly } from "./tool-face.js";
2
2
  import { ENGINE_CARDS, TOOL_CATALOG_ENTRIES } from "./tool-catalog-entries.js";
3
+ import { seatOf } from "./tool-name-readings.js";
3
4
  export function describeToolCatalog() {
4
5
  return TOOL_CATALOG_ENTRIES;
5
6
  }
@@ -51,31 +52,10 @@ export function pathTargetValue(args, pt) {
51
52
  }
52
53
  return undefined;
53
54
  }
54
- export function ruleNamesOf(req) {
55
- const names = req.face?.names;
56
- return names !== undefined && names.includes(req.toolName) ? names : [req.toolName];
57
- }
58
- export function callAnswersTo(req, name) {
59
- return ruleNamesOf(req).includes(name);
60
- }
61
- export function toolNameSetHas(set, req) {
62
- return anyNamed(set, ruleNamesOf(req));
63
- }
64
- export function anyNamed(set, names) {
65
- return names.some((n) => set.has(n));
66
- }
67
- export function firstNamed(map, names) {
68
- for (const name of names) {
69
- const value = map.get(name);
70
- if (value !== undefined)
71
- return { name, value };
72
- }
73
- return undefined;
74
- }
75
55
  export function protectivePathTargetOf(call) {
76
56
  if (call.face?.pathTarget !== undefined)
77
57
  return call.face.pathTarget;
78
- const floor = ruleNamesOf(call).map((n) => pathTargetOf(n)).find((pt) => pt !== undefined);
58
+ const floor = seatOf("floor.protectivePathTarget", { get: pathTargetOf }, call)?.value;
79
59
  return floor === undefined || call.face === undefined ? floor : pathTargetSlotOnly(floor);
80
60
  }
81
61
  export const BARE_REQUEST_CODE = "policy.bare_request";
@@ -23,6 +23,9 @@ export interface AggregateBudgetOptions {
23
23
  budgetChars?: number;
24
24
  /** Tool names exempt from the budget (their own caps bound them) — e.g. read_file, read_tool_result. */
25
25
  exemptTools?: ReadonlySet<string>;
26
+ /** The wire name a result's recorded tool name resolves to on the leg's roster (an exemption is judged on the tool the
27
+ * call ran, not the spelling the model used). Absent ⇒ the recorded name. */
28
+ toolNameOf?: (recorded: string) => string;
26
29
  /** design/30 store for offload+read-back. Absent ⇒ self-contained truncation preview. */
27
30
  store?: ToolResultStore;
28
31
  /** Namespaces the stable ref so an offloaded result is globally addressable. */
@@ -1,6 +1,7 @@
1
1
  import { isToolResult } from "./message-utils.js";
2
2
  import { buildPreview, buildToolResultRef, PERSISTED_OUTPUT_PREFIX, toolResultContentSegment, toolResultProvenanceOf, } from "./tool-result-store.js";
3
3
  import { budgetExemptToolNames } from "./tool-registry.js";
4
+ import { wireOf } from "./tool-name-readings.js";
4
5
  export const AGGREGATE_TOOL_RESULT_BUDGET_CHARS = 200_000;
5
6
  const HEAD = 1_000;
6
7
  const TAIL = 1_000;
@@ -38,6 +39,7 @@ const CATALOG_BUDGET_EXEMPT_TOOLS = budgetExemptToolNames();
38
39
  export async function capAggregateToolResults(messages, opts) {
39
40
  const budget = opts.budgetChars ?? AGGREGATE_TOOL_RESULT_BUDGET_CHARS;
40
41
  const exempt = opts.exemptTools ?? CATALOG_BUDGET_EXEMPT_TOOLS;
42
+ const exemptResult = (m) => m.toolName !== undefined && exempt.has(wireOf("budget.exemptResult", { toolName: opts.toolNameOf !== undefined ? opts.toolNameOf(m.toolName) : m.toolName }));
41
43
  let out;
42
44
  let i = 0;
43
45
  while (i < messages.length) {
@@ -52,7 +54,7 @@ export async function capAggregateToolResults(messages, opts) {
52
54
  let total = 0;
53
55
  for (let k = i; k < j; k++) {
54
56
  const m = arr[k];
55
- if (m.toolName && exempt.has(m.toolName))
57
+ if (exemptResult(m))
56
58
  continue;
57
59
  total += textChars(m.content);
58
60
  }
@@ -94,7 +96,7 @@ export async function capAggregateToolResults(messages, opts) {
94
96
  const cand = [];
95
97
  for (let k = i; k < j; k++) {
96
98
  const m = out[k];
97
- if (m.toolName && exempt.has(m.toolName))
99
+ if (exemptResult(m))
98
100
  continue;
99
101
  if (isOffloadedPreview(m.content))
100
102
  continue;
@@ -112,7 +114,7 @@ export async function capAggregateToolResults(messages, opts) {
112
114
  const cand2 = [];
113
115
  for (let k = i; k < j; k++) {
114
116
  const m = out[k];
115
- if (m.toolName && exempt.has(m.toolName))
117
+ if (exemptResult(m))
116
118
  continue;
117
119
  if (isOffloadedPreview(messages[k].content))
118
120
  continue;