@sema-agent/core 7.36.0 → 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 (33) hide show
  1. package/CHANGELOG.md +81 -0
  2. package/dist/core/ask-origin.d.ts +35 -5
  3. package/dist/core/ask-origin.js +23 -4
  4. package/dist/core/checkpoint-store.d.ts +13 -3
  5. package/dist/core/decision-receipt.d.ts +21 -0
  6. package/dist/core/decision-receipt.js +63 -0
  7. package/dist/core/engine-notice.d.ts +8 -5
  8. package/dist/core/gate-fold.js +2 -1
  9. package/dist/core/gate-lanes.js +8 -13
  10. package/dist/core/gate-outcome.d.ts +7 -5
  11. package/dist/core/gate-outcome.js +5 -1
  12. package/dist/core/hooks.d.ts +11 -2
  13. package/dist/core/hooks.js +3 -1
  14. package/dist/core/park-selfcheck.js +2 -0
  15. package/dist/core/runner/denial-limit-arms.d.ts +28 -18
  16. package/dist/core/runner/denial-limit-arms.js +11 -7
  17. package/dist/core/runner/inherited-ask-grants.d.ts +32 -31
  18. package/dist/core/runner/inherited-ask-grants.js +5 -0
  19. package/dist/core/runner/prepare-inherited-gate.js +1 -1
  20. package/dist/core/runner/prepare-park-ask.d.ts +2 -2
  21. package/dist/core/runner/prepare-park-ask.js +1 -5
  22. package/dist/core/runner/prepare-policy-chain.d.ts +3 -8
  23. package/dist/core/runner/prepare-policy-chain.js +43 -25
  24. package/dist/core/store-contracts/checkpoint-store-contract.js +18 -0
  25. package/dist/core/tool-name-readings.d.ts +4 -0
  26. package/dist/core/tool-name-readings.js +1 -0
  27. package/dist/core/tool-policy.d.ts +74 -84
  28. package/dist/core/tool-policy.js +71 -36
  29. package/dist/index.d.ts +1 -1
  30. package/dist/index.js +1 -1
  31. package/package.json +1 -1
  32. package/test/export-surface.snapshot.json +7 -1
  33. package/wire-changes/core@7.37.0.json +405 -0
@@ -381,6 +381,24 @@ export async function checkpointStoreContract(make, runAssertion) {
381
381
  assert.equal("mandate" in byId.get("mw-absent"), false, "absent must project the key OMITTED");
382
382
  assert.equal("mandate" in byId.get("mw-bogus"), false, "a word outside the closed set must read as absent, never project verbatim");
383
383
  });
384
+ run("#1214 ask class pair: a tool_approval row's `askClass` / `classes` survive the round-trip verbatim (the array whole, in its order); an absent pair stays absent", async () => {
385
+ const store = make();
386
+ const single = createCheckpointFixture({ token: mintCheckpointToken(), sessionId: "ac-single" });
387
+ Object.assign(single.pendingAction, { askClass: "denial_limit_fallback", classes: ["denial_limit_fallback"] });
388
+ const several = createCheckpointFixture({ token: mintCheckpointToken(), sessionId: "ac-several" });
389
+ Object.assign(several.pendingAction, { askClass: "transcript_write", classes: ["transcript_write", "org_rule"] });
390
+ const absent = createCheckpointFixture({ token: mintCheckpointToken(), sessionId: "ac-absent" });
391
+ for (const cp of [single, several, absent])
392
+ await store.put(cp.token, cp);
393
+ for (const [cp, askClass, classes] of [[single, "denial_limit_fallback", ["denial_limit_fallback"]], [several, "transcript_write", ["transcript_write", "org_rule"]]]) {
394
+ const back = (await store.get(cp.token)).pendingAction;
395
+ assert.equal(back.askClass, askClass, "the row's askClass must survive the round-trip verbatim");
396
+ assert.deepEqual(back.classes, classes, "the row's classes must survive the round-trip as the same array, in the same order");
397
+ }
398
+ const bare = (await store.get(absent.token)).pendingAction;
399
+ assert.equal("askClass" in bare, false, "an absent askClass must stay OMITTED on the row");
400
+ assert.equal("classes" in bare, false, "an absent classes must stay OMITTED on the row");
401
+ });
384
402
  await settle();
385
403
  }
386
404
  const sortByToken = (a, b) => a.token.localeCompare(b.token);
@@ -203,6 +203,10 @@ export declare const NAME_READINGS: {
203
203
  readonly rank: "member";
204
204
  readonly file: string;
205
205
  };
