All notable changes to the sanna-ts SDK are documented here. Format: Keep a Changelog. Versioning: Semantic Versioning.
- BREAKING BEHAVIOR CHANGE:
sannaObserveandwithSannaGovernance(packages/core/src/middleware.ts) now default toenforcementMode: "enforced"(was"advisory"), matching the Python SDK's@sanna_observedecorator, which has no mode toggle and always enforces. Previously, a constitution with a halt-severity condition (acannot_executeauthority boundary, or an invariant/coherence check whose failure resolves tocriticalorhighseverity) was detected but not enforced by default -- the wrapped function's result was returned to the caller as if nothing had happened. The same constitution now HALTS by default (throwsSannaHaltError), matching what Python has always done. Callers who want observe-only behavior must now passenforcementMode: "advisory"explicitly;"permissive"(skips coherence checks entirely) is unchanged. Checks that fail at"warn"or"log"enforcement level (severitymedium/low, outsideHALT_SEVERITIES = {critical, high}) are still recorded on the receipt but never halt, in any mode -- this flip is scoped to the default value only and does not touch halt-severity gating. This is a DEFAULT-POSTURE change only: it does not make TS's and Python's halt conditions identical (which conditions resolve to a halt-severity check still differs cross-SDK; tracked separately under SAN-849/850/851).@sanna-ai/gatewayand@sanna-ai/mcp-serverare out of scope -- both have their own independentenforcement.modeconfiguration and do not callsannaObserve. - Composition note: TS fails closed on invariants it cannot classify (
UNKNOWN_TYPEstatus, severity derived from the invariant's ownenforcement: "halt"setting ->critical) -- a separate, already-tracked over-block defect (SAN-850). Because the new default is"enforced", that over-block is now default-active for any constitution containing an invariant TS does not recognize, where previously it was inert under the advisory default. SAN-850 is a required companion fix in the same release batch as this change.
packages/core/tests/middleware.test.ts,packages/core/tests/middleware-sink.test.ts,packages/core/tests/redaction.test.ts: tests that wrapped a halt-severity constitution without an explicitenforcementModeand asserted continuation are migrated by intent. Tests whose purpose was observing receipt/sink/redaction content (not enforcement) are pinned toenforcementMode: "advisory"explicitly, preserving their pre-flip behavior byte-for-byte. The one test whose purpose was the default itself (should default to advisory mode) is renamed and rewritten to assert the new enforced default.
packages/core/tests/san836-enforcing-default.test.ts(new): isolates the default-flip from SAN-850 by using only recognized halt conditions (an authoritycannot_executeboundary; a recognizedrequired_keywordsinvariant) -- never the unrecognized-invariant shape SAN-850 covers. Asserts the new default enforces on a clean halt condition, that explicit"advisory"still opts out, that an all-recognized-and-passing constitution is not over-blocked, and that a recognized invariant failing at"warn"severity is recorded but does not halt.
@sanna-ai/gateway: the escalation approval token is no longer returned in the agent-visible MCP tool response._handleToolCall's escalate branch (packages/gateway/src/gateway.ts) previously puttoken: esc.tokendirectly into the JSON returned to the calling agent, anddelivery_methodsdefaulted to["inline"]-- so an agent that triggered an escalation received the very token needed to approve it, and could callsanna_approve_escalationon itself with no operator involved. The escalated response now returns onlystatus,reason,escalation_id,expires_at, andreceipt_id. Token delivery is now out-of-band: the default delivery method is"stderr"(matching the Python gateway'stoken_delivery = token_delivery or ["stderr"]/_deliver_token,src/sanna/gateway/server.py), which writes[SANNA] Approval token for escalation <id>: <token>directly toprocess.stderr-- the operator's console, not the agent's stdout/JSON-RPC channel."inline"is no longer a supported delivery channel: if a config still lists it, it is ignored and a one-time warning is written to stderr explaining why."file"delivery now emits the same insecurity warning Python does (agents with file-reading tools can read a file-delivered token and self-approve). If the effective delivery list is empty after removing"inline", the gateway fails closed -- it warns to stderr that the escalation has no out-of-band channel and cannot be approved, and leaves it pending, rather than falling back to inline. The escalation store (packages/gateway/src/escalation.ts, hash-only at rest) and the approve-handler's token verification (gateway.ts_handleMetaTool) are unchanged.- Scoped claim: this closes self-approval for an agent that interacts with the gateway only through MCP tool calls. It does not defend against an agent with independent access to the gateway's
hmac_secret(which could recompute a valid token) or to an agent-readable delivery channel such as the token file (escalation.token_file_path). Operators who need the file channel should treat it as agent-readable and preferstderrorwebhookto an operator-controlled endpoint.
packages/gateway/tests/gateway.test.ts("should handle escalation flow ...") rewritten into a negative-control plus positive-path test: it asserts the escalate response carries notokenfield, that an approval attempt built only from the fields the agent actually received fails and does not forward the original call, and that approval succeeds only with the raw token recovered from captured stderr output. Both the no-inline-token and cannot-self-approve assertions were RED against the pre-fix code (the pre-fix self-approval attempt actually succeeded, using the token straight out of the agent's own response).
@sanna-ai/core: the receipt-schema verifier is now self-contained -- the schema is bundled intodistat build time instead of being read from disk at runtime.verifyReceiptpreviously returnedvalid=falsefor ALL receipts, valid or not, in any published install:getAjvValidator(packages/core/src/verifier.ts) loadedreceipt.schema.jsonviareadFileSyncfrom a path relative to the running module (../../../spec/schemas/receipt.schema.json), which only resolves inside this repo tree -- thespecgit submodule is not shipped in the npm tarball (packages/core/package.jsonfileslists onlydist,README.md,LICENSE). The CJS build additionally failed before reachingreadFileSync: tsup shimsimport.metaas{}for CJS output, soimport.meta.urlwasundefinedandfileURLToPath(undefined)threw. Both are fixed:verifier.tsnow imports the schema directly (import receiptSchema from "../../../spec/schemas/receipt.schema.json"), which tsup/esbuild inlines into bothdist/index.jsanddist/index.cjsat build time, and the now-unusedreadFileSync/resolve/dirname/fileURLToPathimports were removed.verifyReceiptnow works from installed packages via ESM and CJS, on any Node version, with no runtime filesystem read -- and still enforces the schema (compiles the sameallOfconditional-rules slice as before; nothing about what the schema validates has changed).
packages/core/tests/san873-packaged-verifier.test.ts(new): a regression test that can only observe this class of defect -- it packs the built@sanna-ai/coredist withnpm pack, installs the tarball OFFLINE into a fresh temp directory outside the repo tree, and drivesverifyReceiptfrom spawned ESM and CJS consumer scripts thatimport/require("@sanna-ai/core")like a real downstream consumer. Asserts a freshly signed valid receipt verifiestruewith no schema-load error (both module systems), and a positive control: a receipt that satisfies theifbut violates thethenof allOf ruleCFC-A(enforcement.halt_reasonpresent requiresenforcement.action="halted", spec Section 4.6) is rejected with a genuine ajv/schema-validation error -- proving the bundled schema is real and enforcing, not an empty or permissive stand-in.
.gitignore: added.claude/worktrees/-- leftover embedded git worktree directories from agent sessions were untracked but not previously ignored.
@sanna-ai/core:signReceiptnow shares the proto-safe canonicalizer withverifyReceipt, completing SAN-818. SAN-818 fixed the__proto__canonical-JSON signature-integrity defect only inpackages/core/src/hashing.ts(normalizeFloats); it left a localsanitizeForSigningfunction inpackages/core/src/receipt.tsthat shadowed the proto-safe one exported fromcrypto.tsand still used the proto-unsaferesult[k] = ...bracket-assignment accumulation pattern.signReceiptused that local function whileverifyReceiptused the proto-safe one, so a receipt whose content legitimately contained a__proto__own-key was signed over bytes that dropped__proto__but verified over bytes that kept it, causing self-verification of such a receipt to fail.receipt.tsnow importssanitizeForSigningfromcrypto.ts(the same proto-safenormalizeFloatsaliasverifier.ts,constitution.ts, andaarm.tsalready use) instead of defining its own; the local function was deleted. Receipts whose content contains a__proto__own-key are now signed and verified consistently. No fingerprint or signature bytes change for receipts without a__proto__own-key.
packages/core/tests/san871-sign-verify-proto.test.ts(new): a negative control proving sign bytes and verify bytes agreed for a__proto__-bearing receipt only after the fix (both the low-level byte reconstruction and the fullverifyReceiptpath), plus a tamper-in-transit guard provingverifyReceiptstill rejects a__proto__injected into a receipt'sinputsafter signing (guards the pre-existing SAN-818 verify-side fix against regression).
@sanna-ai/core:invariants_scopeis now DERIVED from observed execution instead of defaulting to"full"when a caller omits it. Assurance metadata must reflect what actually ran, not the most flattering value; a receipt that omittedinvariants_scopepreviously claimed full invariant coverage even when a declared invariant never executed.generateReceipt(packages/core/src/receipt.ts) now computes the emitted scope from thechecksarray: every invariant declared by the constitution must have produced an executed check entry (status not in the not-evaluated set) or the claim is downgraded to"limited". A caller-supplied scope other than"full"(for example"authority_only"or"none") is still honored unchanged -- it is never upgraded, since those values already understate coverage.sannaObserve(packages/core/src/middleware.ts) now passes the constitution's declared invariant IDs through so the derivation uses the true declared set rather than a proxy; the gateway and MCP server call sites are unchanged and continue to rely on the proxy (the invariant IDs present in the emitted checks), matching the equivalent Python SDK surfaces.- TS's not-evaluated status set is wider than Python's: in addition to
NOT_CHECKEDandERRORED, the built-in invariant runners inpackages/core/src/invariants.tsalso emitUNKNOWN_TYPE(the invariant's rule could not be classified) andUNSAFE_PATTERN(a regex pattern was rejected before evaluation as a ReDoS guard) -- both mean the declared rule did not run and are treated as not-evaluated for coverage purposes. This does not change enforcement outcomes; whether a check ran (coverage) and what it decided (pass/fail) remain independent questions.
- When
invariants_scopeis downgraded to"limited", the receipt now carries acom.sanna.coverageextension (extensions["com.sanna.coverage"]) withinvariants_declared,invariants_executed, and askippedlist of{id, reason}entries (reasonis the observed status, or"DROPPED"if the invariant never produced a check entry at all). This extension participates in the receipt fingerprint and signature like any other extension field. packages/core/tests/san863-invariants-scope.test.ts(new): covers the derivation directly -- unrecognized invariants, invariants that never reach the checks array, explicitnullvsundefinedstatus handling, a caller-declared"full"being downgraded when execution does not support it, a caller-declared"authority_only"being honored unchanged in both directions, the zero-declared-invariants case, C1-C5 coherence checks not affecting coverage, fingerprint/signature self-consistency for both the"full"and"limited"shapes, and a negative control that no code path emits"full"while invariants are missing.packages/core/tests/middleware.test.ts: newSAN-863describe block drives the derivation end-to-end throughsannaObservewith a real constitution, proving the declared-invariant-ID wiring from the resolved constitution through to the emittedinvariants_scopeand coverage extension.
- Because
invariants_scopeand any newcom.sanna.coverageextension participate in the fingerprint (computeFingerprintInput, fields 12 and 16), receipt fingerprints change for any caller whose checks previously included a declared invariant that did not actually execute. This is expected and correct: those receipts were previously claiming a coverage guarantee the SDK could not back up. Fingerprints computed by prior versions of the SDK for such receipts are not comparable to fingerprints computed by this version. Receipts where every declared invariant executed, or where the caller declares a non-"full"scope explicitly (interceptors, gateway authority-only paths), are unaffected.
@sanna-ai/gateway: the Receipt Triad is now embedded atextensions["com.sanna.gateway"].receipt_triadinstead of a top-levelreceipt_triadproperty. The canonicalreceipt.schema.jsonhasadditionalProperties: falseat the top level and does not declarereceipt_triadthere, so every gateway-emitted receipt previously failed full JSON-schema validation. This also correctsaction_hash, which used to hash the real downstream tool result -- a semantic contradiction of the gateway-boundary definition (the gateway is a proxy and cannot attest to what the downstream server actually executed).action_hashnow equalsinput_hashwithcontext_limitation: "gateway_boundary", matching Python'ssanna.gateway.receipt_v2.compute_receipt_triad/sanna.gateway.server._generate_receipt.computeActionHashremoved from@sanna-ai/gateway(packages/gateway/src/receipt-v2.ts, re-exported from index.ts) -- its only callers were the three triad-construction sites ingateway.ts, all of which now deriveaction_hashfrominput_hashdirectly.buildReceiptTriadno longer takes anaction_hashparameter; it derivesaction_hashand setscontext_limitation: "gateway_boundary"internally.
- Because the triad now lives inside
extensions, which participates in the receipt fingerprint (packages/core/src/receipt.tscomputeFingerprintInput), gateway receipt fingerprints change relative to prior@sanna-ai/gatewayversions for the same logical tool call. This is expected: the triad is now actually covered by the cryptographic fingerprint and signature it previously was not. Fingerprints computed by prior versions of the SDK for gateway receipts are not comparable to fingerprints computed by this version.
packages/gateway/tests/san848-receipt-triad-placement.test.ts(new): drives real allow/halt/escalate tool calls through a signed gateway and asserts (1) the triad lives atextensions["com.sanna.gateway"].receipt_triadwithaction_hash === input_hashandcontext_limitation === "gateway_boundary", and the old top-levelreceipt_triadproperty is absent; (2) each receipt's fingerprint recomputes to its own statedfull_fingerprint/receipt_fingerprintand its signature independently verifies (self-consistency, allow + halt + escalate); (3) each receipt passes the COMPLETE canonicalreceipt.schema.json(not just theallOfconditional slicepackages/core/src/verifier.tscompiles) via a dedicated Ajv instance, with a negative control proving a reintroduced top-levelreceipt_triadproperty failsadditionalProperties: false; (4) a redaction-enabled receipt still passes its own fingerprint recomputation.
packages/gateway/src/gateway.ts's allow-path response construction ("m. Return result with receipt metadata") pushes the_sanna_receiptsummary text block ontoresultContent, which is the SAME array object asreceipt.outputs.content(set viaoutputs: { content: result.content }in_buildReceiptwithout cloning). That in-place mutation happens AFTER the receipt has already been fingerprinted and signed. AnyReceiptSinkimplementation that retains a live object reference instead of serializing synchronously atstore()time (unlike the shippingLocalSQLiteSink/CloudHTTPSink, which happen to serialize before their first internalawait) would observe a receipt whoseoutputs.contentno longer matches what was signed -- a real, orthogonal self-consistency defect, out of SAN-848's scope (it is unrelated to where the Receipt Triad lives).san848-receipt-triad-placement.test.ts'sCaptureSinksnapshots viastructuredCloneat store-time specifically to avoid conflating this pre-existing defect with SAN-848's own fix.- The spec's Appendix E.3 ("Receipt Triad Extension Fields") documents gateway extension fields as flat properties directly under
extensions["com.sanna.gateway"](input_hash,reasoning_hash,action_hash,assurance,justification_present,justification_evaluated) -- not nested under areceipt_triadsub-key, and usingassurancerather thancontext_limitation. Python's actual gateway implementation (sanna.gateway.receipt_v2.GatewayReceiptV2,RECEIPT_VERSION_2 = "2.0") already diverges from that documented shape by nesting underreceipt_triadwithcontext_limitation, alongside siblingreceipt_version/action/enforcementsub-objects Appendix E.3 does not mention at all. This TS fix intentionally matches Python's actual v2.0 shape (per SAN-848's cross-SDK parity mandate), so it now shares that pre-existing divergence from Appendix E.3 rather than introducing a new one. Separately, spec Section 7.5 ("Gateway Boundary") is written as part of the first-class top-level Receipt Triad section (7.1-7.4), suggesting gateway receipts may have originally been intended to also carry first-class top-levelinput_hash/reasoning_hash/action_hash/context_limitationfields (the same mechanismpackages/core/src/interceptors/*.tsuse for CLI/HTTP receipts) -- in practice neither SDK's gateway does this; the top-level mechanism is treated as interceptor-specific (see thehasTopTriad/ "Interceptor assurance" comment inpackages/core/src/verifier.ts). Not changed here (would require changing sanna-protocol and/or Python); flagged for a follow-up ticket to reconcile Appendix E.3 with the actual v2.0 shape.
@sanna-ai/corehashing:__proto__(and any prototype-setter) keys are now included in canonical hashing and signing instead of being silently dropped — fixes a signature/fingerprint bypass and restores cross-SDK parity with the Python SDK (SAN-818). Objects with a__proto__own data property (e.g. fromJSON.parse) are now faithfully canonicalized; fingerprints of objects without__proto__are byte-identical to the previous behavior.
docs/ts-sdk-reference.md-- TypeScript SDK reference (structural twin of the Python pair, TS-derived behavior including the interceptor behavior matrix with exact thrown types per surface and a confirmed no-reasoning-gate statement for sannaObserve).docs/cookbook.md-- TypeScript SDK cookbook (delta recipes: subprocess and fetch interceptors, custom invariant evaluator, drift report, evidence bundle, CloudHTTPSink delivery to Sanna Cloud).
- README hero: "the gateway surface enforces governance pre-execution" corrected to "the gateway and interceptor surfaces enforce governance pre-execution" (packages/core/src/index.ts lines 201-207 ship patchChildProcess + patchFetch).
- README hero verb agreement; CLI Reference table completed (12 -> 17 commands: approve, bundle-create, bundle-verify, generate, verify-aarm).
- Cleared all pre-existing lint errors across packages/*/src (tseslint recommended); 4 targeted inline suppressions with rationale (heuristic-judge.ts interface-required params; AnyFn pass-through alias uses any (not unknown) so .apply() call sites keep the original Function semantics -- the unknown variant failed the DTS typecheck (caught by CI Build)); zero behavior changes (full suite unchanged: 73 files, 1545 tests).
- CI now gates
npm run lint(new step after Build) so lint errors cannot accumulate silently.
- CLI --version now reports the real package version (was hard-coded 1.0.0); derived from package.json at runtime.
- Internal @sanna-ai/* dependencies pinned to ^1.5.0 (were "*", which npm publishes literally -- could pair new packages with the old core 1.1.1).
- cli/gateway/mcp-server now ship LICENSE (AGPL-3.0) and README.md (files arrays referenced them but they did not exist).
- gateway dist bin had a stacked shebang (source shebang + tsup banner both emitted); removed source shebang so tsup banner is the sole injector.
- @sanna-ai/core npm README: protocol version v1.3 -> v1.5; SPEC_VERSION "1.3" -> "1.5"; CHECKS_VERSION "8" -> "10"; TOOL_VERSION "sanna-ts/1.3.0" -> "1.5.0"; computeFingerprintInput description updated to cover 21-field (cv>=10) and 20-field (cv>=9) paths.
- Root README CLI command count 16 -> 17.
- Version-parity test: TOOL_VERSION must equal @sanna-ai/core package.json version.
- Bump hono (^4.12.25) and qs (^6.15.2) out of the MODERATE advisory ranges (transitive via the gateway @modelcontextprotocol/sdk path). Closes SAN-798. Dep-update SLA SAN-501 / SOC 2 6.6 evidence trail.
- Declare
tsxas a devDependency so a clean offlinenpm ci && npm testno longer relies on npx auto-fetching it (SAN-797).
- README quickstart: install
@sanna-ai/clifor thesannacommands (core has no bin); corrected the gateway invocation; version notes 1.4.0 -> 1.5.0 (SAN-796 Part 2).
packages/core/tests/allow-conformance.test.ts(new): cross-SDK allow-disposition conformance test (SAN-765). Drives the fetch and child_process interceptors with an allowed (can_execute) action in enforce mode and asserts the emitted receipt matches the shared protocol fixture (allow_disposition_vectors), includingassurance="partial"per spec Section 7.3. Companion to the sanna-repo allow-conformance suite; both SDKs now assert the same allowed-path vector. action_hash is not pinned (the executed action's output hash is environment-dependent).
packages/core/src/verifier.ts(checkSchema): added spec Section 7.3 assurance rule -- an authority-only receipt that carries a Receipt Triad must haveassurance="partial"; any other value is a verifier error. Error text matches Pythonverify.pybyte-for-byte for cross-SDK consistency (SAN-765, lockstep with sanna-repo C2). Allow-list consumer assertions land in Phase D3.packages/core/tests/verifier-7.3-assurance.test.ts(new): two-case verifier rule test driven by a real allowed-action interceptor receipt (echo hellovia child_process interceptor,invariants_scope=authority_only,assurance=partial). Asserts no 7.3 error on the valid receipt and the exact 7.3 message +valid=falseon a tamperedassurance=fullcopy.packages/core/tests/escalate-conformance.test.ts: addedassurance: stringto theDispositionVector.expectedinterface and replaced the placeholder comment withexpect(receipt.assurance).toBe(expected.assurance), closing the SAN-765 cross-SDK conformance gap in this test.
spec(submodule): bumped pin to 323c7af (protocol main HEAD), picking up spec 7.3 assurance-when-triad enforcement and cross-SDK conformance vectors (SAN-765 #49). Verifier enforcement in sanna-ts lands in Phase D2.
packages/core/tests/spec-pin-integrity.test.ts: harden the spec-pin-integrity test to deepen a shallow submodule clone before the is-ancestor check, fixing a cross-SDK-smoke false negative. A depth-1 clone lacks the parent chain between origin/main and an older-but-merged pin, causing merge-base --is-ancestor to wrongly report the pin unreachable; the fix calls git fetch --unshallow when the submodule is detected as shallow. Genuinely dangling or unmerged pins still fail. (SAN-789, follow-up to SAN-667)
packages/core/src/interceptors/fetch-interceptor.ts,packages/core/src/interceptors/child-process-interceptor.ts: HTTP and subprocess interceptors now emitassurance: "partial"unconditionally, fixing a spec section 7.3 non-conformance. The priorhalted ? "partial" : "full"emitted"full"for executed actions that were never reasoning-evaluated; authority-only interceptors must always emit"partial"regardless of outcome. Fingerprint-inert. (SAN-765)
- README.md: corrected schema version claims to match the current implementation. Receipt Schema v1.5 (was v1.4): spec_version "1.5" (was "1.4"), checks_version "10" (was "9"), 21-field fingerprint (was 20), adding agent_identity at fingerprint position 21. Schema reference table, example receipt JSON, and cross-language compatibility note updated consistently. Protocol version reference updated to v1.5; corrected the sannaObserve enforcement framing (post-execution detection and attestation; pre-execution prevention is on the gateway surface). (SAN-752)
packages/core/tests/spec-pin-integrity.test.ts: cross-SDK parity -- ports sanna-repo's spec-submodule pin-on-protocol-main guard to sanna-ts. Fails CI if thespecsubmodule is ever pinned to a commit not reachable on sanna-protocolorigin/main(dangling or unmerged PR-branch HEAD -- supply-chain control). Network-unavailable paths skip on local dev but throw loudly in CI (CI/GITHUB_ACTIONSenv). Matches the invariant enforced by sanna-repo (SAN-667).
packages/core/tests/escalate-conformance.test.ts: cross-SDK escalate-disposition conformance test. Drives the fetch + child_process interceptors with a must_escalate decision in enforce mode and asserts the emitted receipt matches the shared protocol fixture (spec/fixtures/multi-surface-vectors.json->escalate_disposition_vectors) and that the action did not execute. The sanna-repo (Python) suite asserts the same vector (SAN-745 PR3b), making the cross-SDK escalate agreement a single shared contract.
specsubmodule: bumped to4b73ec3(sanna-protocol main) to consumeescalate_disposition_vectors(SAN-745).
assuranceis not asserted by the conformance test: TS interceptors emit it, Python interceptors do not yet (SAN-765). The agreed disposition fields are asserted (event_type, enforcement.action, enforcement_mode, status, enforcement_surface, invariants_scope, action_hash).
- Enforce mode now blocks
must_escalatedispositions in the fetch and child_process interceptors (SAN-745). Previously a constitution decision ofescalatewas non-blocking in enforce mode on the fetch interceptor and on the child_process direct-argv entrypoints (spawn,spawnSync,exec/execSyncwithout shell operators,execFile,execFileSync,fork), so an action requiring human approval executed anyway. Escalate now fails closed exactly likehalt: the action does not execute, and the interceptor emits an*_escalatedreceipt (enforcement.action: "escalated",status: "WARN",assurance: "partial", halted action hash) before raising. Matches the Python SDK and the in-pipeline escalate blocking already present for shell commands.
- Behavior change: code that previously continued past an
escalatedecision in enforce mode now receives the same simulated failure as a halt (fetch->TypeError/ECONNREFUSED; child_process ->ENOENT). Audit and passthrough modes are unchanged (escalate still executes).
- Regression tests for
-0normalization + BigInt rejection at the signing-prep boundary (SAN-538): 10 new test cases inpackages/core/tests/hashing.test.tscoveringnormalizeFloats/sanitizeForSigning/canonicalizefor both edges (scalar + nested in object/array). Closes the test-coverage gap surfaced during SAN-537 (which added the alias-identity test catching structural drift; this PR adds semantic-drift coverage). The corrected behavior was shipped by SAN-527 + SAN-537; this PR ensures future refactors cannot silently regress without test failures.Object.ischecks are used alongside===to defeat JavaScript's-0 === 0trivial pass.
- No source changes. No behavior changes. Pure test coverage addition.
- Tests would fail if
normalizeFloatswere modified to no longer normalize-0OR to no longer throwTypeErroron BigInt -- explicit regression guards per SAN-538 ACs 4 and 5. - Cross-references: SAN-538 (this ticket), SAN-527 (introduced
normalizeFloats), SAN-537 (centralized viasanitizeForSigningalias), SAN-294 (TS crypto PBT covering NaN/Infinity but not these edges).
sanitizeForSigningis now a single canonical re-export in@sanna-ai/core(packages/core/src/crypto.ts), aliasingnormalizeFloatsfromhashing.ts. Removes three inline duplicate implementations that existed inaarm.ts,constitution.ts, andverifier.ts. Net reduction in source lines; behavior preserved at all existing call sites. Two correctness-positive edge-case improvements:-0now normalizes to0, andBigIntthrowsTypeErrorat the sanitization layer (rather than passing through to JCS). Mirrors SAN-524 centralization methodology from sanna-repo. (SAN-537)
- [BREAKING]
canonicalizein@sanna-ai/corenow rejects non-integer floats withTypeError. Pre-SAN-527, canonicalize delegated to RFC 8785 JCS which accepted any number (e.g.,canonicalize(1.5)returned"1.5"). The rejection now matches Pythoncanonical_json_bytesbehavior and conforms to spec section 3.2 ("MUST reject any JSON value that is a floating-point number in signing and hashing contexts"). Also rejects BigInt explicitly (Sanna receipts use Number type only). Pre-customer state means no external consumer should be affected. Internal callers were audited in Phase 1 and classified as data-shape-safe. (SAN-527)
-
packages/core/tests/property-crypto.test.ts: property-based test suite using fast-check (^3.20.0 added to@sanna-ai/coredev deps). TypeScript mirror of SAN-293 (Hypothesis-based PBT in sanna-repo; merged 2026-05-12 as 2ffd764). 30 tests across 10 property categories:canonicalizeinvariant under dict key reordering (key-sorted output)hashContentNFC-scope invariance (ADR-004 normative);hashObjNFC byte-differentiation (no NFC normalization inside objects)computeFingerprintsdeterministic on repeat calls (3a) and stable across canonical-JSON round-trip (3b)[] vs nullproduces distinct fingerprints forparent_receipts(cv=6) andagent_identity.privilege_scope(cv=10, spec Section 2.19 optional array-of-strings field)- Integer-valued numbers serialize without decimal point — cross-SDK byte-parity with Python's int coercion (5a); non-integer floats are accepted by
canonicalize(serialized as JSON) but rejected by the signing path viasanitizeForSigningto prevent cross-SDK divergence (5b) NaN,+Infinity,-Infinityall throw incanonicalize(three explicit cases)sign+verifyround-trip on arbitrary Buffer; tampered message and tampered signature both fail verification- cv-aware field-count dispatch: cv=5→12-field, cv=6/7→14-field, cv=8→16-field, cv=9→20-field, cv=10→21-field (verified by differential receipt pairs at each boundary)
- Redaction marker shape (spec Section 2.11.1): fixture-based byte-parity against
spec/fixtures/gateway-redaction-vectors.json(cross-SDK canonical source from SAN-516); PBT on arbitrary strings; NFC-invariance of markers - Fingerprint cross-site parity: static-analysis test (10a) asserts all four consumers (
verifier.ts,aarm.ts,redaction.ts,bundle.ts) importcomputeFingerprintsfrom./receipt.jswith no inline declarations; sample-equality integration (10b) runsapplyRedactionMarkersand verifies stored fingerprint matches a directcomputeFingerprintscall on the updated receipt
sanna-ts is centralized on
computeFingerprints; static-analysis test (10a) catches drift if any consumer copies the function inline. Companion SAN-524 brings sanna-repo to equivalent centralization parity.SANNA_FAST_CHECK_NUM_RUNSenv var overrides the default 100 examples per property for nightly extended runs. Per-test timeout is 30s. Extended 1000-run pass confirmed clean.Pairs with: SAN-293 (sanna-repo Hypothesis PBT; merged 2026-05-12 as 2ffd764).
checkGatewayRedactionMarkersCorrect(receipt) -> string[]inpackages/core/src/verifier.ts: new TS verifier-side enforcement for spec section 2.11.1 marker objects. Emits umbrella stable error codeREDACTION_CLAIM_WITHOUT_MARKERfor three rejection cases: (a) content_mode='redacted' claimed but no valid markers in inputs.context or outputs.response, (b) marker dict has__redacted__=truebut missing or invalidoriginal_hash, (c) content_mode='full' claimed but markers are present (claim/state mismatch). Mirrors sanna-repo Python verifier byte-identically at the error-code level (cross-SDK error semantics aligned).- Internal helper
isRedactionMarker(value)inpackages/core/src/verifier.ts: validates spec section 2.11.1 marker shape ({__redacted__: true, original_hash: <64-hex>}). TS parallel of Python_is_redaction_marker. NOT exported (module-private). packages/core/tests/cross-sdk-gateway-redaction-vectors.test.ts: new vitest fixture-consumer test suite consumingspec/fixtures/gateway-redaction-vectors.json(15 total tests: 2 canary + 4 marker + 1 fix12 + 5 apply_redaction + 3 verifier_rejection).
spec/submodule pin bumped fromaa1ccc1e6e24faa77801463f6c171f9a0e4d0d2ctod69977132ba3be4f7a144c8e43a2ff1c65019c91(sanna-protocol PR #42 squash; SAN-516 PR 1 of 3).
Verifier-tightening change. Receipts that previously passed verification with content_mode='redacted' but no spec section 2.11.1 markers, OR content_mode='full' with markers present, will now FAIL verification with the stable umbrella error code REDACTION_CLAIM_WITHOUT_MARKER. Pre-customer state means no real-world impact; cross-SDK aligned with sanna-repo PR #67 (Python verifier; merged 2026-05-12 squash cd8b422).
SAN-249 + SAN-250 (closed 2026-05-12) brought both SDKs into spec section 2.11.1 marker-shape conformance at the EMISSION side. SAN-516 PR 1 of 3 (sanna-protocol) shipped the cross-SDK conformance fixture. PR 2 of 3 (sanna-repo) added the Python verifier rejection. This PR closes the audit-trail loop by adding the equivalent TS verifier rejection. Both verifiers now reject incomplete-state receipts with the SAME stable umbrella error code, providing cross-SDK error semantics for downstream consumers of VerifyResult.errors.
Per CLAUDE.md governance principles 'Cross-SDK coherence is load-bearing' + 'Verifier-side enforcement is non-negotiable'.
After this PR merges, the SAN-516 ticket can move to Done (all 3 PRs landed). A small follow-up may add sanna-protocol AGENTS.md documenting the cross-SDK verifier rejection contract (AC item 5; not blocking).
- Receipt redaction model rewritten to spec section 2.11.1 marker objects. Previously, the gateway used pattern-detection + in-place string substitution (e.g.,
"user@example.com"->"[EMAIL_REDACTED]"), which is NOT spec section 2.11.1 conformant. Now, redaction produces marker objects matching the Python SDK byte-for-byte:{__redacted__: true, original_hash: '<sha256-hex>'}whereoriginal_hash = SHA-256(NFC(value).encode("utf-8")).hex(). gateway.yamlconfig schema BREAKING CHANGE: thepii:block is replaced by aredaction:block matching the Python SDK shape:- Before:
pii: { enabled: true, patterns: [...] } - After:
redaction: { enabled: true, mode: "hash_only", fields: ["arguments", "result_text"] } - The
modefield is currently restricted to"hash_only";"pattern_redact"is reserved for future use and rejected at config load.
- Before:
@sanna-ai/sanna-gatewaypublic API BREAKING CHANGE:redactPII,redactInObject,PiiPattern,RedactionResultare no longer exported. The new field-level marker API is in@sanna-ai/core. Customers consuming the old pattern-detection symbols must migrate toapplyRedactionfrom@sanna-ai/core.@sanna-ai/corenew exports:applyRedaction,makeRedactionMarker, typeRedactionConfig, typeRedactionMarker.- fetch-interceptor, child-process-interceptor, core middleware now accept a
redactionConfigoption. Whenenabled: true, the receipt'sinputs.contextandoutputs.response(or any subset perfields) are replaced with marker objects BEFORE signing. Content hashes and fingerprints are recomputed against the marker-bearing receipt.content_modeis auto-set to'redacted';content_mode_sourceto'local_config'(matches sanna-repo). - spec section 2.11.4 FIX-12 pre-existing-marker injection guard ported. Pre-existing marker dicts are re-redacted via Python-equivalent
JSON.dumps(sort_keys=True, ensure_ascii=True)serialization with(', ', ': ')separators. Cross-SDK byte-identical with sanna-repo's_apply_redaction_markers.
packages/gateway/src/pii.tsandpackages/gateway/tests/pii.test.tsdeleted. Pattern-detection + substring substitution is non-conformant. If a pattern-detection-only utility is wanted later (a non-redacting PII detector for warning emission), file a follow-up ticket.
content_mode_source='local_config' (existing enum value), NOT a new 'middleware_redaction_config' value. The middleware-vs-gateway provenance distinction is carried by the enforcement_surface field per spec section 2.16. Both SDKs use 'local_config' for receipts emitted from local SDK configuration. Cross-SDK byte parity preserved on this field.
Before this change, sanna-ts shipped receipts with content_mode='redacted' metadata but no spec section 2.11.1 markers in the actual content fields. A Python verifier reading a TS-redacted receipt could not distinguish "spec-conformant redaction" from "metadata claim without redaction artifact". After this change, both SDKs produce byte-identical marker shapes; cross-SDK verifier-side enforcement (rejection of content_mode='redacted' without markers) becomes meaningful.
Paired with: SAN-249 (Python; merged 2026-05-12 squash commit 8daad7d). Pairs with new Sprint 17 P0 ticket for cross-SDK fixtures + verifier rejection of receipts claiming content_mode='redacted' without spec section 2.11.1 markers (filed 2026-05-11; will dispatch after SAN-250 merges).
- Cosmetic test-title cleanup. Renamed two stale describe(...) block
titles in sanna-ts to drop the "BLOCKED ON SAN-487 (authority
bypass)" suffix. The tests were unblocked by SAN-487 PR 2
(sanna-ts commit
bc931f6, merged 2026-05-06) -- thedescribe.skip(...)was removed at that time, but the title strings retained the stale "BLOCKED" label. Tests execute successfully; the label was misleading audit-trail noise.packages/core/tests/child-process-interceptor.test.ts:1399packages/core/tests/fetch-interceptor.test.ts:1135Both renamed to "... -- end-to-end (SAN-406 + SAN-487)" form.
- Future test-output readers seeing
BLOCKED ON SAN-487 (authority bypass)in describe titles would assume the test is still blocked or that SAN-487 isn't fixed. Both are false. Cleanup eliminates the audit-trail confusion potential. - No functional change. No assertion changes, no test-body changes.
- CHANGELOG.md historical reference (the original SAN-406 entry quoting the old describe.skip string) is unchanged -- historical audit-trail content is immutable.
- SAN-487 PR 2 (sanna-ts commit
bc931f6, merged 2026-05-06) -- the fix that unblocked the tests; left the stale titles. - SAN-406 -- the underlying redaction-emission ticket the tests exercise.
- Patched 3 production-dep CVEs (1 HIGH + 2 MODERATE) via npm
package overrides in root
package.json:fast-uri^3.1.2 (was transitive 3.1.0): patches GHSA-q3j6-qgpj-74h6 + GHSA-v39h-62p7-jpjc. Path:ajv-formats->ajv->fast-uri. HIGH severity.hono^4.12.18 (was overridden at ^4.12.12, transitive 4.12.15): patches 5 advisories. Path:@modelcontextprotocol/sdk->hono. MODERATE severity.ip-address^10.1.1 (was transitive 10.1.0): patches GHSA-v2v4-37r5-5v8g. Path:@modelcontextprotocol/sdk->ip-address. MODERATE severity.
- Side-effect bumps from pristine lock-file resolution: 99 other
transitive packages bumped to caret-compatible newer versions during
npm's full re-resolution pass (required because npm 11.x preserves
existing lock + cache resolutions; the only way to apply override-
constrained re-resolution is
npm cache clean --force && rm -rf node_modules package-lock.json && npm install --prefer-online). All 99 bumps are within the same major and verified non-breaking by the full test suite (1467/1467). Bump families: esbuild platform binaries, rolldown platform binaries, rollup platform binaries, vite, ajv, express-rate-limit, jose, zod, zod-to-json-schema, and smaller build/lint toolchain deps.
overridesRationalestring inpackage.jsonextended with a SAN-508 sentence. Existing SAN-228 + SAN-253 references preserved.
- SOC 2 6.6 (Vulnerability Management): per SAN-501 SLA, security advisories are merged within 7 days. CVEs were published 2026-05-10; this fix lands within 1 day -- well inside SLA.
- CI's
npm audit --omit=dev --audit-level=highstep was failing on every sanna-ts PR (and on origin/main itself) until this lands. Unblocks SAN-493 PR 3 of 3 (sanna-ts PR #52) to merge with green CI after rebase. - Patch-level bumps within the same major (caret syntax) avoid upstream-breaking jumps. ajv and @modelcontextprotocol/sdk continue to function with the patched transitives.
- Upstream coordination with @modelcontextprotocol/sdk to release a version that pulls patched hono + ip-address directly. Tracked as ongoing SAN-253 coordination. This PR pins via overrides until upstream catches up.
- pnpm-lock.yaml is NOT updated by this PR. Per memory rule
reference_sanna_ts_uses_npm_not_pnpm.md, sanna-ts uses npm; pnpm- lock is vestigial in tree and gets caught up by dependabot's next pass.
- SAN-501 -- dep-update SLA + SOC 2 6.6 evidence (the policy this PR satisfies).
- SAN-228 -- original overrides block + dependabot configuration.
- SAN-253 -- upstream MCP SDK coordination on transitive CVEs.
- SAN-493 PR 3 of 3 (sanna-ts PR #52) -- blocked on this; unblocks after merge.
tools/generate-state-doc.ts: drops git-SHA from thedocs/state.mdheader. The pre-fix header was<!-- generated: TS git-sha: SHA -->; post-fix it is<!-- generated: TS -->. The SHA was always one-commit-stale because regen runs pre-commit (per the sealed-gate pattern, HEAD at regen time is the parent commit), so the embedded SHA never matched the commit that landed the state.md update. Now the file contains only derived state from sources of truth (per-package versions + test counts, receipt constants, spec submodule SHA, latest CHANGELOG entry); commit SHAs come fromgit log.gitSha()function removed fromgenerate-state-doc.ts(was used only for the header; now dead code).generateFull()signature simplified from(root, sha, timestamp)to(root, timestamp). Internal API change; no external callers.main()regen print statement drops thesha=${sha},field; keeps tests + spec_version + tool_version.
packages/core/tests/state-md-header.test.ts(new file): regression guard asserting thegit-sha:substring does NOT appear in the first 5 lines of the committed state.md. Catches a future re-introduction of the embedded SHA. No side effects (reads state.md directly; does not invoke the regen command).
- Eliminates a known one-commit-stale audit artifact in state.md.
Auditors reading the file previously saw a SHA that didn't match
the commit it landed in; post-fix, the file contains no SHA and
refers auditors to
git logfor that information. - Removes the extra round-trip (regen post-first-commit) that Sonnet workarounds previously required. One-commit PRs become possible again for state-only changes.
- Cross-SDK consistency: closes the loop on SAN-493. Same fix pattern landed in sanna-protocol (PR #39, merged 16798d6) and sanna-repo (PR #63, merged 39b1152). All 3 repos now produce state.md with identical header format.
- The fix is mechanism-only: per-package versions + receipt constants + spec submodule SHA on a clean tree are byte- identical to pre-fix. The aggregate test count goes 65 -> 66 due to the new regression-guard test (a real new test, real count); core test count goes 48 -> 49.
- Cleanup of the untracked
packages/core/tests/anomaly.test 2.tsmacOS Finder duplicate -- separate hygiene ticket. The generator usesgit ls-files, so the count is unaffected by this untracked file. - Bumping the
specsubmodule pin (currentlyaa1ccc1, pre-SAN-498). Separate concern.
- SAN-493 PR 1 of 3 -- sanna-protocol (PR #39, merged 16798d6).
- SAN-493 PR 2 of 3 -- sanna-repo (PR #63, merged 39b1152).
- SAN-492 PR 1 (sanna-protocol PR #36) -- where this surfaced,
workaround commit
ca2de52. - Memory rule
feedback_state_md_hard_gate_before_commit.md. - Memory rule
feedback_state_md_regen_commands_per_repo.md. - Memory rule
reference_sanna_ts_uses_npm_not_pnpm.md.
- Full ReasoningConfig type system in
packages/core/src/types.ts: ReasoningConfig + GLCCheckConfig + GLCMinimumSubstanceConfig + GLCNoParrotingConfig + GLCLLMCoherenceConfig + JudgeConfig + InvariantJudgeOverride. Mirrors Python's structure (sanna-repo/src/sanna/constitution.py:286-440) for byte-equal cross-SDK canonicalization. Constitution interface now exposesreasoning?: ReasoningConfig | null. inspect_scripts: booleanadded toCliPermissionsinterface (defaultfalse). Closes a feature-parity gap with Python'sCliPermissions.inspect_scripts. Schema formalized this field in v1.1.0 (sanna-protocol PR #36).- Cross-SDK byte-parity regression test for v2 in
packages/core/tests/cross-language.test.tsloadingspec/fixtures/constitution-signable-vectors-v2.json(20 vectors). - v1 backwards-compat canary asserting
minimal.yaml(v1-signed) verifies under v1 dispatch.
- TypeScript SDK aligned to v2 unified canonical signable form.
constitutionToSignableDictaccepts asigningVersion: number = 2parameter. v1 path preserved unchanged for legacy verification of currently-signed customer constitutions; v2 path mirrors the reference generator atspec/tools/generate_signable_vectors_v2.pybyte-for-byte. Sign defaults to v2; verify readssignature.schemeand dispatches via a defensive_parseSigningVersionhelper. Unrecognized/malformed schemes are rejected at the verify level by returningfalse(preserving the existing boolean return contract). saveConstitutionexplicitly passessigningVersion: 1to preserve existing customer-visible YAML output. Save-time YAML output is unchanged by this release; only sign-time canonical bytes shift to v2 by default.- Spec submodule pin advanced from
3aea629toaa1ccc1(3-commit delta covering SAN-492 spec/schema/vectors/generator + state.md SHA fix + receipt schema enum widen). - YAML loader (
loadConstitutionStringor equivalent) now parsesreasoningandcli_permissions.inspect_scripts; previously dropped silently.
- Customer-visible save-path output unchanged. Existing TS callers
of
saveConstitutionget the same YAML output as before. v2 canonical bytes are produced only at sign-time. - policy_hash scope unchanged.
compute_constitution_hashcontinues to cover the v1 subset (excludes cli_permissions, api_permissions, composition). v2 constitution signatures additionally cover those fields. Asymmetry is intentional and documented in spec Section 5.3.
- SAN-492 (this entry; TypeScript SDK portion -- closes the cross-3-repo v2 alignment work). Companion sanna-protocol PR #36 (spec + schema + vectors + reference generator) and sanna-repo PR #61 (Python SDK alignment) already merged.
- Canonical signable form aligned to Python.
constitutionToSignableDictatpackages/core/src/constitution.ts:691-696now emitsnullexplicitly for absent optional sub-fields ofmust_escalate.target(url,handler). The previous object-spread pattern ({ ...r.target }) omitted undefined fields entirely, producing canonical bytes that diverged from Python'sdataclasses.asdict()output. After this change, TS-signed and Python-signed constitutions produce byte-identical canonical signable bytes for the same input. - Spec submodule pin advanced from
cc2602ato3aea629. The submodule delta is the SAN-490 sanna-protocol portion (cross-SDK byte-equal contract fixture + schema null-acceptance + spec canonical form Section 6.9 / 13.5).
- Cross-SDK byte-parity regression test in
packages/core/tests/cross-language.test.tsloadingspec/fixtures/constitution-signable-vectors.jsonand asserting byte-equal canonical signable output for all 5 vectors. - New public API
computeCanonicalSignableJson(constitution: Constitution): stringexposing the canonical signable bytes pipeline for cross-SDK parity testing. Wraps the existing internalconstitutionToSignableDict->sanitizeForSigning->canonicalizeflow with no behavioral change.
- Save-path output change.
saveConstitutionatpackages/core/src/constitution.ts:881consumesconstitutionToSignableDict, so YAML output for constitutions whosemust_escalate.targetpreviously omittedurlorhandlerwill now include those fields as explicitnull. The schema accepts both forms (persanna_constitution>="1.0.1"); existing YAML files at rest are unaffected. - Verification of constitutions previously signed by sanna-ts. Any
constitution previously signed by sanna-ts whose
must_escalate.targetomittedurlorhandlerwas signed over canonical bytes that lacked those fields. After this change, sanna-ts recomputes canonical bytes that include explicitnull, producing a different signable byte sequence. Such a constitution will not verify against this version of sanna-ts; re-signing produces a verifiable artifact under the aligned form.
- SAN-490 (this entry; sanna-ts portion -- the canonicalization alignment). Companion sanna-repo regression test follows in a separate PR that bumps the spec submodule pin.
- Pre-commit hook. Added
.pre-commit-config.yamlwithpre-commit/pre-commit-hooksdetect-private-keyto block any future PEM private key from entering the repo. CI runs the same hook on every pull request via inlinepip install pre-commit+pre-commit run --all-files. The exclude is scoped to exactly^packages/core/tests/crypto\.test\.ts$-- the legitimate PEM-format assertion test (around line 37-38) verifyingexportPrivateKeyPem()returns valid PEM format. No other path is whitelisted; any other tracked file containing PEM markers is still blocked.
- Spec submodule pin advanced from
95e87e5tocc2602a. The submodule delta is the SAN-404 sanna-protocol rotation (test-author + test-attacker key rotation, fixture regeneration, pre-commit hook addition) plus the follow-on CI fix. The TypeScript SDK consumes the rotated cross-SDK fixtures (key_idsa1b0635d...for test-author andd610a877...for test-attacker; old6edb...3af61and02dd...12de6REVOKED). SDK source is unchanged -- key_ids are read dynamically fromspec/fixtures/golden-hashes.jsonandspec/fixtures/bundle-trust-vectors.json.
- Five test modules that loaded
spec/fixtures/keypairs/test-author.keyat module scope -- a file deleted by SAN-404 PR 1's forward-only key rotation -- now use an ephemeral keypair viagenerateKeypair(). The cross-SDK pub-key load (test-author.pub) is preserved where present; only the private-key load was replaced. Affects crypto.test.ts, receipt.test.ts, verifier.test.ts, receipt-chaining.test.ts, and cv9-legacy-warning.test.ts. packages/core/tests/cross-language.test.ts:347-352V15_EXPECTED full_fingerprint pins updated forescalated,fail-halted, andfull-featuredto match the post-rotation values inspec/fixtures/golden-hashes.json.pass-single-checkis unchanged (no enforcement block; fingerprint stable across rotation).
- SAN-404 (this entry; sanna-ts portion). Companion sanna-protocol PR #34 and sanna-repo PR #59 already merged.
- CRITICAL authority bypass (TS side): mirror of sanna-repo PR 1 fix
(commit 2c02f76). Under
content_mode=redacted(orhashes_only), CLI and HTTP interceptors populated_state.suppressedPatternsfrom the manifest'spatterns_suppressedlist -- which had been redacted to["<redacted>"]. Operators who configured redacted/hashes_only mode for privacy got ZERO ENFORCEMENT. - The fix mirrors the gateway's pattern (sanna-repo
server.py:2079-2111sources_suppressedToolNamesdirectly from constitution policies). New helpergetSuppressedPatterns(constitution, surface)returns the rawSet<string>of suppressed patterns from constitution data, bypassinggenerateManifest()entirely. Both CLI and HTTP interceptors now use this helper for enforcement state population.
packages/core/src/manifest.tsexportsgetSuppressedPatterns(constitution: Constitution, surface: "cli" | "http"): Set<string>. Mirror of Python'ssanna.manifest.get_suppressed_patterns(SAN-487 PR 1 @ 2c02f76); cross-SDK behavioral parity guaranteed by mirrored authority semantics. Helper does NOT take contentMode -- enforcement state is contentMode-independent (the entire point of this fix).- 9 unit tests in
packages/core/tests/manifest.test.ts(new top-level describe"getSuppressedPatterns (SAN-487)") mirroring the Python suite. Reuses existing file-local helpersbareConstitution/makeCliCommand/makeCliPermissions/makeApiEndpoint/makeApiPermissions. Includes a regression guard assertinggetSuppressedPatterns.length === 2(no contentMode parameter). - 6 integration tests in
child-process-interceptor.test.tsandfetch-interceptor.test.tswith REAL bodies (previously empty placeholders underdescribe.skipwith SAN-487 cite from SAN-406 PR 2). Each test usespatchChildProcess({...contentMode: "<mode>"})orpatchFetch({...contentMode: "<mode>"})to set up the interceptor, triggers the suppressed pattern, finds the anomaly receipt, and asserts onext.attempted_command/ext.attempted_endpoint:- redacted mode ->
"<redacted>" - hashes_only mode -> matches
/^[0-9a-f]{64}$/ - full mode -> raw value (regression guard)
- redacted mode ->
- Removed
describe.skip(...)fromchild-process-interceptor.test.ts(line 1396) andfetch-interceptor.test.ts(line 1132). The 6 tests now execute. Updated the SAN-487 cite comment block above each describe to a post-fix note explaining the SAN-406 + SAN-487 storyline.
- SAN-487 PR 2 of 2 (this entry; sanna-ts mirror; CLOSES SAN-487). PR 1 (sanna-repo) merged at 2c02f76 (2026-05-06). With this PR, SAN-487 closes; the customer-facing content_mode=redacted/hashes_only toggle now works end-to-end (emission + enforcement) across both SDKs.
Helper unit tests assert identical Set membership for identical inputs; combined with PR 1's Python-side helper unit tests, both SDKs produce identical suppression sets for the same constitution. Integration tests prove the end-to-end path: SAN-406 emission redaction (PR 2 / 77acc44) + SAN-487 enforcement state fix (this PR) work together to deliver the documented content_mode behavior.
packages/core/tests/redaction-vectors.test.ts: consumes the cross-SDK fixturespec/fixtures/redaction-vectors.json(added to sanna-protocol in SAN-406 PR 3 at commit 95e87e5; consumed by sanna-repo in PR 4 at 0809568). 26 tests: 1 fixture-well-formedness check, 1 bidirectional vector-ID-set contract canary, 9 parametrized helper_vectors (redactAttemptedField(input, mode) === expected), 6 parametrized verifier_vectors (NEGATIVE cases: raw value under redacted/hashes_only -> marker check FAILS), 9 parametrized derived positive verifier cases (each non-full helper_vector'sexpectedvalue -> marker check PASS; full mode -> no marker check emitted). Top-level fixture read via readFileSync serves as the hard fixture-presence canary (vitest collection-error semantics); equivalent to PR 4's separate test_fixture_file_exists.- These tests are INDEPENDENT of SAN-487. They call the helper + verifier DIRECTLY (no interceptor traversal). The 6 end-to-end interceptor tests skipped under describe.skip("SAN-406 redaction emission ... -- BLOCKED ON SAN-487 (authority bypass)") in PR 2 remain skipped.
- Bumped
specsubmodule pin from 6795979 to 95e87e5 (sanna-protocol PR SAN-406 PR 3). Phase 0 scope sanity check verifies bump pulls in EXACTLY ONE commit (the new fixture). No operational schema copy in sanna-ts (verifier loads schema directly from submodule); submodule bump is the only sync operation needed.
- SAN-406 PR 5 of 5 (this entry; sanna-ts fixture consumption; CLOSES SAN-406). PR 1 (sanna-repo emission + verifier) merged at 817bf1a. PR 2 (sanna-ts mirror) merged at 77acc44. PR 3 (sanna-protocol fixture) merged at 95e87e5. PR 4 (sanna-repo fixture consumption) merged at 0809568. With this PR, SAN-406 closes; SAN-439 closes as superseded.
- Related: SAN-487 (CRITICAL authority bypass; orthogonal to this PR's scope). The new redaction-vectors.test.ts tests are INDEPENDENT of SAN-487; the 6 end-to-end tests skipped under SAN-487 cite remain skipped.
PR 4 (Python) verified redact_attempted_field("rm", "hashes_only") produces
58466ebdd352f801198118e294e38715f864985fd87977f348bfcd7db62e7c76. PR 5
(this; TypeScript) calls redactAttemptedField("rm", "hashes_only") and
asserts the SAME hex via the helper-cli-hashes-only test. Both SDKs
produce identical output -> cross-SDK byte-equal contract proven.
packages/core/src/anomaly.ts:redactAttemptedField(value, contentMode)helper implementing Section 2.22.5 single-value redaction for com.sanna.anomaly extension emissions. Three modes: "full"/undefined/null/"" (raw, current behavior preserved), "redacted" (literal<redacted>), "hashes_only" (SHA-256 hex lowercase via canonicalhashContent). Cross-SDK parity with sanna-repo'ssrc/sanna/anomaly.py.- Verifier semantic check that emits Check.name
"redaction_markers_correct"(snake_case STRING for cross-SDK parity with Python's emission). Implemented inpackages/core/src/verifier-manifest.tsascheckRedactionMarkersCorrect. Runs from bothverifySessionManifestReceiptandverifyInvocationAnomalyReceipt(placed BEFORE the receiptSet null early-return). Subsumes SAN-439 scope; that ticket superseded by SAN-406. - Tests:
packages/core/tests/anomaly.test.ts(11 helper unit tests), extendedpackages/core/tests/verifier-manifest.test.tswith 12 verifier tests including a placement-regression guard AND a cross-SDK Check.name parity guard, gateway integration tests in gateway.test.ts for redacted- hashes_only modes.
child-process-interceptor.ts:870,fetch-interceptor.ts:763, andgateway.ts:1027:attempted_command/attempted_endpoint/attempted_toolnow apply Section 2.22.5 field-level redaction at emission time. Closes AUDIT-008 (CRITICAL) on the TS side. Mirror of SAN-406 PR 1 (sanna-repo, 817bf1a).
- 6 CLI/HTTP integration tests in
packages/core/tests/child-process-interceptor.test.tsandpackages/core/tests/fetch-interceptor.test.ts(underdescribe.skip("SAN-406 ... -- BLOCKED ON SAN-487 (authority bypass)")) await SAN-487's fix. Same authority-bypass design gap exists in TS interceptors (suppressedPatterns populated from redacted manifest at child-process-interceptor.ts:825 and fetch-interceptor.ts:718). Test code preserved as harness for SAN-487 re-enable.
- Closes the AUDIT-008 emission gap on the TS side. Cross-SDK fixture in PR 3 (sanna-protocol); SDK CI consumption in PRs 4 + 5.
hashes_onlymode is for audit-time deterministic comparison, NOT privacy. Operators relying on strong privacy MUST useredacted.
- SAN-406 PR 2 of 5 (this entry; sanna-ts emission + verifier check). PR 1 (sanna-repo Python) merged at 817bf1a. PR 3 (sanna-protocol fixture), PR 4 + 5 (SDK CI consumption) follow.
- Related: SAN-487 (CRITICAL authority bypass under content_mode=redacted in CLI/HTTP interceptors; cross-SDK; 6 skipped tests cite it).
packages/core/tests/bundle-trust-anchor-vectors.test.ts: consumes the cross-SDK fixturespec/fixtures/bundle-trust-vectors.json(added to sanna-protocol in SAN-403 PR 3 of 3 at commit 6795979). Asserts every vector's expectedvalidandtrust_anchoredagainst the actualverifyBundle()verdict. 11 tests: well-formedness, bidirectional vector-ID-set contract, 2 bundle internal- reference sanity assertions, and 7 parametrized vector cases. Top-level fixture read serves as the hard fixture-presence canary (vitest reports collection error if submodule uninitialized).
- Bumped
specsubmodule pin from baa517f to 6795979. The bump pulls in sanna-protocol commits SAN-381 (R1 aggregate_suppression_reasons schema rule), SAN-383 (A1' cv<10 forbids agent_identity schema rule), SAN-372 (archive escalated.json regression guard), SAN-373 (spec Section 2.17.2 -> 2.18.4 cross-reference), in addition to SAN-403 PR 3. Runtime already implements these rules; the bump is a schema-resync, not a behavior change. Verified by full test suite green post-bump with no new failures vs pre-bump baseline.
- SAN-486 (this entry). Closes the "run by both SDK CIs" acceptance criterion of SAN-403 on the TypeScript side. SAN-485 closed the Python side at sanna-repo commit eda4dda. With both SDK consumption tickets merged, SAN-403 closes.
verifyBundle(..., trustedKeyIds)parameter (Set | null). When non-null, the bundle's receipt key_id and every constitution signature key_id must appear in the supplied set or verification fails closed. Empty Set is the explicit "trust nothing" signal.--trusted-key-ids <FILE>CLI flag andSANNA_TRUSTED_KEY_IDSenv var onsanna bundle-verify. File format: newline-separated 64-hex key_ids, lowercase, '#' comments allowed. Malformed lines reject with line number; empty file rejects.BundleVerificationResult.trust_anchoredboolean indicating whether the verdict was evaluated against an external anchor (regardless of pass/fail).- Stderr warning banner when no anchor is supplied and --json is not used
(and
trust_anchored: falsein --json output). Operators see that the verdict is self-consistent only -- the bundle internally agrees but no external authority confirms the key_id's identity claim. - Verification steps display updated from "(7-step)" to "(8-step)".
- Closes the bundle-forge attack vector at the verifier level (cross-SDK
parity with sanna-repo PR merged at 912a058). An adversary who re-signs a
genuine receipt + constitution with their own key and repackages the bundle
would, prior to this change, get a
valid: trueverdict. With a trust anchor, the forgery is now caught. Without an anchor, the warning makes the limitation visible. Approval signature key_ids are NOT yet checked against the trust anchor (known limitation; matches Python).
- SAN-403 PR 2 of 3 (this entry; TypeScript SDK). PR 1 (Python SDK in
sanna-repo) merged. PR 3 (sanna-protocol cross-SDK forged-bundle fixture
- spec/SECURITY.md updates) follows.
- Gateway non-anomaly path emitted
enforcement_modedirectly from config DSL ({enforced, advisory, permissive}) into the receipt, violating the receipt schema enum ({halt, warn, log}). Python verifier rejected such receipts; TS verifier silently accepted (cross-SDK divergence on a normative enforcement field). The anomaly path at gateway.ts (~996) already hardcoded "halt" correctly and is unchanged.
configModeToEnforcementLevel()helper: maps config DSL to spec enum (enforced->halt, advisory->warn, permissive->log) with fail-loud behavior on unsupported values (defense against malformed runtime config that bypasses the TypeScript type).- Regression tests covering all three config modes through the non-anomaly halted path, plus a fail-loud test for unknown modes.
- SAN-405 (this entry).
- Pairs the separate ticket on TS verifier full-schema enforcement, which would catch this class of cross-SDK emission divergence at verifier time.
- Gateway ListToolsRequestSchema handler: session_manifest emission protected by shared-promise pattern. Concurrent tools/list calls await the same emission promise (exactly one manifest emitted; second call waits for completion). No TOCTOU gap. Cross-SDK parity with sanna-repo SAN-380 Prompt A.
- SAN-380 Prompt B (this entry; TS half). Closes SAN-380.
- CLI + HTTP interceptors: enforcement.enforcement_mode now emits schema-conformant values (halt/warn/log) instead of interceptor mode values (enforce/audit/passthrough). Mapping: enforce->halt, audit->warn, passthrough->log. Cross-SDK parity with sanna-repo PR #49.
- SAN-379 TS follow-up (this entry).
AnomalyTrackinginterface +AuthorityBoundaries.anomaly_tracking(per-surface opt-in for CLI/HTTP invocation_anomaly emission). Default: both false (backward compat).- CLI interceptor: when
anomaly_tracking.cli === true, suppressed-command attempts emitcli_invocation_anomalyreceipt (substitutes forcli_invocation_halted). Extensions:com.sanna.anomaly.attempted_command. parent_receipts chains to active CLI session_manifest. - HTTP interceptor: when
anomaly_tracking.http === true, suppressed-endpoint attempts emitapi_invocation_anomalyreceipt. Extensions:com.sanna.anomaly.attempted_endpoint. parent_receipts chains to active HTTP session_manifest. - Spec submodule bumped to include SAN-397 Prompt A (constitution.schema.json gains anomaly_tracking field).
constitutionToSignableDictomitsanomaly_trackingwhen at defaults (both false). Pre-v1.5 constitutions hash IDENTICALLY without re-signing. Cross-language hash parity with Python PR #48.
- Extension shape matches Python byte-for-byte (SAN-395 Section 2.22.2).
- content_mode on receipt envelope only (Section 2.22.5 spec-ahead-of-impl consistent with gateway + Python interceptors).
- SAN-397 Prompt C (this entry; TS half). Closes SAN-397.
- Companion: Prompt A (protocol, PR #27), Prompt B (Python, PR #48).
- Gateway ListToolsRequestSchema handler now returns empty tools when
_emitSessionManifestfails (generation or persistence). Previously the gateway caught manifest failures silently and returned the full filtered tool list -- a governance leak. _manifestFailedstate is sticky: once manifest fails, ALL subsequent tools/list calls return empty for the gateway lifecycle.- Belt-and-suspenders: handler wraps
_emitSessionManifestin try/catch as catch-all for unexpected failures.
- Per PRD CT-7 (fail-closed): no tool-name data leaks to the agent on
manifest failure. Response is
{ tools: [] }. Cross-SDK parity with sanna-repo SAN-359 Prompt A (PR #47).
- SAN-359 Prompt B (this entry; TS half). Closes SAN-359.
- Companion: SAN-359 Prompt A (Python, PR #47, merged).
checkSchema()in verifier.ts now runs ajv-driven validation against the bundled receipt.schema.json (draft-2020-12) as a second pass after the existing hand-rolled required-field checks. Catches all conditional allOf rules: B1 (session_manifest enforcement-absent), B2 (mixed requires multi- surface), A1 (cv=10 agent_identity), A3 (com.sanna.manifest implies session_manifest), B3 (com.sanna.anomaly implies anomaly event_type), B4 (anomaly requires com.sanna.anomaly), MODIFY (modify_with_constraints requires recording fields), R1+R2 (content_mode redaction rules).- ajv validates ONLY the allOf conditional rules (not the full schema) to avoid duplicating hand-rolled required-field checks with different messages. allOf rules have no $ref/$defs dependencies and extract cleanly.
ajv-formatsadded to production dependencies (previously test-only transitive).
- Verdict-level parity restored: same schema-violating receipt now produces errors in both Python (jsonschema) and TS (ajv) verifiers. Error message TEXT differs between libraries (expected); error PRESENCE is parity-gated.
- Existing byte-equal messages (SAN-370 cv=10, SAN-222 cv=9, SAN-213 cv=8) preserved unchanged; ajv pass is additive, not a replacement.
- SAN-394 (this entry).
- Adjacent: SAN-358 (semantic checks, already merged), SAN-395 (spec B3/B4, already merged).
- New module
packages/core/src/verifier-manifest.tswithverifySessionManifestReceipt()(9 checks) andverifyInvocationAnomalyReceipt()(3 checks). Mirror of Pythonsrc/sanna/verify_manifest.pyfrom SAN-358 Prompt A (PR #46). Cross-SDK byte-equal verdict text. - New public function
verifyReceiptSet(receipts, publicKey?)in verifier.ts for cross-receipt parent-resolution.verifyReceipt()signature unchanged (backward compat). Checkinterface added to types.ts; optionalchecksfield onVerificationResult.- Spec submodule bumped to 0f99a44 (SAN-395: reserves com.sanna.anomaly namespace + B3/B4 schema rules).
- Every Check.message string matches Python character-for-character.
- Cross-language verdict fixture (SAN-358 Prompt C) will assert identical verdicts.
- SAN-358 Prompt B (this entry; TS half).
- Companion: SAN-358 Prompt A (Python, PR #46 merged), SAN-358 Prompt C (sanna-protocol fixture).
- Adjacent: SAN-394 (TS schema-validator gap; orthogonal), SAN-395 (merged; spec namespace).
- TypeScript parity for sanna-verify aarm. New module
packages/core/src/aarm.tsmirrors the Python implementation at sanna-repo f2b53a5. Same SANNA_TO_AARM decision-enum mapping, same six per-requirement check functions (R1 pre-execution interception, R2 parent_receipts chain, R3 constitution policy_hash, R4 decision-enum subset + STEP_UP chain check, R5 fingerprint + signature integrity with redacted-receipt acceptance, R6 cv-aware identity binding), aggregate report, JSON + human output formats. sanna-verify-aarmCLI subcommand in the TS CLI package. Same flags + exit codes as Python (0 PASS/PARTIAL, 1 FAIL, 2 file errors, 3 internal errors).- Cross-SDK verdict parity test: TS aggregator produces verdict structure matching Python reference output for the same receipt set (AC #5 closed for the cross-language fixture parity contract).
- New tests in
packages/core/tests/san368-aarm-verifier.test.tsmirroring Python coverage: 42 tests covering per-check PASS/FAIL/PARTIAL/N/A, STEP_UP chain check, R6 dispatch, redacted-receipt R5 acceptance, fixture-set integration, and cross-SDK parity.
- Cross-SDK verdict byte-equal: TS aggregator and Python aggregator produce identical aggregate_status + per-check status for identical receipt sets (modulo generated_at timestamp).
- Spec section "How to verify AARM conformance" (AC #6). Lands in sanna-protocol SAN-368 portion.
- SARIF output format. Marked optional; deferred.
- SAN-368 (this entry; sanna-ts TypeScript portion)
- Predecessor: sanna-repo SAN-368 portion (Python implementation, MERGED at sanna-repo f2b53a5)
- Companion: sanna-protocol SAN-368 portion (operational docs, separate Opus prompt)
- Cross-references: SAN-356 G2, SAN-361, SAN-369, SAN-370, SAN-371
- MODIFY authority decision recording infrastructure (TypeScript).
buildModifyAuthorityDecision(action, original, transformed, transformations, options?)constructs an object matchingAuthorityDecisionRecordwithdecision=modify_with_constraintsand the three required MODIFY recording fields (tool_input_original,tool_input_transformed,transformations_applied) per spec Section 2.7. Validates at construction: transformations is a non-empty array of{type, target_field, rationale}objects;originalandtransformedare string or plain object (null and arrays rejected). Cross-SDK byte-equal parity with Python helper (sanna-repo c2c6a39): identical inputs produce identical record shape, key order, and value semantics. Records produced by the helper satisfy the A1' conditional rule inreceipt.schema.json. - New test coverage in
packages/core/tests/san369-modify-recording.test.ts: 9 tests covering valid construction, key-order parity with Python, construction-time errors for malformed inputs, deterministic byte-equality, and cross-SDK byte-equal shape parity against a hardcoded Python reference output.
- Constitution-rule-driven MODIFY emission. Authority evaluation does NOT yet return
modify_with_constraints. Rule engine is a separate ticket. - Cross-SDK fixture file. Lands in sanna-protocol SAN-369 portion (hand-constructed + signed with the committed e58ed3e keypair).
- Implementer's guide example. Lands in sanna-protocol SAN-369 portion.
- SAN-369 (this entry; sanna-ts TypeScript portion)
- Predecessor: sanna-repo SAN-369 portion (Python helper, MERGED at sanna-repo c2c6a39)
- Companion: sanna-protocol SAN-369 portion (implementer's guide + cross-SDK fixture, separate PR)
- Verifier rejection of MODIFY receipts missing the three fields: SAN-368
- TypeScript verifier emits CV9_LEGACY-prefixed warning on cv=9 receipts. When
verifyReceipt(...)processes a receipt withchecks_version=9, the warnings array now includes a string starting withCV9_LEGACY:indicating partial R6 conformance only (agent_identity is absent at cv<10 per spec Section 2.19). Receipt remains valid; the warning is informational. Cross-SDK parity: warning text is byte-equal to the Python sanna verifier emission. - New test coverage in
packages/core/tests/cv9-legacy-warning.test.tsvalidating: cv=9 receipts emit exactly one CV9_LEGACY warning; cv=10 receipts emit no CV9_LEGACY warning; archive cv=9 fixtures emit the warning.
- No-action-required for existing signed cv=9 receipts. Pre-v1.5 receipts remain cryptographically valid; their 20-field fingerprints continue to verify. Verification output now includes the CV9_LEGACY informational warning.
- Cross-SDK warning-text byte-equal: matches Python sanna verifier emission exactly. Audit consumers can pattern-match on the
CV9_LEGACY:prefix regardless of which SDK verified the receipt.
- SAN-371 (this entry; sanna-ts TypeScript portion -- closes SAN-371)
- Predecessors:
- SAN-371 sanna-protocol portion (migration memo, MERGED at sanna-protocol a684a33)
- SAN-371 sanna-repo portion (Python verifier CV9_LEGACY emission, MERGED at sanna-repo ed5ae77)
- Restored strict cross-SDK signature verification. The cross-language test (
packages/core/tests/cross-language.test.ts) added agetVerifyKey()helper that skipped Ed25519 signature verification when a fixture'sreceipt_signature.key_iddid not match the bundledtest-author.pub. The helper masked an upstream sanna-protocol artifact divergence (cv=10 fixtures signed with a key not in the spec submodule). With sanna-protocol now bundling a self-consistent keypair (commit e58ed3e), the workaround is no longer needed; strict signature verification works for cv=9 archive AND cv=10 active fixtures.
- Bumped
spec/submodule pin from 9ee7527 to e58ed3e (sanna-protocol main HEAD post-artifact-self-consistency fix). - Removed
getVerifyKey()helper from cross-language.test.ts; all callsites now use the top-loadedpubKeydirectly.
- Cross-SDK byte-equal contract intact: cv=10 fingerprints byte-equal across the sanna-protocol keypair rotation (formula uses pipe-joined receipt fields, not signing key). cross-language.test.ts validates this via fingerprint comparison.
- Receipt signature compatibility: post-bump, the bundled
spec/fixtures/keypairs/test-author.pubmatches the cv=10 fixture signatures (test_key_id = 6edb993...). Strict signature verification works in both directions.
- SAN-389 (this entry; sanna-protocol portion merged at e58ed3e)
- Cross-SDK contract: SAN-355
- Unblocks: SAN-386 (v1.5 release coordination)
- Forward-pointer: SAN-391 (make generate_fixtures.py deterministic; idempotent keypair + frozen reference timestamps)
- Package versions: all 4 packages (cli, core, gateway, mcp-server) bumped 1.4.0 -> 1.5.0 (v1.5 SHIPPED moment for TS SDK runtime).
packages/core/src/receipt.ts: SPEC_VERSION 1.4 -> 1.5; CHECKS_VERSION 9 -> 10; TOOL_VERSION 1.4.0 -> 1.5.0.packages/core/src/types.ts: addedagent_identity?: Record<string, unknown>field to Receipt interface (spec Section 2.19; AARM R6 binding).packages/core/src/receipt.tsgenerateReceipt: acceptsagent_identityparam with cv-dispatch. When provided (and hasagent_session_id), emits cv=10 with 21-field fingerprint formula addingagent_identity_hashat field 21 =hashObj(agent_identity). When absent (library middleware path), emits cv=9 legacy with 20-field formula and hardcoded "1.4"/"9" overrides for byte-equal compatibility with archive fixtures.packages/core/src/receipt.tscomputeFingerprintInput: cv>=10 branch added (21-field formula).packages/core/src/verifier.ts: cv>=10 required-field check (agent_identity + agent_session_id sub-field). Mirrors Python verify.py post-Prompt-B + SAN-385.packages/gateway/src/gateway.ts: addedMCPGateway._agentSessionId(crypto.randomUUID() at constructor; stable for instance lifetime). 3 generateReceipt callers passagent_identity: { agent_session_id: this._agentSessionId }-> cv=10.packages/core/src/interceptors/child-process-interceptor.ts: lazy-init module-level_agentSessionIdon first emitReceipt; passed to generateReceipt -> cv=10.packages/core/src/interceptors/fetch-interceptor.ts: lazy-init module-level_agentSessionIdon first emit; passed to both generateReceipt callers (invocation + session_manifest) -> cv=10.spec/submodule: bumped from 03160f1 (post-SAN-378 Prompt A) to 9ee7527 (post-SAN-370 Prompt A; v1.5 protocol artifact).- Tests: SDK constant assertions flipped to "1.5" / "10" / "1.5.0"; receipt-field assertions case-by-case per emission path; new
packages/core/tests/v15-integrity.test.tscovers cv=10 emission + verifier required-field check + wire-format parity (agent_identity absent for cv=9, present for cv=10); cross-language.test.ts updated for cv=10 fixtures + getVerifyKey helper for sanna-protocol keypair rotation handling.
- gateway / cli_interceptor (child-process) / http_interceptor (fetch) surfaces emit cv=10 with populated agent_identity.
- middleware surface (sannaObserve / library middleware path) emits cv=9 legacy with no agent_identity, per spec Section 2.19 line 781-782.
- Cross-SDK byte-equal restored: Python (post-SAN-385) and TypeScript (post-SAN-370 Prompt C) emit identical wire format for cv=9 receipts (
agent_identityabsent) and cv=10 receipts (agent_identitydict withagent_session_id). cross-language.test.ts validates byte-equal fingerprint computation. - Receipt fingerprint compatibility: existing signed cv=9 receipts continue to verify via the 20-field formula; verifier dispatches on
checks_version. Re-emission post-upgrade from gateway/interceptor produces cv=10 with field 21; library middleware re-emission preserves cv=9 byte-equal output. - Wire format alignment: TS naturally omits undefined optional fields via JSON.stringify; cv=9 emissions have no
agent_identitykey (parity with Python post-SAN-385). - Release coordination (SAN-386): v1.5 SDK lockstep deployment + customer notification required BEFORE any production deploy of cv=10 emission. Merging this PR ships v1.5 to main; npm publish + customer SDK upgrade is gated by SAN-386.
- SAN-370 Prompt C (this entry) -- closes SAN-370
- Predecessors: SAN-370 Prompt A (sanna-protocol 9ee7527), Prompt B (sanna-repo a0ee706), SAN-385 (sanna-repo 36832e3)
- Forward-pointers: SAN-383 (cv<10 negative schema rule, Backlog), SAN-384 (content_mode agent_identity redaction, Backlog), SAN-386 (release coordination, Up Next), SAN-387 (typed AgentIdentityBinding interface, Backlog), SAN-388 (cross-language test archive coverage, Backlog)
- Out-of-scope: SAN-368, SAN-369, SAN-371
- packages/core/src/manifest.ts: _generateCliSurface and _generateHttpSurface now emit suppression_reasons: Record<string, string> per v1.5 spec Section 2.20.2. Empty dict {} when no suppressions; populated when the constitution declares cannot_execute or must_escalate-with-visibility-suppressed. Mirrors Python SAN-378 Prompt B (sanna-repo e8fb027) and the mcp surfaces existing suppression_reasons algorithm. Cross-SDK byte-equal output preserved.
- packages/core/src/manifest.ts: CliSurface and HttpSurface interfaces updated to include suppression_reasons field.
- spec/ submodule pin bumped from sanna-protocol f89c8c9 to 03160f1 (SAN-378 Prompt A merge: MC-006 + MC-007 fixture vectors updated to include suppression_reasons).
- packages/core/tests/manifest.test.ts: Existing TS manifest tests updated with suppression_reasons assertions for cli/http surface output (8 assertions added across 4 CLI + 4 HTTP tests -- Issue 14-equivalent for SAN-378).
- Receipt fingerprint compatibility: post-SAN-378 receipts include suppression_reasons in cli/http surfaces (per v1.5 Section 2.20.2). This changes the canonical JSON shape and therefore the receipt fingerprint when cli/http surfaces have suppressed entries. Existing signed receipts remain valid (signature is over what was emitted). Re-emission of the same input post-upgrade produces a different fingerprint than pre-upgrade. Verifiers should accept receipts as-emitted; cross-version fingerprint replay is not a conformance test.
- Cross-SDK lockstep restored: with this PRs merge, sanna-ts and sanna-repo both emit cli/http surfaces with suppression_reasons. The bounded divergence window (between SAN-378 Prompt B merge and this merge) closes.
- SAN-378 Prompt C (this entry)
- Companion: SAN-378 Prompt A (sanna-protocol fixture update, MERGED at 03160f1), SAN-378 Prompt B (sanna-repo Python implementation, MERGED at e8fb027). SAN-376 (cross-SDK fixture origin), SAN-203 (TS manifest origin, will be annotated post-done on full SAN-378 close), SAN-377 (spec clarification, MERGED), SAN-382 (R1 schema-rule enforcement gap, deferred Backlog).
- packages/core/src/manifest.ts: generateManifest gains surfaces and contentMode params per v1.5 Section 2.14 (post-SAN-377). Cross-SDK byte-equal with Python (SAN-206) via canonical hashContent helper.
- Gateway _emitSessionManifest: passes surfaces=["mcp"] + contentMode. Captures _manifestFullFingerprint BEFORE persistence (Issue 18 governance-honest fail-closed).
- Gateway _suppressedToolNames Set populated by tools/list filter loop. Stores PREFIXED names. Used by _handleToolCall to substitute invocation_anomaly receipt for invocation_halted/invocation_escalated when name is suppressed.
- Gateway _emitInvocationAnomaly: emits invocation_anomaly receipt per v1.5 Section 2.12 + 2.16.3. Receipt: event_type=invocation_anomaly, enforcement_surface=gateway, enforcement.action=halted, enforcement.enforcement_mode=halt, status=FAIL, invariants_scope=authority_only, parent_receipts=[<full_fingerprint>], extensions[com.sanna.anomaly]={attempted_tool, suppression_basis}. Signed via signReceipt if signing key configured.
- packages/core/src/interceptors/child-process-interceptor.ts: patchChildProcess emits per-surface session_manifest at init time. surfaces=["cli"], enforcement_surface=cli_interceptor. Mode-aware fail-closed/fail-open.
- packages/core/src/interceptors/fetch-interceptor.ts: patchFetch entry mirrors CLI. surfaces=["http"], enforcement_surface=http_interceptor.
- New tests: manifest-content-vectors.test.ts (loads SAN-376 fixtures), manifest-content-modes.test.ts (redacted + hashes_only with AJV schema validation), gateway.test.ts TestSessionManifestParentChain (cannot_execute + must_escalate-suppressed + typo negative).
- spec/ submodule pin bumped from sanna-protocol 5bfee54 to f89c8c9.
- packages/core/src/evaluator.ts matchesCondition: normalize condition string the same way as context (underscores -> spaces) before keyword extraction. Fixes must_escalate matching when condition contains underscores (e.g. "send_email"). Pre-existing bug revealed by SAN-376 fixture MC-003.
- generateManifest signature backwards-compatible.
- Gateway session_manifest receipts now include only surfaces.mcp. SAN-203 inherited multi-surface defect resolved.
- Gateway session_manifest receipts under contentMode=redacted/hashes_only apply spec-conformant redaction. SAN-203 inherited content_mode defect resolved.
- New invocation_anomaly receipts SUBSTITUTE for invocation_halted/invocation_escalated on suppressed-tool calls (one receipt per call, not two).
- CLI/HTTP interceptors emit session_manifest at patch time. mode=enforce raises if sink rejects manifest.
- CLI/HTTP invocation_anomaly variants: pending constitution opt-in field.
- Cross-SDK fixture vectors (MC-008 + redacted/hashes_only): SAN-380 post-this-merge.
- Race condition in session_manifest single-emission across both SDKs: SAN-381.
- spec/impl divergence on cli/http suppression_reasons: SAN-378.
- SAN-209 (this entry)
- Companion: SAN-206 (Python, MERGED 97668d1), SAN-203 (TS origin, annotated x2), SAN-202 (Python origin, annotated x2), SAN-204, SAN-205, SAN-376, SAN-377 (merged), SAN-378/379/380/381 (deferred).
- New module
packages/core/src/manifest.tswithgenerateManifest(constitution, mcpTools?). Mirror of Python'ssrc/sanna/manifest.pyfrom sanna-repo SAN-202 PR #37. Produces thecom.sanna.manifestextension dict per v1.5 spec Section 2.20: snake_case keys; deterministic sorted lists; stable suppression_reason enum (Section 2.21); per-surface breakdown (mcp / cli / http); fail-closed when constitution is null. - Gateway
tools/listhandler applies authority filtering: suppresscannot_executetools; suppressmust_escalatetools whenconstitution.authority_boundaries.escalation_visibility === 'suppressed'; deliver others. Suppressed tools are absent from the response (anti-enumeration). - Gateway emits a
session_manifestreceipt on the FIRST tools/list call per gateway lifecycle. State-tracked via_manifestEmitted: boolean. Receipt hasevent_type="session_manifest",invariants_scope="none",enforcementabsent (per v1.5 Section 2.16.3). Cross-language parity with Python (sanna-repo SAN-202).
- Pre-Manifest gateway behavior preserved when no constitution is loaded: tools pass through unfiltered, no manifest receipt emitted.
- v1.4-era constitutions (no
escalation_visibility) default to"visible"per SAN-205 TS half (PR #26). must_escalate tools remain in tools/list as before.
- SAN-203 (this entry)
- Companion: SAN-202 (Python, PR #37 merged), SAN-209 (TS interceptor manifest emission), SAN-206 (Python interceptor manifest emission), SAN-205 (constitution authority enum, merged), SAN-375 (TS schema sync, merged), SAN-204 (v1.5 protocol schema, merged).
AuthorityBoundaries.escalation_visibility(v1.5+, default"visible"; backward compatible).Compositioninterface + optionalConstitution.compositionfield.AuthorityDecisionTypeextended from"halt" | "allow" | "escalate"to add"modify"and"defer"(v1.5+; reserved for future runtime evaluators starting with SAN-369). evaluateAuthority does not return either value in v1.5.BoundaryTypelegal values extended withmodify_with_constraintsanddefer_for_context(v1.5+; reserved).
constitutionToSignableDictbuildsauthority_boundariesmanually;escalation_visibilityis included ONLY when non-default ("suppressed"). Pre-v1.5 constitutions hash IDENTICALLY without re-signing. Mirrors the Python fix from PR #36 (sanna-repo SAN-205 Python half).
- v1.4-era constitutions WITHOUT escalation_visibility or composition parse cleanly; defaults applied. No migration needed.
- Cross-language hash parity preserved: TS and Python produce identical hashes for the same v1.4-era constitution.
- SAN-205 TS half (this entry; companion Python PR #36 already merged in sanna-repo).
- Companion: SAN-203 (TS gateway filtering, depends on this), SAN-204, SAN-375 (already merged), SAN-374 (already merged).
- Submodule
spec/bumped from sanna-protocol commit72097f2to5bfee54(post-SAN-204; sanna-protocol v1.5 release). v1.5 introduces 10 new event_type values, themixedenforcement_surface, agent_identity field (required at cv=10), the com.sanna.manifest extension namespace, the suppression_reason enum, and the modify_with_constraints + defer_for_context authority decisions.
- cv=9 receipts continue to validate against the new schema (SAN-204 used CONDITIONAL cv=10 rules so the new requirements are no-ops at cv<10). All existing tests pass.
- This bump alone does NOT activate cv=10 in the SDK. SDK code flips CHECKS_VERSION 9 -> 10 in SAN-370.
- SAN-375 (this entry)
- Companion: SAN-374 (sanna-repo schema sync, already merged), SAN-205 (constitution authority enum + escalation_visibility), SAN-203/209/370/371 (TS feature work that depends on this sync).
- New required top-level field
tool_name(v1.4+, required at cv>=9). Canonical SDK identity constant"sanna-ts"in TS. Participates in fingerprint as position 17. - New optional nullable fields
agent_model,agent_model_provider,agent_model_version. Capture LLM model identity at receipt generation. Null = opt-out; absent = not captured. Fingerprint positions 18-20. - Verifier v1.4 required-field check: rejects cv>=9 receipts missing
tool_name. Error text byte-equivalent to Python SDK and spec §13. - Verifier 20-field fingerprint dispatch for cv>=9.
SPEC_VERSIONbumped to"1.4".CHECKS_VERSIONbumped to"9".TOOL_VERSIONchanged to bare semver"1.4.0"(was tool-qualified"sanna-ts/1.3.0"). SDK identity now lives in the newtool_namefield.- All 4 npm packages bumped to
1.4.0(core was1.1.1; cli, gateway, mcp-server were1.0.2). Closes the package-version lag noted in SAN-217. - Fingerprint algorithm extended from 16 to 20 fields at cv=9. Legacy receipts (cv=8, cv=6/7, cv=5) unchanged.
- Spec submodule advanced to sanna-protocol main (1532f28), picking up v1.4 schema and canonical fixtures.
- Pin 4 transitive deps to patched versions via
package.jsonoverrides: hono ≥4.12.12 (auth bypass, cookie injection, SSE injection, path traversal), @hono/node-server ≥1.19.13 (auth bypass via encoded slashes), express-rate-limit ≥8.2.2 (IPv4-mapped IPv6 bypass), path-to-regexp ≥8.4.0 (ReDoS × 2). All 4 are transitive through@modelcontextprotocol/sdk, which still declares ranges admitting vulnerable versions. SAN-253 tracks upstream coordination. Overrides can be removed when upstream catches up. - CI gate:
npm audit --omit=dev --audit-level=highnow fails PRs on any HIGH or CRITICAL advisory in production deps. - Dependabot: enabled via
.github/dependabot.ymlfor ongoing scans.
- 16-field fingerprint formula (CHECKS_VERSION 8) with enforcement_surface_hash and invariants_scope_hash at positions 15-16. SAN-213.
- Required Receipt fields enforcement_surface and invariants_scope for v1.3+ receipts.
- 4-action enforcement override in generateReceipt: halted→FAIL, warned→WARN, escalated→WARN (only when computed status is PASS). Mirrors Python receipt.py:678-692.
- Verifier 4-action enforcement override in checkStatusConsistency. Mirrors Python verify.py:479-491.
- Verifier v1.3 required-field assertion: rejects cv>=8 receipts missing enforcement_surface or invariants_scope. Mirrors Python verify.py:900-919.
- Centralized TOOL_VERSION constant in receipt.ts; bundle.ts imports it.
- Hardcoded enforcement_surface labels at all emit sites: middleware ("middleware"), child-process-interceptor ("cli_interceptor"), fetch-interceptor ("http_interceptor"), gateway ("gateway"), mcp-server ("middleware"), cli demo and generate ("middleware").
- Vocabulary normalization: TS internal decision verbs ("halt"/"escalate"/"allow") map to canonical Sanna spec enforcement.action values ("halted"/"escalated"/"allowed") via ENFORCEMENT_ACTION_MAP at all interceptor and gateway emit sites.
- Cross-language v1.3 fixture verification test.
- HALT-regression guard tests at child-process and fetch interceptors.
- SPEC_VERSION 1.1 → 1.3.
- CHECKS_VERSION 7 → 8.
- tool_version default sanna-ts/1.1.0 → sanna-ts/1.3.0.
- Spec submodule advanced to sanna-protocol v1.3.
- Non-spec "HALT" status value from interceptor receipts. Status now derived from enforcement.action via the canonical mapping.
- Stale local CHECKS_VERSION="6" override in mcp-server. Now imports from core.