206
+ readonly "report.markSources": {
207
+ readonly kind: "any";
208
+ readonly file: string;
209
+ };
206
210
  readonly "verify.wroteThisRun": {
207
211
  readonly kind: "wire";
208
212
  readonly file: string;
@@ -50,6 +50,7 @@ export const NAME_READINGS = {
50
50
  "report.riskAxes": seat("tier", CHAIN),
51
51
  "report.riskAxes.egress": any(CHAIN),
52
52
  "report.riskAxes.negatives": seat("member", CHAIN),
53
+ "report.markSources": any(CHAIN),
53
54
  "verify.wroteThisRun": wire(HANDLERS),
54
55
  "verify.wroteThisRunSeed": wire("src/core/runner/run-attachment-seats.ts"),
55
56
  "event.toolStartEnd": spelling(HANDLERS),
@@ -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
@@ -13,6 +13,7 @@ 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";
@@ -244,56 +245,90 @@ function stableJson(v) {
244
245
  export function constraintChainDigest(chain) {
245
246
  return `cpv1:${createHash("sha256").update(stableJson(chain)).digest("hex")}`;
246
247
  }
247
- const ALLOW = { action: "allow" };
248
+ const ALLOW = Object.freeze({ action: "allow" });
248
249
  const RETIRED_TEXT_FIELD = "reason";
249
250
  const RETIRED_WINDOW_MEMBER = "autoDenyAfterMs";
250
251
  const RETIRED_WINDOW_MEMBER_DENY_MESSAGE = `a permission decision's "denialLimitFallback" carries "${RETIRED_WINDOW_MEMBER}" — the decision names the counts only; ` +
251
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 ` +
252
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
+ }
253
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 ` +
254
279
  `a decision carries); denied fail-closed rather than executing a decision whose text this layer cannot read`;
280
+ const ENGINE_RECEIPTS = new WeakSet();
255
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
+ }
256
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))
257
292
  return d;
258
- if (Object.prototype.hasOwnProperty.call(d, RETIRED_TEXT_FIELD)) {
259
- 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;
260
308
  }
261
- const fb = d.denialLimitFallback;
262
- if (typeof fb === "object" && fb !== null && fb.autoDenyAfterMs !== undefined) {
263
- 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`);
264
316
  }
265
- if (opts?.reasonIsNonInput === true)
266
- return d;
267
- const dr = d.decisionReason;
268
- if (dr === undefined)
269
- return d;
270
- if (!DECISION_REASON_SET.has(dr)) {
271
- let rendered;
272
- try {
273
- rendered = JSON.stringify(String(dr).slice(0, 64));
274
- }
275
- catch {
276
- rendered = `[unprintable ${typeof dr}]`;
277
- }
278
- return {
279
- action: "deny",
280
- message: `a permission decision carries an unrecognized "decisionReason" value (${rendered}) — ` +
281
- `the closed set is ${DECISION_REASONS.join("/")}; denied fail-closed rather than letting consumers ` +
282
- `branch on a value this layer cannot classify`,
283
- decisionReason: "rule",
284
- };
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`);
285
321
  }
286
- const carrier = Object.getOwnPropertyDescriptor(d, "decisionReason");
287
- if (carrier === undefined || !("value" in carrier)) {
288
- return {
289
- action: "deny",
290
- message: `a permission decision supplies "decisionReason" through ${carrier === undefined ? "its prototype" : "an accessor"} — ` +
291
- `contract fields must be own data properties, so consumers re-reading the field cannot be shown a ` +
292
- `different value than this screen validated; denied fail-closed`,
293
- decisionReason: "rule",
294
- };
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);
295
328
  }
296
- return d;
329
+ carryEngineAttestations(d, receipt);
330
+ ENGINE_RECEIPTS.add(receipt);
331
+ return receipt;
297
332
  }
298
333
  const DEADLINE_ELAPSED = Symbol("approval.deadline_elapsed");
299
334
  function withTimeout(p, ms, onTimeout) {
package/dist/index.d.ts CHANGED
@@ -192,7 +192,7 @@ export { createAllowDenyPolicy, createApprovalPolicy,
192
192
  type ApprovalSeatOutcome, COARSE_SHELL_TOOLS, createCoarseCommandNamePolicy, createTranscriptIntegrityPolicy, createUnverifiableDeletePolicy, findUnverifiableRecursiveDelete, combinePolicies, wrapToolPolicy, decisionText, resolveAsk, toolPolicyNameSets, toolPolicyPathDenyRules, type ToolPolicyNameSets, type NamedToolPolicy, type ToolPolicyProjection, type ToolPolicyProjectionComponent, type ConstraintChainEntry, checkToolPolicyProjection, constraintChainEntryOf, constraintChainDigest, type ToolPolicy, type ToolCallRequest, type PermissionResult, type DecisionReason, screenApproverAttribution, APPROVER_ATTRIBUTION_MAX_CHARS, type OnAsk, type AskOutcome, type ResolvedAsk, type AskRequest, type AskDelegationProvenance, type AskRuleEvidence, type AskEvidenceAbsence, ASK_EVIDENCE_ABSENCE_VALUES, } from "./core/tool-policy.js";
193
193
  export { type ReadRootGrantScreen } from "./core/read-root-grant-screen.js";
194
194
  export { type AskOrigin, ASK_ORIGINS, isAskOrigin, ORIGIN_IMPLIES_REAL_APPROVAL, PERSISTED_RULE_MANDATES, type PersistedRuleMandate, isPersistedRuleMandate } from "./core/ask-origin.js";
195
- export { PERMISSION_MODES, type PermissionMode, isPermissionMode, PERMISSION_MODE_RANK, clampPermissionMode, ASK_CLASSES, type AskClass, isAskClass, MODE_ARBITRATION, type ModeArbitrationRow, RELEASABLE_ASK_CLASSES, RRA_EXPLAINERS, classifyAsk, foldArbitrationRow, type AskClassification, type AskClassFacts, type ToolMarkSource, arbitrateByMode, type Arbitration, fileCommandOptionsSentence } from "./core/ask-origin.js";
195
+ export { PERMISSION_MODES, type PermissionMode, isPermissionMode, PERMISSION_MODE_RANK, clampPermissionMode, ASK_CLASSES, type AskClass, isAskClass, MODE_ARBITRATION, type ModeArbitrationRow, RELEASABLE_ASK_CLASSES, MODE_RELEASE_REASONS, type ModeReleaseReason, isModeReleaseReason, RRA_EXPLAINERS, classifyAsk, foldArbitrationRow, type AskClassification, type AskClassFacts, type ToolMarkSource, arbitrateByMode, type Arbitration, fileCommandOptionsSentence } from "./core/ask-origin.js";
196
196
  export { modePolicyOf, type ModePolicy, PERMISSION_MODE_FOLD_REASONS, type PermissionModeFoldReason, type PermissionModeReceipt, ACCEPT_EDITS_FILE_COMMANDS, modeAcceptsFileCommand } from "./core/mode-policy.js";
197
197
  export { readDenyClassifierRules, renderClassifierDenyRules, type DerivedDenyRule } from "./core/auto-mode-prompt.js";
198
198
  export { AUTO_MODE_DENY_SOURCES, isAutoModeDenySource, type AutoModeDenySource } from "./core/runtime-caps.js";
package/dist/index.js CHANGED
@@ -157,7 +157,7 @@ export { tightenTaskSpec, TaskSpecTightenError } from "./core/tighten-task-spec.
157
157
  export { createAllowDenyPolicy, createApprovalPolicy, COARSE_SHELL_TOOLS, createCoarseCommandNamePolicy, createTranscriptIntegrityPolicy, createUnverifiableDeletePolicy, findUnverifiableRecursiveDelete, combinePolicies, wrapToolPolicy, decisionText, resolveAsk, toolPolicyNameSets, toolPolicyPathDenyRules, checkToolPolicyProjection, constraintChainEntryOf, constraintChainDigest, screenApproverAttribution, APPROVER_ATTRIBUTION_MAX_CHARS, ASK_EVIDENCE_ABSENCE_VALUES, } from "./core/tool-policy.js";
158
158
  export {} from "./core/read-root-grant-screen.js";
159
159
  export { ASK_ORIGINS, isAskOrigin, ORIGIN_IMPLIES_REAL_APPROVAL, PERSISTED_RULE_MANDATES, isPersistedRuleMandate } from "./core/ask-origin.js";
160
- export { PERMISSION_MODES, isPermissionMode, PERMISSION_MODE_RANK, clampPermissionMode, ASK_CLASSES, isAskClass, MODE_ARBITRATION, RELEASABLE_ASK_CLASSES, RRA_EXPLAINERS, classifyAsk, foldArbitrationRow, arbitrateByMode, fileCommandOptionsSentence } from "./core/ask-origin.js";
160
+ export { PERMISSION_MODES, isPermissionMode, PERMISSION_MODE_RANK, clampPermissionMode, ASK_CLASSES, isAskClass, MODE_ARBITRATION, RELEASABLE_ASK_CLASSES, MODE_RELEASE_REASONS, isModeReleaseReason, RRA_EXPLAINERS, classifyAsk, foldArbitrationRow, arbitrateByMode, fileCommandOptionsSentence } from "./core/ask-origin.js";
161
161
  export { modePolicyOf, PERMISSION_MODE_FOLD_REASONS, ACCEPT_EDITS_FILE_COMMANDS, modeAcceptsFileCommand } from "./core/mode-policy.js";
162
162
  export { readDenyClassifierRules, renderClassifierDenyRules } from "./core/auto-mode-prompt.js";
163
163
  export { AUTO_MODE_DENY_SOURCES, isAutoModeDenySource } from "./core/runtime-caps.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/core",
3
- "version": "7.36.0",
3
+ "version": "7.37.0",
4
4
  "description": "Stateless, task-oriented AI agent core",
5
5
  "type": "module",
6
6
  "license": "BUSL-1.1",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "_comment": "design/87 L3 — frozen public export surface of src/index.ts (name -> kind). DO NOT edit by hand to silence a red test. A removed/changed entry = a SemVer-BREAKING change; bump MAJOR and update this fixture in the SAME commit (design/87 §4.2 / §5.2). Regenerate via REGEN in test/export-surface.test.ts.",
3
3
  "_tierComment": "#435 v1 — machine-readable layering of the public surface: stable = demonstrated by README.md / src/examples; internal = an `Internal`-marked name or a runner/engine deep-subtree declaration (the model seam src/engine/llm is excluded — it is the BYOM contract, not an engine internal); advanced = a supported export the front door does not walk you through. THIS IS AN INITIAL HEURISTIC, derived mechanically and expected to be refined ticket by ticket: no human reviewed these 1700+ entries one by one, and nothing here claims otherwise. Known bias: a short or English-word export name (ok, err, Result, Usage) can match ordinary prose in README.md and land `stable` on a coincidence. Every export MUST carry a tier — a new export with no row fails the gate in export-surface.test.ts.",
4
- "count": 2537,
4
+ "count": 2540,
5
5
  "exports": {
6
6
  "A2ATaskState": "type",
7
7
  "A2ATaskStateReversal": "type",
@@ -713,6 +713,7 @@
713
713
  "MEMORY_SEARCH_TOOL_NAME": "variable",
714
714
  "MODEL_ROLES": "variable",
715
715
  "MODE_ARBITRATION": "variable",
716
+ "MODE_RELEASE_REASONS": "variable",
716
717
  "MONITOR_BATCH_WINDOW_MS": "variable",
717
718
  "MONITOR_DEFAULT_TIMEOUT_MS": "variable",
718
719
  "MONITOR_MAX_BATCHES_PER_MINUTE": "variable",
@@ -822,6 +823,7 @@
822
823
  "ModePolicy": "interface",
823
824
  "ModeRefusal": "interface",
824
825
  "ModeRelease": "interface",
826
+ "ModeReleaseReason": "type",
825
827
  "Model": "interface",
826
828
  "ModelCriteria": "interface",
827
829
  "ModelMention": "interface",
@@ -2094,6 +2096,7 @@
2094
2096
  "isGateKind": "function",
2095
2097
  "isInstructionEntry": "function",
2096
2098
  "isIsolated": "function",
2099
+ "isModeReleaseReason": "function",
2097
2100
  "isModelGatedForClass": "function",
2098
2101
  "isModelPriced": "function",
2099
2102
  "isNamespacedCoveringRuleName": "function",
@@ -3252,6 +3255,7 @@
3252
3255
  "MEMORY_SEARCH_TOOL_NAME": "advanced",
3253
3256
  "MODEL_ROLES": "advanced",
3254
3257
  "MODE_ARBITRATION": "advanced",
3258
+ "MODE_RELEASE_REASONS": "advanced",
3255
3259
  "MONITOR_BATCH_WINDOW_MS": "advanced",
3256
3260
  "MONITOR_DEFAULT_TIMEOUT_MS": "advanced",
3257
3261
  "MONITOR_MAX_BATCHES_PER_MINUTE": "advanced",
@@ -3361,6 +3365,7 @@
3361
3365
  "ModePolicy": "advanced",
3362
3366
  "ModeRefusal": "advanced",
3363
3367
  "ModeRelease": "advanced",
3368
+ "ModeReleaseReason": "advanced",
3364
3369
  "Model": "stable",
3365
3370
  "ModelCriteria": "advanced",
3366
3371
  "ModelMention": "advanced",
@@ -4633,6 +4638,7 @@
4633
4638
  "isGateKind": "advanced",
4634
4639
  "isInstructionEntry": "advanced",
4635
4640
  "isIsolated": "advanced",
4641
+ "isModeReleaseReason": "advanced",
4636
4642
  "isModelGatedForClass": "advanced",
4637
4643
  "isModelPriced": "advanced",
4638
4644
  "isNamespacedCoveringRuleName": "advanced",