Skip to content

Changelog

Single source of truth

This page mirrors CHANGELOG.md at the root of the repository.

Changelog

All notable changes to signet are documented here.

The format is based on Keep a Changelog. Versioning follows SemVer, with the understanding that pre-1.0 minor versions may break the API.

Unreleased

[0.1.11] -- 2026-06-24

Completeness, canonicalization, post-quantum, and a zero-install verifier

Four additive, opt-in hardening features. All default to OFF, so existing chains stay byte-for-byte verifiable with no migration.

Added

  • Monotonic sequence numbers + signet audit verify-contiguity. Build an HmacChain(..., sequence=True) to stamp a per-chain monotonic _seq (HMAC-bound) into each entry. The new verifier reports gaps, duplicates, out-of-order numbering, and unnumbered entries, and accepts --expect-start / --expect-end to assert an operator-declared boundary. This closes the one gap the prev_hmac links cannot show on their own: a truncated tail (or never-written entry at a boundary) still links cleanly, but a hole in the sequence proves an entry is missing. New API: verify_contiguity(), ContiguityReport, ContiguityGap.
  • RFC 8785 (JCS) canonicalization, versioned and opt-in. HmacChain(..., canon="jcs") signs entries under the JSON Canonicalization Scheme. The scheme is recorded in a signed _canon marker, so writer and verifier always agree and each entry self- describes its canon. Out-of-range integers (e.g. ts_ns) are I-JSON-encoded per RFC 7493 before canonicalization. Requires signet-sign[jcs] (the rfc8785 package); legacy chains never load it.
  • Post-quantum ML-DSA-65 (FIPS 204) receipt signer. MLDSAReceiptSigner joins Ed25519ReceiptSigner for receipts that must resist a harvest-now-decrypt-later adversary. New CLI: signet keys generate-mldsa. Requires signet-sign[pq]. Marked EXPERIMENTAL (the backend is the pure-Python dilithium-py reference implementation; not constant-time). Note: ML-DSA-65 signatures are ~3.3 KB, so the receipt header is ~6.6 KB.
  • Standalone zero-install verifier (tools/verify_standalone.py). A single file with no import signet that re-derives the canonical serialization and verifies an HMAC chain, its sequence completeness, and Ed25519 / ML-DSA receipts from the log/receipt bytes plus a key alone. Hand it to an auditor who has neither installed signet nor trusts you. A parity test asserts its canonicalization stays byte-identical to the in-tree serializer.

Changed

  • ChainVerifier now reports a non-canonicalizable entry (NaN/Inf in metadata, an unknown/tampered _canon marker, or a missing JCS backend) as a structured MALFORMED_LINE break instead of letting the exception escape the walk.
  • New optional extras: jcs (rfc8785) and pq (dilithium-py); both folded into all and the dev environment.

[0.1.10.1] -- 2026-05-16

Hotfix: 324 KB spiral test asserts deadline-fired signal, not wall-clock

The v0.1.10 CI matrix flaked on a single cell — Python 3.12 / ubuntu- latest — where the 324 KB random-bytes spiral test took 15.9 s. The 14 s wall-clock bound (already raised v0.1.9.2) was correct in spirit but tied to GitHub Actions runner allocation; every other matrix cell (Python 3.11 / 3.12 / 3.13 across all three OSes) finished in under 14 s on the same commit.

The right fix: assert the side channel directly. The test now verifies check._last_bfs_deadline_exceeded is True after the spiral — which is what we actually care about (the cap engaged) rather than how long the runner took to finish.

Changed

  • tests/unit/test_round16_hunt.py::test_324kb_random_spiral_under_4s renamed to test_324kb_random_spiral_deadline_fires; assertion refactored from wall-clock-bound to side-channel (_last_bfs_deadline_exceeded).

[0.1.10] -- 2026-05-16

Polish release: hostile-reviewer triage closures

External-review-driven cleanup. Six items closed; one (prompt-injection module split) is deferred to v0.1.11 so it can land alongside its own confidence cycle.

Changed (maturity / framing)

  • PyPI classifier: Development Status :: 3 - Alpha → Development Status :: 4 - Beta. The classifier was contradicting the README's "production-grade" framing; v0.1.10 closes that gap. Beta status accurately describes "operationally tested by external reviewers; pre-1.0 minor versions may still break API contracts."
  • Overhead claim (README.md): < 5 ms overhead per request → < 5 ms p50 overhead per request (p95/p99 insignet bench --gatedocumented below). The next code block had always shown p99=9.5ms; the marketing line now states the percentile explicitly so the numbers line up on first read.
  • Commit-authority framing (README.md): "The model never holds commit authority" was overstating without the upstream-auth context. Reframed to "Combined with upstream caller authentication, signet is the layer where the refusal decision lives." Same value prop, no hidden dependency.
  • LLM teleology (README.md, docs/architecture.md, docs/index.md): "Sufficiently capable models ignore that instruction whenever their objective gradient outweighs it" → "Current models comply with system-prompt restrictions inconsistently under adversarial pressure; that compliance is not a security boundary." Behavioral framing replaces teleological framing — same operational claim, doesn't read as alignment hand-waving to the audience signet wants as allies.

Added (supply-chain hygiene)

  • Trusted Publishing for PyPI (.github/workflows/publish.yml): the publish job now uses PEP 740 OIDC token minting (id-token: write) instead of a long-lived PYPI_API_TOKEN secret. PEP 740 attestations are published per artifact automatically.

Operator action required before next tag push: on the PyPI project page (pypi.org/manage/account/publishing/), add a Trusted Publisher binding with owner=thornveil-ai, repository=signet, workflow=publish.yml. The previous API-token path can be temporarily restored by re-adding the password: parameter to the pypa/gh-action-pypi-publish step if the binding setup hasn't happened yet.

Documented (packaging clarity)

  • signet-sign (PyPI) vs. signet (import) (README.md): prominent blockquote at the top explaining the divergence. Migration of the import path is deferred to v0.2 with a deprecation cycle — invasive for existing users.

Deferred to v0.1.11

  • prompt_injection.py module split: 2578 LOC, heavily interconnected. Splitting into prompt_injection/{normalize,decoders,rules,check}.py per the hostile-reviewer recommendation needs its own R30 hunt cycle to verify zero behavior change. Bundling it with the bookkeeping release would mix risk profiles. The hostile-reviewer "audit surface per file" concern stays valid until v0.1.11.

Acknowledged out of repo scope

  • Bus factor 1 (recruit outside contributor with commit access before DoD-facing pitch) and launch-narrative diversification beyond the Substack write-up are human-action items, not code.

[0.1.9.2] -- 2026-05-14

Hotfix: raise BFS wall-clock to 10s + restore fail-closed default

v0.1.9.1 flipped the BFS-deadline default from "block" to "audit_warn" to dodge a false-positive class on long benign base64 (npm SRI / git SRI / CSP hashes). The CI matrix immediately caught the consequence: the relaxed default broke R16's "N ≤ 16 always blocks" security guarantee. On slow CI hardware the 2 s deadline fired BEFORE depth-10-through-16 base64-nested attack cascades unrolled, and the "audit_warn" default then allowed the request. The test parametrization test_b64_nested_blocks[10] through [16] failed across every non-macos-3.11 matrix cell.

The right fix, shipped here: raise the wall-clock budget from 2.0 s to 10.0 s and restore "block" as the default. 10 s accommodates legitimate long base64 inputs on any reasonable hardware (npm sha512-...== SRI complete in well under 1 s even on the smallest GitHub Actions runners), while still being well under the 12.5 s uncapped R14 baseline — the CPU-DoS backstop is preserved. Restoring "block" as the default reinstates the R16 security promise for adversarial pad-attack payloads that still hit the cap.

The configurability of on_decode_budget_exceeded remains intact; operators legitimately shipping multi-megabyte user content beyond scan_max_chars can still opt into "audit_warn" to keep traffic moving with a structured warning.

Changed (vs. v0.1.9.1)

  • _BFS_WALL_BUDGET_SECONDS: 2.0 s → 10.0 s.
  • PromptInjectionCheck.on_decode_budget_exceeded default: "audit_warn" → "block" (reverts the v0.1.9.1 default flip).
  • tests/unit/test_round18_hunt.py: test_deadline_policy_block_opt_in_blocks renamed back to test_deadline_default_policy_blocks; constructor argument for "block" opt-in removed from the same file's two regression tests (the default IS the regression-pinned policy again).
  • tests/unit/test_round16_hunt.py::test_324kb_random_spiral_under_4s: bound relaxed 10 s → 14 s. The BFS deadline is now 10 s; the test wraps admission / scan / result plumbing around it. 14 s is still comfortably below the 12.5 s uncapped R14 baseline.

Net effect

v0.1.9.0 had two real bugs: silent-allow under deadline burn on attacker-padded depth-16 cascades (R16's promise leaked) AND CI matrix failures from coincidence of the FP class. v0.1.9.1 tried to solve the FP class by changing defaults; it succeeded at that but re-opened the security promise. v0.1.9.2 solves both at once by giving the deadline enough budget to accommodate legitimate inputs, keeping the fail-closed default for the adversarial residue.

[0.1.9.1] -- 2026-05-14

Hotfix: relax BFS-deadline default + loosen CI timing thresholds

The v0.1.9 CI matrix (Python 3.11/3.12/3.13 across Ubuntu / macOS / Windows GitHub Actions runners) immediately surfaced one production- shape false-positive class and two CI-hardware timing flakes:

  • on_decode_budget_exceeded default changed "block" → "audit_warn". The R18 closure shipped with "block" as the default to close a silent-allow P0 (attacker padding the depth-14 cascade past the 2 s wall-clock budget). On real-world CI hardware the 2 s budget doesn't always finish unrolling long benign base64 strings — npm sha512-...== package-lock SRI integrity attributes, git commit SRI checksums, CSP sha256-... directives — and the "block" default false-positives on legitimate developer / infrastructure traffic. The new "audit_warn" default preserves the allow path with a structured warning (_refusal_kind="decode_budget_exceeded", bfs_deadline_exceeded=True) so operators see the deadline burn rate in audit and can either raise the wall-clock budget, drop scan_max_chars, or opt explicitly into "block". The deadline remains a CPU-DoS backstop; the depth-16 ceiling + per-depth budget is the security boundary. Operators with strict-traffic profiles (classified-network gateways, audit-only environments) should pin on_decode_budget_exceeded="block" in config.
  • tests/unit/test_round16_hunt.py::test_324kb_random_spiral_under_4s: relaxed 4 s wall-clock bound to 10 s. Locally the 324 KB random- bytes spiral completes in ~700 ms; GitHub Actions runners routinely observed 6 – 7 s. 10 s is still well under the 12.5 s uncapped R14 baseline so the cap-active invariant is preserved.
  • tests/unit/test_v017_polish.py::test_one_megabyte_benign_input_completes_quickly: relaxed 1 s wall-clock bound to 3 s. Under coverage instrumentation on ubuntu-latest the same payload routinely hit 1.5 – 2 s.

No PyPI artifact for v0.1.9 needs yanking — the package is runtime-functional and the only user-visible regression (on_decode_budget_exceeded default false-positive on long benign base64) is closed by the v0.1.9.1 default change. Operators already running v0.1.9 should pip install --upgrade signet-sign to pick up the new default; deployments that explicitly set on_decode_budget_exceeded in config are unaffected.

Tests

  • 1893 → 1894 (one R18 test renamed; constructor argument added at two sites that pinned the BLOCK path).
  • CI matrix expected to land green for the first time since v0.1.7.

[0.1.9] -- 2026-05-12

Eleven hunt-fix cycles after v0.1.8 — the version that survives a determined adversary

v0.1.8 advertised a "the version that actually delivers on the project's promises" pitch. The post-ship confidence hunt ran 11 cycles of five domain-isolated hunters against the locally-fixed tree (audit, server, streaming, pipeline+checks, CLI+plugins), each cycle's findings closed locally before the next hunt. Cycle counts: 31 findings → 35 → 16 → 14 → 17 → 14 → 6 → 4. The journey is documented in docs/bug-hunt-log.md cycle 7. Headline outcomes:

  • 3 P0 streaming bypasses closed — SSE chunk-boundary split, unparseable-JSON event leaking raw bytes, unbounded _pending_raw_sse (250 MB → 5 MB peak under bomb).
  • 2 P0 pipeline regressions closed — override-rule \b boundary letting Pleaseignore previous slip past, BFS deadline fail-open under attacker padding.
  • R14 prompt-injection regression caught and reverted — the inflating-chain alarm had a catastrophic false-positive rate on JWT tokens, npm sha512-... hashes, git commit SRI checksums, CSP headers, RFC 2047 MIME subjects. v0.1.9 removes the alarm; depth-16 ceiling + per-depth budget is the boundary.
  • Audit chain marker-aware bridge survives process restart and cache_prev=False multi-process backends (the original R10 fix only worked same-instance; R12+R22 close every variant).
  • Streaming walker covers every text-bearing schema field — choices[i].text / .message.content / .logprobs.content[].token / delta.refusal / delta.reasoning / delta.audio.transcript / tool_calls[*].function.{name,arguments,description} / event-level id/model/system_fingerprint/error.message. Default-deny on unknown text fields.
  • Plugin discovery hardened against hostile metaclass __getattribute__ raising on __name__/__repr__/__class__, bounded by _truncate_for_log against 10 MB __repr__ DoS, BFS short-circuit on (KeyboardInterrupt, SystemExit) re-raise.

Fixed (P0)

Streaming SSE

  • SSE chunk-boundary bypass — _extract_sse_content was stateless across aiter_bytes() chunks. A data: line split across chunks (TLS records, HTTP/2 frames, MTU boundaries) silently allowed the marker through. New _SSEBuffer class with per-stream _pending_line / _pending_data state; byte-level _pending_raw_sse holds raw bytes until the LAST event terminator before client emission; inspection runs on assembled event first. CR/LF/CRLF/CR-only event terminators all recognized via _SSE_EVENT_TERMINATOR_RE.
  • Unparseable-JSON event leaks raw bytes — _flush_event's JSONDecodeError catch silently incremented dropped_frame_count while letting raw event bytes flow to the client. Hostile upstream smuggled by appending a junk data: line. Now sets malformed_event_seen=True; _forward_stream polls it and aborts via _emit_upstream_error_abort(reason="upstream_sse_malformed").
  • _pending_raw_sse unbounded — no size cap meant 250 MB upstream / 720 MB peak under unterminated stream. New _MAX_PENDING_RAW_SSE_BYTES = 4 MiB cap; exceeded triggers upstream_sse_unterminated abort with audit row.
  • SSE depth-recursion bypass — _collect_inspectable_strings cap at depth 6 silently returned [] for content at depth 7+. Cap raised to _MAX_JSON_DEPTH (64); overflow returns _DepthSentinelList sentinel; _flush_event detects and aborts via upstream_delta_too_deep reason token.

Server admission

  • Invalid UTF-8 body → 500-no-audit — non-UTF-8 bodies (gzip-encoded, latin-1, any high-bit byte) raised UnicodeDecodeError past the JSON-decode except tuple, surfacing as bare 500 with no audit row, no correlation_id, no signet shape. _admit now catches UnicodeDecodeError+LookupError; routes through _record_preflight_refusal with _refusal_kind="invalid_encoding".

Pipeline checks

  • /v1/completions + /v1/embeddings unscanned — PromptInjectionCheck._extract_text read only body["messages"]. Two of three gated endpoints had zero prompt-injection defense; embeddings are a common indirect-injection vector via RAG. _extract_text now also walks body["prompt"] (string or list), body["input"] (string or list), plus tools[*].function.{name, description, parameters}, tool_choice (string or object), messages[*].{name, tool_calls[*].function.arguments}, response_format.json_schema.schema, metadata (recursive walk).
  • Override-rule \b boundary bypass — leading \b on ignore_previous / disregard / forget_prompt / jailbreak_keyword / developer_mode / no_restrictions let an attacker glue a single letter onto the verb (Pleaseignore previous instructions, xyzzyjailbreak). LLM tokenizers split the glued prefix back into [X, ignore] so the model still saw the verb. Leading \b dropped; trailing \b retained so igniter still doesn't match ignore.
  • BFS deadline fail-open — when the 2-second wall-clock deadline burned under attacker padding (20-80 KB high-entropy noise prepended to a depth-14 b64 cascade), the silent-allow path let depth-N attacks through. Now configurable on_decode_budget_exceeded: Literal["block","escalate","audit_warn"] defaults to "block". _refusal_kind="decode_budget_exceeded" audit row records the deadline burn.

Fixed (HIGH)

Streaming

  • Tool-call args + reasoning + audio transcript + refusal + logprobs tokens uninspected — _extract_sse_content read only delta.content. Now harvests delta.tool_calls[*].function.{name, arguments, description}, delta.refusal, delta.reasoning, delta.reasoning_content, delta.audio.transcript. Default-deny via _collect_inspectable_strings recursive walk over all string values; structural-key skip list (_SSE_DELTA_STRUCTURAL_KEYS) scoped to TOP-LEVEL delta only; nested keys always inspected.
  • choices[i] sibling fields uninspected — _flush_event walked only choices[i].delta. choices[i].text (/v1/completions legacy), choices[i].message.content (buffered-as-SSE), choices[i].logprobs.content[].token all bypassed inspection. New _SSE_CHOICE_STRUCTURAL_KEYS ({finish_reason, index, object}) with choice-level structural validation; recursive walk on remaining fields.
  • Event-level fields uninspected — id, system_fingerprint, model, error.message, usage.* skipped inspection. New _SSE_EVENT_STRUCTURAL_KEYS with _validate_event_top_level_ structural_field checking enum values for object.
  • Realtime WS used wrong structural skip set — _collect_inspectable_strings(event, _top_level=True) activated the DELTA-level skip on an EVENT-level dict, so event.type / event.id / event.stop / event.tool_call_id / event.function_call_id / event.object smuggled markers through. Now passes _event_top_level=True; mirrors the HTTP path's structural pre-abort loop.
  • Realtime walker mishandled _DepthSentinelList — treated as regular empty list. Deep-nested (>64) events yielded zero sibling strings. Now refuses the frame with sanitized refusal frame and pipeline.realtime audit row.

Audit chain

  • AuditEntry.from_dict lacked type validation — hmac / prev_hmac tampered to non-string (null, true, 42) crashed ChainVerifier.verify() with raw TypeError. Now tightened: hmac / prev_hmac must be str, ts_ns must be int in [0, 10**19]; bad types route through MalformedAuditEntry and BreakKind.MALFORMED_LINE.
  • _read_archive except clause too narrow — caught only JSONDecodeError; KeyError/TypeError/ValueError from AuditEntry.from_dict crashed verify. Widened to all four; verify_with_archives outer except matches.
  • Compaction marker MAC signature — is_compaction_marker was a pure shape check; any caller writing an entry with check_name="_compaction" permanently blocked compactions (persistent DoS). Now is_compaction_marker(entry, *, keyring=) verifies an HMAC-SHA256 MAC over the marker's identifying fields with the active key, domain-separated by b"signet-compaction-marker-v1\x00". _has_marker_shape(entry) is the unsigned shape check used for verifier dispatch and compactor A2 DoS guard; is_compaction_marker adds the MAC for trust decisions. Split closes both the fail-open on key revocation and the user-controllable DoS surface.
  • Marker-aware tail read — _read_prev_hmac / _read_tail_hmac return last.prev_hmac (the bridge value the marker commits to under its own signed HMAC) when the tail is a compaction marker. Works regardless of cache_prev state, survives process restart, closes the post-full-sweep LINK_MISMATCH across cache_prev=False multi-process backends.

Pipeline checks (encoding arms race)

  • Bounded-depth iterative BFS replaces single-pass decoder. _extract_decoded BFS with _MAX_DECODE_DEPTH = 16, _PER_DEPTH_ BUDGET = 16 KiB, total budget 256 KiB, cycle detection via seen: set[bytes]. Every decoded blob re-fed into _decode_one_pass. Per-depth budget allocation prevents tier-0/1 noise overlays from starving deeper layers; tier-2 cipher overlays drop when their slot is exhausted.
  • Per-depth budget exhaustion bypass (R11/R13) — original FIFO budget let depth-0 overlay products saturate the global cap before depth-1 attack candidates surfaced. Per-depth slots + smallest-first prioritization within each depth.
  • 18 encoding channels — base64 (padded/unpadded/MIME-with- newlines), base32 (upper/lower/base32hex), base36, base58, base62, base85, ASCII85, hex (with separators, 0x prefix, xxd-style), URL percent-encoding, HTML entities (decimal+hex), Unicode \uXXXX + ES6 \u{...} + \x{...}, ROT13, Caesar-N (1-25 excluding 13), Atbash, reverse-string, punycode (OverflowError/LookupError/ UnicodeError-safe + printable-ratio gate), quoted-printable, gzip+hex / zlib+base64 (bomb-safe via _safe_gzip_decompress / _safe_zlib_decompress with 64 KiB input + 1 MiB output caps), UUencode, decimal-codepoint chr() runs.
  • Confusables map expanded to ~80 entries across Cyrillic, Greek (uppercase + lowercase), Armenian, Coptic, IPA, Tamil, Kannada, Malayalam, Devanagari, NKO digit-zero, Roman-numeral, small-cap blocks. Covers i/n/u/t/c/o/r/a/d/e/g/l/k/m/p/v/s.
  • scope_drift output marker scan NFKC + zero-width strip — fullwidth SECRET//NOFORN, ZWSP-interleaved S​E​C​R​E​T//NOFORN, circled-letter ⓢⓔⓒⓡⓔⓣ//ⓝⓞⓕⓞⓡⓝ all now block under UNCLASS request.
  • Markdown emphasis + backslash split — i*g*n*o*r*e previous and i\g\n\o\r\e previous now block via _strip_markdown_split parallel scan.
  • pipeline.post_complete catches BaseException with asyncio.CancelledError re-raise. SystemExit/KeyboardInterrupt from RECORD plugins no longer kills the batch; CancelledError propagates so graceful shutdown works.

Server

  • Non-dict upstream JSON crashes — top-level array/scalar/null, or {"choices": "string"}, or {"choices": [1,2,3]} raised AttributeError past the outer except. Now defensively coerced; non-dict / non-list choices / non-dict choices[0] route through _record_upstream_failure with refusal_kind="upstream_protocol_violation".
  • 3xx upstream redirects → bypass — sync + streaming both forwarded 3xx verbatim to client. Now: audit row + signet-shaped 502 with upstream_redirected reason, no raw body / Location URL in response.
  • 413 oversize body → no audit row — direct return without _record_preflight_refusal. Now routes through helper with _refusal_kind="body_too_large"; correlation_id + attribution headers.
  • Forwarded-header CRLF/non-ASCII injection — Authorization / OpenAI-Beta / OpenAI-Organization values were forwarded without validation; \r\n / \0 / 0x80-0xFF bytes triggered 502 misattribution at httpx. New _header_value_is_safe ASCII-strict validator; 400 at admit with _refusal_kind="header_invalid_charset".
  • pipeline.post_complete in _forward_unary unwrapped — a crashing RECORD plugin returned 502 after upstream 200. Now wrapped (matches streaming twin); outer fallback hides exception classname under strict mode, always emits correlation_id + X-Signet-Upstream.
  • All preflight 4xx responses unified shape — single _preflight_response() wrapper + _upstream_attribution_headers. Every refusal carries correlation_id, X-Signet-Upstream, signet-shaped JSON with stable snake_case error tokens: {empty_body, json_decode_error, invalid_encoding, non_object_body, non_finite_float, session_id_too_long, session_id_invalid_charset, body_too_large, json_too_deeply_nested, header_invalid_charset}.
  • Method-not-allowed routing asymmetry — GET /v1/<unknown> returned 405 advertising POST; POST /v1/<unknown> returned 404 catch-all. _method_not_allowed now swaps to the catch-all body via _REGISTERED_V1_PATHS.
  • Session-ID length + charset caps — _MAX_SESSION_ID_BYTES = 256
  • _SESSION_ID_RE = ^[A-Za-z0-9_.:\-]+$. Realtime WS admission parity (was previously HTTP-only).
  • ServerConfig immutability + validate-first __setattr__ — _VALIDATED_FIELDS covers upstream_url, port, request_timeout_s, max_request_body_bytes, audit_log_path, hmac_secret, shutdown_grace_seconds, extra_forward_headers, pool fields. Scheme validator rejects non-http/https. NaN/Inf rejected on floats. HMAC secret 32-byte floor (NIST SP 800-107). Header-name token-charset enforced. Pool keepalive ≤ max cross-field check. Re-validation runs BEFORE super().__setattr__ so rejected values don't persist.

CLI

  • Audit-log path symlink refusal — new AuditLogSymlinkError + _assert_not_symlink + _open_audit_log_append (POSIX O_NOFOLLOW + Windows os.path.islink pre-check). Centralized _open_jsonl_backend helper across every chain-walking CLI command.
  • Windows reserved device names rejected at parse time — _reject_windows_reserved_device_name covers {CON, NUL, PRN, AUX, COM1-9, LPT1-9}, case-insensitive, including trailing-space and trailing-dot variants (CON, CON., CON .txt). Applied to --audit-log, --out, --public-out, --output, signet init <target>.
  • _sanitize_for_terminal covers Unicode bidi / C1 / BOM / LSEP/PSEP — was ASCII-only; now strips bidi overrides (U+202A-202E, U+2066-2069), C1 controls (U+0080-009F), BOM (U+FEFF), line/paragraph separators (U+2028-2029) in addition to ASCII <0x20 and 0x7F.
  • 20+ sanitization sites swept — audit verify pretty mode (entry_id, detail, last_known_good_hmac), audit tail/count/ report (markdown + JSON), replay, plugins list/doctor, doctor --self/--probe-injection, serve banner, bench markdown/ CSV, keys generate-ed25519 key-id (parse-time strict charset), pipeline-loader exceptions, malformed-audit-line echo. AST sweep test enforces the discipline.

Plugin discovery hardening

  • Hostile __repr__ / __str__ / __name__ defended — new _safe_repr, _safe_str, _safe_name helpers catch BaseException from plugin-controlled accessors. Helper fallback strings themselves use _safe_name(exc) so a hostile metaclass __getattribute__ raising on __name__ doesn't defeat the fallback. _truncate_for_log(max_chars=1024) bounds 10 MB __repr__ DoS.
  • (KeyboardInterrupt, SystemExit) re-raise before BaseException — plugin import raising operator-intent signals propagates; GeneratorExit / MemoryError / etc. caught and recorded as load failure, later plugins still loaded.
  • AST sweep test — tests/unit/test_round19_cli_hunt.py programmatically walks discovery.py; any future bare repr(obj) / str(exc) / obj.__name__ / type(obj).__name__ / obj.__class__.__name__ on plugin-controlled locals trips the test.

Fixed (MED / LOW)

  • HTTP client trust_env=False — httpx default trust_env=True let process-env HTTPS_PROXY / SSL_CERT_FILE / CURL_CA_BUNDLE silently MITM upstream. Now explicitly trust_env=False, verify=True with configurable pool limits.
  • from_env Unicode whitespace + bidi rejected — SIGNET_UPSTREAM_URL env value strip rejects C1 controls, Cf/Zl/Zp Unicode categories, NBSP, bidi marks/overrides, BOM.
  • anchor.py AnchorProtocolError — defensive extraction with named exception type instead of raw AttributeError/TypeError.
  • signet doctor --self corpus drift WARN line — stale install detection (compares against _CANONICAL_PROBE_IDS); WARN, not FAIL.
  • signet bench --gate p100=/p0= rejected — gate parser enforces 0 < pct < 100.
  • signet bench --requests capped at 1_000_000 — memory bound on the task-allocation up-front.
  • pipeline.record.error synthetic audit row — RECORD-stage check that raises now appends a structured pipeline.record.error row with the failing check name + exception type; sibling RECORD checks still run.

Added — corpus expansion

  • Probe corpus 11 → 60 positive entries. New entries cover every R7-R18 hunt finding: nested b64 depths 2/3/4/7/12, polyglot compositions (b64+rot13, b85+b64+rot13, rot13+b85+b64), base32hex / base36 / base58 / base62 / base85 / ASCII85, MIME-base64 with newlines, hex-with-separators, hex-0x-comma, URL-percent, HTML decimal + hex entities, Unicode escape, ES6 \u{} + \x{} curly braces, gzip+hex / zlib+b64, gzip+url-percent, Greek-cluster + non-Latin homoglyph variants, reverse-string, atbash, Caesar-5, markdown-emphasis, backslash-split, punycode, quoted-printable, UUencode, decimal-codepoint, byte-budget-exhaustion (60 KB pad), boundary-bypass for each override rule, jailbreak space-split, jailbreak standalone, devanagari-zero, greek-lambda, non-latin-homoglyph.
  • PROMPT_INJECTION_BENIGN_CORPUS (11 entries) — must-ALLOW regression suite for production-shape inputs the R14 inflating-chain alarm false-positived: JWT tokens, npm sha512-... hashes, SHA-512 SRI, git commit messages, CSP sha256-... headers, RFC 2047 MIME encoded-word subjects, nested b64 of English. Pairs with the positive corpus to lock the FP rate at zero.

Changed

  • PromptInjectionCheck._MAX_DECODE_DEPTH raised from 8 to 16 (R14 introduced 8; R18 raises to 16 with _PER_DEPTH_BUDGET = 16 KiB keeping total at 256 KiB).
  • PromptInjectionCheck.base64_min_length from 24 to 4 (short attacks like DAN, god mode on, disregard above were escaping encoded channels).
  • ServerConfig.inspect_all_sse_lines default False → True (retry:, event:, id: SSE field values now inspected by default).
  • httpx.AsyncClient constructed with explicit trust_env=False, verify=True, limits=... (was: defaults).
  • ServerConfig is now effectively frozen post-__post_init__: __setattr__ re-runs validation; rejected values don't persist.
  • Override-rule regex family in prompt_injection.py no longer anchors with leading \b (closes glued-prefix bypass; trailing \b retained so igniter doesn't match ignore).
  • pipeline.post_complete now catches BaseException with explicit asyncio.CancelledError: raise to preserve cooperative cancellation while protecting against hostile RECORD plugins.

Documented out-of-scope

The following encoding channels are knowingly NOT covered by the default PromptInjectionCheck and live in LLM-judge-plugin territory:

  • Morse code, NATO phonetic alphabet, Pig Latin
  • Brainfuck source
  • Whitespace cipher (Tab/Space/Newline encoding bits)
  • Vigenère / other key-required ciphers

The check is defense-in-depth, not a hard boundary. The corpus documents what we catch; the absence list documents what we don't. Operators wanting these covered should add a COMMITMENT-stage LLM-judge check.

Tests

  • 763 → 1738 total tests (+975 across cycle 7): the largest test expansion in the project's history. Every closure has a regression test; tests/unit/test_round{4,7,9,11,13,14,15,16,17,18,19,21}_ hunt.py files document each cycle's findings.
  • Probe corpus: 60/60 positive blocked, 11/11 benign allowed.
  • ruff check src tests: clean.

Acknowledgments

The 11-round hunt-fix discipline was made possible by Claude Code's parallel subagent dispatch — five domain-isolated hunters per cycle attacking the local tree, findings rolled up, fixes dispatched as narrow-scope agents, integrated suite verified between cycles. The public bug-hunt log (docs/bug-hunt-log.md cycle 7) is the chronological record. Several R14-era fixes introduced their own regressions (the inflating-chain FP rate disaster being the most visible); R16-R18 reverted and rebuilt with negative-corpus regression tests so the FP class can't return silently.

[0.1.8] -- 2026-05-10

The version that actually delivers on the project's promises

v0.1.7 closed ~90% of v0.1.6's bug surface. The v0.1.7 confidence-hunt (docs/bug-hunt-log.md cycle 5) found that one P0 (S1 classification leak) and two new HIGH bypasses (N1 ROT13 prefix, N2 truncation tail — both regressions introduced by Phase 1's prompt-injection improvements) survived the polish. v0.1.8 closes them, plus the four broken-CLI surfaces, plus the V2 concurrency race, plus NF1 (audit-row gap) and NF2 (NaN crash). Probe corpus: 11/11 blocked (was 6/9 at 0.1.6, 9/9 at 0.1.7). Every advertised feature is verifiable against the published wheel.

Fixed (P0/HIGH from the v0.1.7 confidence hunt)

  • S1 — Classification leak via accumulated_text_cap (was P0 in v0.1.6, advertised-fixed-but-not-actually-fixed in v0.1.7). The fix: ScopeDriftCheck.inspect_response_chunk now scans the current chunk parameter directly when accumulated_text_truncated=True. Per-context cursor in ctx.scratch prevents double-counting on the cumulative scan path. The S6 contract (don't scan non-data: SSE lines on the default path) is preserved because the chunk-direct fallback is gated on cap-saturation. Integration test: pad-1-MiB-then-leak blocks.
  • N1 — ROT13 fast-path English-prefix bypass (new HIGH introduced by v0.1.7 C6.7). _looks_like_natural_english sampled only the first 4096 chars; an attacker prepending 4 KB of stop-words skipped ROT13 decoding for a tail-appended attack. The fast-path is removed; ROT13 always runs. The 1-2 ms savings wasn't worth the bypass surface. New corpus entry rot13_english_prefix_bypass is the permanent gate.
  • N2 — PromptInjection truncation-tail bypass (new HIGH introduced by v0.1.7 C6.6). The scan_max_chars=512KB cap silently allowed injection past the cap. Now PromptInjectionCheck accepts on_scan_truncated: Literal["block","escalate","allow"] = "block". Default fails closed (match_source="truncation-fail-closed"). "allow" opts back into the v0.1.7 shape for operators legitimately shipping multi-megabyte content. Corpus entry truncation_tail_bypass gates the regression.
  • NF1 — Malformed body 400 writes audit row (HIGH charter violation: v0.1.7's H1 fix landed the 400 response shape but skipped the audit row). New _record_preflight_refusal helper wires synthetic audit rows for every pre-pipeline 400 path: empty body, JSONDecodeError, non-dict body, and the new non-finite-float gate. Rows carry _pre_pipeline_refusal=True plus a _refusal_kind discriminator (empty_body, json_decode_error, non_object_body, non_finite_float). Metrics (signet_pipeline_decisions_total{check="pipeline.preflight"}) stay consistent with pipeline-stage decisions.
  • NF2 — NaN/Infinity in JSON crashes upstream forward (HIGH). Python's json.loads accepts NaN/Infinity/-Infinity; httpx's encode_json rejects with ValueError, which v0.1.7 misattributed as 502 upstream_forward_failed. New _contains_non_finite_float walks the parsed body before forwarding and refuses with 400. Depth limit prevents pathological inputs from blowing the recursion limit.
  • V2 — HmacChain.append outside cross-process lock (HIGH). v0.1.7's A7 lock landed for the compactor but the appender path still read the chain head outside the lock, so cache_prev=False with concurrent Windows appenders could fork the chain on the os.replace race. New FileLockingJsonlBackend.append_locked_with_link routes the entire read-modify-write through one acquire. HmacChain.append refactored into _build_linked_entry(prev_hmac) so the locked path can call it inside the acquire. cache_prev=True path is byte-identical to v0.1.7.

Fixed (broken CLI surfaces from cycle 5)

  • A9 — Anonymize slug 8 hex → 16 hex. v0.1.7 CHANGELOG advertised 16 (64 bits); cli.py was still 8. Now matches the docstring contract.
  • A13/F2 — audit verify --json missing fields. v0.1.7's Phase 2 agent added signet_version + verified_at to the VerificationReport dataclass; the CLI's JSON serializer omitted them. Wired through.
  • F1 — audit compact --force traceback leak. Stacked-compaction errors no longer escape as raw Python tracebacks through the CLI; wrapped in ClickException with the remediation hint.
  • F3 — signet init scaffold missing PromptInjectionCheck. Scaffold pipeline now includes the check (Option A: scaffold is useful on first run). doctor --probe-injection helper also emits a friendly hint when every probe leaks as plain HTTP 200 with no shadow header, pointing the operator at the missing check (Option B: UX win for legacy scaffolds).

Added — the adoption push

  • signet bench — new CLI subcommand that measures per-request overhead, decomposed by stage. Operators evaluating signet can verify the "<5 ms" claim themselves; CI can use --gate p95=10ms,p99=20ms to catch regressions. JSON / Markdown / CSV output formats. Spec at docs/bench.md.
Per-request overhead (excluding upstream):
  Stage         p50     p95     p99     max
  ADMISSION    2.1ms   4.8ms   6.2ms   12.4ms
  INSPECTION   0.4ms   0.9ms   1.3ms   3.1ms
  RECORD       0.7ms   1.4ms   2.0ms   4.3ms
  TOTAL        3.2ms   7.1ms   9.5ms   19.8ms
  • examples/ — three paste-and-go deployment recipes:
  • examples/docker-compose/: signet + Ollama + optional Prometheus/Grafana with a pre-loaded dashboard
  • examples/kubernetes/: minimal Helm chart (deployment, service, configmap, secret, pvc) with /healthz liveness + /readyz readiness, non-root container, RO rootfs
  • examples/github-action/: CI workflow with signet lint --strict, signet doctor --probe-injection, and signet bench --gate on public runners (no secrets needed)
  • docs/bug-hunt-log.md — public iteration record from v0.1.5 through v0.1.8. Every published version that fails a stated promise is documented; every hunt cycle that surfaces new bugs adds. The log is the credibility artifact for an OSS LLM safety gate.
  • Probe corpus expansion: 9 → 11 entries (added rot13_english_prefix_bypass and truncation_tail_bypass so the v0.1.7-era regressions can't return silently).

Changed

  • PromptInjectionCheck no longer has a "natural English" fast-path for ROT13 (security > 1ms speedup).
  • PromptInjectionCheck.on_scan_truncated defaults to "block" (was: silent allow with scan_truncated=True metadata in v0.1.7). Operators with legitimate long-input workloads opt into "allow".
  • README rewritten with a "Why use this" section, a visible link to the bug-hunt log, and a link to examples/. Hero pitch tightened.

Tests

  • 705 → 763 total tests (+58 across this cycle): unit 693, integration 70, plus 7 skipped that hit a live LLM (Ollama / RigRun) and skip when unreachable.
  • All five P0/HIGH from the v0.1.7 confidence-hunt verified fixed against the published signet-sign==0.1.8rc1 wheel before the final tag.
  • Probe corpus 11/11 blocked.

0.1.7 -- 2026-05-09

The polish release -- every bug surfaced by the v0.1.6 hunt is fixed

Five hunters surfaced ~98 issues against v0.1.6. v0.1.7 lands every P0/HIGH plus most P1/P2 polish, with regression tests so each found bug can never silently return. Three release candidates (v0.1.7-rc1 through v0.1.7-rc3) staged the work; the final tag is the polish bundle.

Fixed (P0 / HIGH)

Server core

  • H1: _handle_chat no longer crashes with a 500 + Python traceback when the request body is a JSON list, scalar, or null. _admit now validates that the parsed body is a JSON object and returns a structured 400 ({"error":"request body must be a JSON object"}) with a synthetic audit row.
  • H2: X-Signet-Upstream and X-Signet-Upstream-Status are now set on every forwarded response, including the 502 wrappers emitted when the upstream returned non-JSON, an empty body, or a redirect. Operators can finger-point upstream-vs-signet errors on every code path now, matching the docstring contract.
  • H3: The unsupported-endpoint refusal body no longer ships the literal string "v0.1.3". The version is interpolated from signet.__version__.
  • H4: Boolean env vars (SIGNET_SHADOW, SIGNET_ALLOW_EPHEMERAL_KEY, SIGNET_EMIT_RECEIPTS, SIGNET_STRICT_ERROR_REDACTION) accept {"1","true","yes","on","enabled"} case-insensitive. The CHANGELOG promised SIGNET_SHADOW=1 would enable shadow mode in v0.1.6; it now actually does.

Audit subsystem

  • A1: verify --including-archives no longer crashes with an uncaught zlib.error when the gzip body of an archive is corrupted. _read_archive wraps decompression in try/except (zlib.error, UnicodeDecodeError) and surfaces the failure as a structured ARCHIVE_FORMAT_INVALID break.
  • A2: Re-compaction over an existing compaction marker is now refused cleanly with an actionable error instead of producing a silently broken multi-archive chain. (The walker can't bridge marker-to-marker yet; that's deferred.)
  • A3: audit verify no longer crashes with a raw json.JSONDecodeError on malformed JSONL lines. Truncated lines, stray text, and UTF-8 BOM prefixes now surface as BreakKind.MALFORMED_LINE per-entry breaks. JsonlBackend opens the log with encoding="utf-8-sig" so a leading BOM is stripped silently.
  • A4: signet audit compact --output <existing> refuses by default. Pass --force to overwrite. Archive integrity is no longer silently lost to a typo.
  • A6: A single-byte tamper of prev_hmac no longer doubles up as both a LINK_MISMATCH and a SELF_MISMATCH; the verifier short-circuits the self-check when the link already failed.
  • A7: The compactor and the appender now coordinate via a <log>.compacting sidecar lock. Concurrent appends during compaction fail loudly (the documented contract) instead of silently dropping writes.

Checks layer

  • C1: OwnerResolutionCheck rejects header values containing CR/LF/NUL or exceeding the configured length cap. The v0.1.6 path admitted bare \r\n injections into audit-row metadata.
  • C1.4: The human: / agent: value prefix check is now case-insensitive so Human:alice resolves identically to human:alice. The block hint message tells operators about the case-insensitive prefix explicitly.
  • C1.5: An X-Policy-Name value that itself starts with a literal policy: prefix is now stripped before recording so the audit row doesn't carry a doubly-prefixed identifier.
  • C2.1: ClassificationGateCheck treats whitespace-only classification headers as missing instead of admitting them as the literal whitespace string.
  • C3: RateLimitCheck fails closed (block) when the backing state store raises an exception. Previously a Redis outage would silently let traffic through.
  • C4: RegexContentCheck and RegexOutputCheck use the optional regex package's wall-clock timeout to bound ReDoS surface. The re fallback path stays as the no-extra-deps default.
  • C4.2: RegexContent accepts a roles= filter so the matcher scans only specific message roles (e.g. roles=("user",)) instead of every message in the conversation.
  • C6: PromptInjectionCheck lowered its decoder length floor from 64 chars to 24 so short obfuscated payloads no longer slip past. Single-message inputs and 1 MiB inputs are now both correctly bounded.
  • C6.7: A ROT13 fast-path skips the decoder when the input contains common English stop-words; the check rate stays correct but the cost on natural-language traffic drops to near-zero.
  • C7: ScopeDriftCheck's default classification-marker dictionary picked up the markers the v0.1.6 dictionary missed (OFFICIAL, PROTECTED, etc.) and matches case-insensitively.
  • C8: TokenBudgetCheck now reserves the pessimistic estimate against the per-owner budget at admission and reconciles on completion. A burst of in-flight requests can no longer stack-bypass the cap.
  • C8.3: A negative max_tokens is now refused cleanly instead of silently falling through.

Plugin discovery

  • D1: discover_plugins now distinguishes status="duplicate_name" plus a duplicate_with=<other_dist> field so operators can see when two installed plugins both register the same check name.

CLI

  • C1 (CLI): signet doctor --self <down> now exits non-zero whenever any probe fails. v0.1.6 happy-pathed even when the gate was down.
  • C2 (CLI): signet init does partial-write-skip-existing -- when the target directory has some scaffold files already, the missing files are written and the existing ones are left alone.
  • C4 (CLI): The keys generate-ed25519 --key-id <id> success message renders the path via Python's repr() so Windows paths with spaces / UNCs show cleanly. The command also writes a <out>.meta.json sidecar capturing the key-id, algorithm, and generation timestamp.
  • C5 (CLI): signet serve --config <broken> surfaces a one-line ClickException instead of a multi-page Python traceback.
  • C6 (CLI): Every audit subcommand that walks the chain emits a structured warning when the chain is empty, instead of a silent no-op.
  • C7 (CLI): signet audit tail --filter foo=bar (an unknown field name) now raises a ClickException with the list of valid fields.
  • C9 (CLI): The lint success message and the report headers interpolate signet.__version__ everywhere, so the version always matches the installed binary.

Added

  • regex package as an opt-in runtime dependency (pip install signet-sign[regex]). Used by RegexContent / RegexOutput for ReDoS-bounded matching; falls back to stdlib re when not installed.
  • signet plugins doctor subcommand. CI gate for plugin-heavy deployments: discovers every plugin, surfaces ABI mismatches, duplicate-name collisions, and import errors, exits non-zero on any of them.
  • signet audit compact --force -- the explicit overwrite for the default refuse-existing-archive behavior (A4).
  • signet audit verify --summarize-cascades -- collapse cascading LINK_MISMATCH runs after a single tamper into a single CASCADE_SUPPRESSED summary break, keeping large-chain reports readable (A11).
  • ServerConfig.inspect_all_sse_lines field. Opt-in tighter SSE scanning that inspects every data: line in a streamed event, not just the first; default False preserves v0.1.6 behavior.
  • VerificationReport carries signet_version and verified_at (A13). Stored verify reports tie back to the binary that produced them.
  • Sidecar <out>.meta.json on signet keys generate-ed25519 --key-id.
  • New BreakKind values: MALFORMED_LINE (A3) and CASCADE_SUPPRESSED (A11).
  • New DiscoveredPlugin status: duplicate_name plus a duplicate_with field (D1).

Changed

  • Strict error redaction preserves transport reasons. Coarsened 4xx responses still surface stable upstream_protocol_violation / upstream_exception / similar transport tokens so SDK retry contracts work. Policy-refusal reason continues to coarsen to "refused".
  • Boolean env vars accept {"1","true","yes","on","enabled"} case-insensitive across SIGNET_SHADOW, SIGNET_ALLOW_EPHEMERAL_KEY, SIGNET_EMIT_RECEIPTS, and SIGNET_STRICT_ERROR_REDACTION (H4).
  • audit verify on a chain-empty file no longer prints the dangling (last hmac=) sentinel parenthesis (A15).
  • audit report --no-anonymize drops the (anonymized) header suffix when raw owner IDs are being printed (A5).
  • "1 blocks" rendering corrected to "1 block" in the Markdown report pluralization sweep.
  • Markdown report timestamps no longer double-tag UTC -- the +00:00 ISO offset is trimmed before the human-readable UTC suffix is appended (A12).
  • Plugin authors are now recommended to pin signet-sign~=0.1.0 (was ~=0.1) to follow the v0.1.x ABI window precisely.
  • CLI --dev bundle documentation across README, docs/index.md, and signet serve --help is now consistent: four items (--allow-ephemeral-key, --audit-log audit.jsonl, --config pipeline.py, --no-strict-error-redaction).

Documentation

  • README's refusal-payload example now leads with the strict-redaction shape ({"error":"refused","correlation_id":"..."}) -- the production default since v0.1.5 -- with a follow-on showing the verbose body that --dev flips on (M5).
  • docs/index.md corrected to say signet init writes .env.example (not .env) (M7).
  • docs/deploying.md /health payload table now includes service and shadow and the three-state semantics of audit_chain_head_hmac (L1).
  • Em-dash sweep across all source files: 418 occurrences of U+2014 replaced with -- so help text and error messages render cleanly on Windows cp1252 stdout.

Internal

  • 627 unit tests (was 418 at v0.1.6 ship). +209 new tests; every finding has a regression test.
  • Three RC tags (v0.1.7-rc1, v0.1.7-rc2, v0.1.7-rc3) staged the work; the final tag is the polish bundle.

0.1.6 — 2026-05-07

The architectural-stretch release — bug fixes, the marquee feature, plus six P3 items at ship-in-0.1.6 scope

Three release candidates (v0.1.6-rc1 through v0.1.6-rc3) staged the work over the sprint window so each chunk got soak time on PyPI before the final tag. Final tag includes all of P0, P1, P2, and the ship-in-0.1.6 boundary of P3.

Fixed (P0)

  • B1: Owner.create(type="human") previously did not coerce string inputs to the OwnerType enum, leaving owner.owner_type as a raw string and breaking downstream is OwnerType.HUMAN checks. Now routed through a new _coerce_owner_type helper that accepts the enum, lowercase, uppercase, and Title Case strings; invalid strings raise ValueError with the list of valid values.
  • B2: KeyRing(keys={"k1": b"x" * 32}, active_id="k1") previously stored raw bytes instead of Key instances, causing kr.active.key_id to raise AttributeError. The dict-shape constructor now wraps bytes values into Key before falling through to the list-handling path.
  • B3: New tests/unit/test_constructor_aliases.py parametrizes every public constructor over its accepted input forms (24 cases). Both bugs above slipped through 262 unit tests in v0.1.5 because coverage only exercised canonical typed inputs; this test file catches the next alias drift.

Added (P1 polish)

  • F1 — --shadow mode (the marketing-leverage feature). Run signet in non-enforcing pilot mode: pipeline runs, audit chain records every decision with metadata.shadow=True, metrics fire, but block / escalate / redact decisions are neutralized at the response layer. The would-be refusal is surfaced to the caller as response headers (X-Signet-Shadow-Decision, -Reason, -Stage, -Check, plus X-Signet-Correlation-Id). New signet_shadow_would_have_blocked_total Prometheus counter tracks the would-be refusal rate during pilots. /health body gains "shadow": true when shadow is on. --shadow / SIGNET_SHADOW=1 / ServerConfig.shadow=True to enable.
  • F2 — signet replay <correlation_id> promoted to first-class. Takes the correlation_id from a 403 / 202 response (the only field exposed under strict redaction) and pretty-prints the full audit row, including HMAC verification against the configured key. signet audit show continues to work as the canonical alias.
  • F3 — Case-insensitive header sweep via a new get_header_ci helper in signet.core.context. Production traffic from reverse proxies that normalize headers differently (uvicorn lowercases, nginx may preserve case) no longer silently misses lookups. 29 new parametrize cases cover X-Classification, X-Commit-Owner, X-Agent-Id, X-Policy-Name across canonical / lowercase / uppercase variants.
  • F4 — Three-state audit_chain_head_hmac on /health: "disabled" (no audit configured), null (configured but empty), or 8-hex-char tail (has entries). Monitoring can distinguish "alive but no audit" from "alive but chain not yet written to."
  • F5 — Per-check latency histograms. New signet_check_duration_seconds{check, stage, decision} Prometheus histogram. Pipeline wraps every check hook (pre_request, inspect_response_chunk, inspect_tool_call, post_complete) in a perf_counter timer. Timeouts map to decision=block. Optional dependency: Pipeline without metrics observer just skips observation.
  • F6 — signet lint SIG001 repurposed for explicit RateLimitCheck.priority overrides below the default 100. The original SIG001 was made moot by v0.1.5's RateLimitCheck.priority=100 default; the rule now catches plugin authors / pipeline hand-edits that recreate the v0.1.4 footgun (rate limits draining on downstream-blocked requests).

Added (P2 nice-to-haves)

  • N1 — signet doctor --probe-injection. New flag on the existing doctor command. Sends a 9-payload corpus of obfuscated injection attempts (cyrillic confusable, stretched whitespace, base64, ROT13, base32, hex, DAN persona, etc.) to --self and asserts every one is blocked. Catches "someone mis-edited the rule list" regressions in CI. Corpus lives at signet.cli_helpers.probe_injection_corpus for reuse.
  • N2 — signet_response_text_truncated_total counter. Increments the first time ResponseContext.accumulated_text_cap is hit per response. INSPECTION-stage drift checks scanning a prefix instead of the full output is now visible in Prometheus.
  • N3 — --upstream-label round-trip integration test. Locks in the chain from CLI flag → ServerConfig.upstream_label → X-Signet-Upstream response header on success, refusal, and escalation paths. /health, /healthz, /version, /readyz correctly omit the header.
  • N4 — Multi-worker rate-limit caveat documented in docs/deploying.md. Default in-process bucket store means uvicorn --workers N>1 gives effective per-owner rate limit of N× configured. Bundled RedisRateLimitState documented as the strict-limit fix.

Added (P3 architectural stretch — ship-in-0.1.6 boundary)

  • A1 — Plugin entry-point convention + ABI versioning. Third- party Check authors register against [project.entry-points."signet.checks"]. New signet.plugins.discover_plugins() walks signet.checks / signet.adapters / signet.anchors groups and returns a structured DiscoveredPlugin list with status per entry (loaded, incompatible_abi, load_error). signet.core.check.CHECK_ABI_VERSION = 1 anchors plugin ABI compatibility going forward. New signet plugins list CLI surfaces the discovery report. Spec at docs/plugin-authors.md. Deferred to 0.1.7: hot-reload, plugin-supplied lint rules, plugin-supplied report formats.
  • A2 — Audit log compaction with Merkle archival. JSONL chains grow unboundedly; this adds happy-path compaction that archives old ranges while preserving end-to-end verifiability. signet audit compact --before <ts> --output <archive> builds a SHA-256 Merkle tree over the entries, writes a byte-stable archive (header + Merkle tree + gzip JSONL), and replaces the compacted range in the live log with a single HMAC-chained compaction marker. signet audit verify --including-archives <dir> walks live log + every referenced archive and reports MERKLE_MISMATCH / ARCHIVE_MISSING / ARCHIVE_FORMAT_INVALID breaks alongside the existing kinds. Spec at docs/audit-archive-format.md. Deferred to 0.1.7: concurrent-write safety (chain MUST be quiesced; documented in three places and enforced via --quiesce-confirm), partial-compaction recovery, encryption-at-rest, sub-range incremental verification.
  • A3 — Streaming abort-frame contract. Drove an SSE stream end-to-end through the proxy for the first time at v0.1.6 RC and the test harness surfaced 5 real bugs in the existing streaming path. All fixed: discriminator key was signet_aborted not signet_abort (wire spec mismatch), abort frame missed correlation_id and stage, no strict-redaction coarsening, no audit row for partial state on mid-stream block, malformed upstream / 5xx mid-stream emitted nothing. Now standardized: the abort frame is {"signet_abort":true,"reason":...,"correlation_id":...,"stage":...,"check":...} followed by data: [DONE]. Strict redaction collapses reason to "refused" and omits check. Spec at docs/streaming.md.
  • A4 — signet audit report --since <duration>. Daily / weekly markdown summary suitable for direct paste into incident review. Aggregates by decision distribution, top firing checks, top blocked owners (anonymized by default via SHA-256 of salt:owner_id). Shows deltas vs prior period. Includes audit- chain integrity check + head HMAC tail. --format json for programmatic consumers; --no-anonymize for authorized roles. Salt comes from SIGNET_ANONYMIZE_SALT or --anonymize-salt. Deferred to 0.1.7: HTML output with sparklines, time-series export to Prometheus / Datadog, auto-cron emission to Slack / webhooks.
  • A5 — WebSocket / OpenAI realtime API support. Pass-through proxy at /v1/realtime. ADMISSION runs at handshake, COMMITMENT runs on every function-call event in the session (the highest-risk surface for voice agents — send_email / delete_file from voice is the same gating problem as from chat), INSPECTION runs on text content events only (audio passes through with audit row tagged metadata.audio_inspection_skipped=True), RECORD writes session-start / session-end rows with cumulative session metadata plus periodic 30-second interim flushes so a crash doesn't lose hours of audit data. Refusal events use a structured signet.refusal shape that SDK callers can parse. Spec at docs/realtime.md. Deferred to 0.1.7+: audio transcription + INSPECTION on transcribed text (needs local Whisper integration design), interruption handling (linear-stream model breaks here), latency-aware check ordering.
  • A6 — Owner.approval_chain surface in COMMITMENT escalation. When ToolCallInspectorCheck escalates an irreversible high-tier tool call, the audit row now carries requires_approval_from (full ordered approval chain) and current_approver (first link, or None) so downstream approval workflows can route without re-deriving them. Spec at docs/escalation.md. Deferred to 0.1.7: signet escalation pending|approve|deny subcommand suite, multi-step chain walking, webhook config, timeout / auto-deny policy.

Changed

  • RateLimitCheck no longer rejects priority < 100 at construction; signet lint SIG001 surfaces it as a warning instead so authors who genuinely need pre-content rate limiting can opt in.
  • The Knowledge of which check fired (the _check_name metadata key) on every CheckResult continues to flow through the pipeline; the new abort-frame contract surfaces it in shadow / streaming response envelopes alongside the strict-redaction-mode correlation_id.

Tests

  • 262 unit tests at v0.1.5 → 418 at v0.1.6. Phase-by-phase: rc1 added parametrize sweep + header case variants + 3-state health + per- check latency histograms + escalation metadata coverage; rc2 added shadow mode (6) + truncation counter (9) + upstream-label round- trip (5); rc3 added plugin discovery (5) + audit compaction (21) + CLI sweep (19); final tag added streaming abort harness (8) + WebSocket realtime (8).

0.1.5 — 2026-05-06

Operator-day-one polish — 13 items from the v0.1.4 evaluation pass

A targeted round of fixes and ergonomic upgrades aimed at the friction operators hit on first integration. No new check shipped; no public-API renames; existing pipeline.py files continue to work unchanged.

Added

  • signet lint pipeline.py subcommand. Static analysis on a configured pipeline. Catches the four most common misconfigurations: missing OwnerResolutionCheck (SIG002), rate-limit ordered before content checks (SIG001), ToolCallInspectorCheck(allow_unregistered=True) (SIG003), ClassificationGateCheck without a paired INSPECTION-stage ScopeDriftCheck (SIG004). --strict promotes warnings to a non-zero exit for CI invocations.
  • /healthz and /readyz endpoints. /healthz is an alias of /health matching k8s convention. /readyz actively probes the configured upstream with a 1-second timeout and returns 503 when it is unreachable, so kubernetes can shed traffic without triggering a liveness restart.
  • /health body upgrade. Now includes version, uptime_seconds, pipeline_check_count, and the last 8 hex of the audit-chain head HMAC (audit_chain_head_hmac) — enough for monitoring to distinguish "alive" from "alive and writing the expected configuration".
  • Check.priority attribute. Sub-orders execution within a single Stage. Lower runs earlier; defaults to 0. Use to enforce ordering dependencies between checks. Registration order remains authoritative on priority ties.
  • RateLimitCheck.priority = 100. Schedules the rate-limit check late within ADMISSION so cheaper content-scanning checks (RegexContent, PromptInjection, classification) refuse a bad request before a token is consumed from the owner's bucket. Closes the "refused requests still cost quota" footgun.
  • RateLimitCheck hard-quota mode. Pass refill_per_second=0 for a never-refilling cap. The bucket drains once and never replenishes within the process — useful for daily / monthly quotas reset out-of-band. The error response carries hard_quota=True and retry_after_seconds=None so callers can distinguish a recoverable rate-limit from a hard cap.
  • ServerConfig.strict_error_redaction (default True). 4xx refusal bodies are coarsened to {"error": "refused", "correlation_id": "<entry_id>"} so the public response no longer names the firing check, its rule, or the severity. Full detail still lands in the audit chain. --strict-error-redaction / --no-strict-error-redaction CLI flags; signet serve --dev flips the default to False for integration ergonomics.
  • signet doctor auto-detection. When invoked inside a signet init workspace, doctor reads SIGNET_UPSTREAM_URL from .env / .env.example and defaults --self to http://127.0.0.1:8443. Mirrors the convenience that serve --dev already had.
  • Owner.create(type=, id=) factory and KeyRing(keys=[...], active_id=...) / KeyRing(keys={...}, active_id=...) constructor shapes. Backwards-compatible ergonomic aliases for the longer historical kwargs. The Owner.human / Owner.agent / Owner.policy classmethods remain the recommended path for typical use; create is the one-stop entry point for callers who want a single constructor.
  • RequestContext.method field (default "POST"). Surfaced so checks that gate on HTTP verb don't have to read raw headers. Populated by the proxy from request.method.
  • ToolSpec.as_metadata() helper. Projects a ToolCallInspectorCheck registry entry into the dict shape expected by ToolCallContext.tool_metadata, so consumers don't have to maintain two parallel registries.
  • SandboxPreviewCheck(registry=...) parameter. When supplied, the sandbox plugin reads dryrun_supported directly from the shared ToolSpec registry rather than expecting ToolCallContext.tool_metadata to be populated by hand. The inspector's registry becomes the single source of truth.
  • Production deployment guide at docs/deploying.md. mTLS / CSRF posture, multi-process worker rules, anchor-backend selection, HSM/KMS integration notes, probe wiring.

Changed

  • RateLimitCheck no longer rejects refill_per_second=0. The validator now requires refill_per_second >= 0 and tells the caller about hard-quota mode in the error message.
  • README's prompt-injection section calls out the match_source: "decoded-base64" audit field as the evidence surface for end-to-end obfuscation handling.
  • OwnerResolutionCheck module docstring documents the require_owner=True ↔ OwnerType.UNRESOLVED flow with a 4-line example, including the strict-redaction body shape.
  • LangchainSignetCallbackHandler recognizes both the new strict refusal body (error == "refused") and the legacy verbose body (error.startswith("signet refused")).

Internal

  • Pipeline.__init__ sort key is now (stage.ordinal, priority), not just stage.ordinal. Stable sort on registration order is preserved for any check that doesn't override priority.

0.1.4 — 2026-05-03

Documentation accuracy pass

This is a doc-only release so the README on PyPI's project page reflects what v0.1.3 actually shipped. No code changes.

  • README "Honest scope" section rewritten. Removed stale "roadmap for v0.2" claims about ed25519 receipts, RFC 3161 anchoring, multi-process audit writers, and embeddings/completions — all shipped in v0.1.3. Restructured into "architectural boundaries" (things signet doesn't do because they belong elsewhere) + "When you need more than the OSS" (the legitimate Pro/Thornveil call for production-tuned LLM-judge calibration, behavioral fingerprinting, HSM integrations, compliance attestation, custom check development).
  • README built-in-checks table updated to reflect the v0.1.3 PromptInjection improvements (NFKC, confusables fold, multi- encoding decoders).
  • SECURITY.md threat model updated: tamper-evidence now points at the actually-shipped Rfc3161Anchor; receipt symmetry now points at the actually-shipped Ed25519ReceiptSigner; multi- process writer warning now points at the actually-shipped FileLockingJsonlBackend. Stale "v0.2 supply-chain roadmap" removed.
  • docs/architecture.md "two limits" section reframed as "two architectural choices" — both choices are now operator-config decisions, not future work.
  • 7 new check pages: loopback_trust, rate_limit, regex_content, prompt_injection, token_budget, scope_drift, continuing_consent, tool_call_inspector. Each documents what the check does, configuration patterns, audit-row shapes, and known false-positive surface. The mkdocs nav now lists all 10 built-in checks.

0.1.3 — 2026-05-03

Added — Phase 1: bulletproof OSS

  • Asymmetric receipt signing (ed25519) via signet.server.receipt.Ed25519ReceiptSigner and signet keys generate-ed25519 CLI command. Verifiers hold only the public key and cannot forge. Optional dep pip install signet-sign[ed25519].
  • External anchor backends for tamper-proof audit chains. signet.audit.anchor.AnchorBackend Protocol + NoopAnchor (default, byte-compat) + Rfc3161Anchor (FreeTSA / any free public RFC 3161 TSA, requires no extra deps). Anchor receipt embedded in entry metadata BEFORE the chain HMAC is computed, so the HMAC binds the receipt to the entry.
  • Multi-process safe audit writer via signet.audit.backend.FileLockingJsonlBackend (fcntl on POSIX, msvcrt on Windows). Pair with HmacChain(cache_prev=False) to run uvicorn --workers N>1 safely.
  • Endpoint coverage: /v1/embeddings and /v1/completions are now gated through the full pipeline. /v1/audio/* and /v1/images/* remain explicit 404s with a roadmap note (their non-JSON request shapes need their own check protocols).
  • PromptInjection obfuscation hardening: Unicode NFKC normalization, Cyrillic / Greek / Cherokee confusables fold, zero-width / bidi character stripping, "stretched" letter-spaced text collapse (i g n o r e → ignore), wider encoding decoders (URL-safe base64, base32, hex, ROT13). Module docstring documents the bypass surface still left open and points at production-tuned LLM-judge layer for the rest.
  • Auth integration recipes at docs/integrations/auth.md — three concrete patterns (nginx + mTLS, FastAPI middleware + JWT, oauth2-proxy + OIDC) for putting real authentication in front of signet's owner-resolution gate.

Added — Phase 2: production-grade ops

  • /metrics Prometheus endpoint with counters: signet_requests_total{path}, signet_pipeline_decisions_total{check, decision}, signet_audit_chain_appends_total, signet_audit_anchor_failures_total{backend}, plus a signet_uptime_seconds gauge. No external dep — exposition format written manually.
  • CORS support via ServerConfig.cors_allowed_origins (+ methods / headers / credentials / preflight-cache fields). Skipped entirely when origins is empty (default), so non-browser deployments incur zero overhead.
  • Per-check timeout via Check.timeout_seconds. Pipeline wraps each hook call in asyncio.wait_for; timeout fails closed (BLOCK with a clear reason). Bounds external dependencies (LLM-judge calls, sandbox runners) so a stuck dependency cannot halt the proxy.
  • Graceful shutdown: lifespan exit waits up to ServerConfig.shutdown_grace_seconds (default 10s) for in-flight streaming responses to drain before tearing down the upstream client. Audit rows for abandoned streams still write via the generator's finally block.
  • signet audit count / signet audit tail CLI subcommands. audit count --by check|owner|decision|owner_type|stage for incident-response counts; audit tail -n 50 --filter decision=block for log inspection. Both support --json for scripting.
  • signet audit verify --json machine-readable output mode for CI cron consumers.
  • Redis-backed state stores: signet.server.redis_session_store.RedisSessionStore and signet.checks.redis_rate_limit_state.RedisRateLimitState — drop-in replacements for the in-memory defaults when running multiple replicas. Optional dep pip install signet-sign[redis].
  • --log-format json flag on signet serve switches stdlib logging output to structlog's JSON renderer (one JSON object per line). Wire to Loki/Datadog/ELK without changing signet code.

Documentation polish

  • Docs site nav keeps Contributing / Security / Changelog inside the site (mkdocs pymdownx.snippets mirrors the canonical root files; GitHub keeps auto-discovering the originals). README gets explicit "📚 Documentation: thornveil-ai.github.io/signet" link with PyPI / docs / license / Python badges at the top.
  • CONTRIBUTING.md internal links upgraded to absolute GitHub URLs so they resolve cleanly in both the repo browser and the included docs-site page.
  • GitHub repo description corrected — signet is a proper noun, not preceded by an article.

Tooling

  • pyproject.toml extras: [ed25519], [redis], [prometheus]. [all] includes them all. [dev] pulls them for dev-environment coverage.
  • ruff per-file-ignores for the deliberately-non-ASCII confusables table in prompt_injection.py + the obfuscation-attack tests.
  • Cross-platform mypy fix: file-locking implementation selected at module import time via sys.platform narrowing — keeps mypy clean on both Linux and Windows.

Tests

  • 242 unit + adversarial green. mypy --strict clean. ruff lint clean. mkdocs --strict build clean.
  • New tests: ed25519 sign/verify roundtrip + verify-only-cannot-sign + alg-downgrade rejection + PEM-roundtrip; anchor receipt binding to chain HMAC + failing-anchor-recorded-as-failure + require_anchor_success raises; FileLockingJsonlBackend basic + two-instance shared-file safety; PromptInjection obfuscation- busting (5 homoglyph variants + ROT13 + URL-safe base64); embeddings/completions endpoint round-trips; Redis adapters (sessions + rate-limit) end-to-end via fakeredis.

0.1.2 — 2026-05-03

Added — UX polish from first-user smoketest

  • signet serve --dev shorthand. Bundles --allow-ephemeral-key, --audit-log audit.jsonl, and --config pipeline.py into one flag. Each is only set if not otherwise specified. The most common local invocation drops from five flags to one.
  • signet doctor command. Prints versions and probes endpoints: --upstream <url> checks the LLM upstream is reachable; --self <url> hits a running signet's /health, /version, and sends a no-owner refusal probe to confirm the gate is enforcing. Exits non-zero on any probe failure.
  • signet audit show <entry-id> is the new (honest) name for what signet replay did. The replay alias still works but prints a deprecation warning; it will be removed in v0.2 alongside the actual pipeline-replay feature.
  • signet init writes a client_example.py alongside the pipeline scaffold. New users no longer have to read the README to find out how to call signet from Python — both raw httpx and wrap_openai patterns are demonstrated.
  • signet serve prints the ephemeral HMAC key on startup when --allow-ephemeral-key is in effect. Lets the user save it externally if they want to verify the audit log later instead of losing the key on shutdown.
  • OwnerResolutionCheck refusal hint now lists three concrete header examples (X-Commit-Owner: human:alice@example.com, X-Agent-Id: agent:nightly-syncer, X-Policy-Name: acme-default) instead of just naming the field shapes. Refusal payload also carries an examples array.
  • X-Signet-Upstream and X-Signet-Upstream-Status response headers on every reply so callers can finger-point upstream errors vs. signet errors at a glance. Configurable label via --upstream-label / SIGNET_UPSTREAM_LABEL (defaults to the upstream URL host).
  • ServerConfig.upstream_label field exposed for embedded use.

Documentation

  • README rewritten to lead with the "why you need this now" pitch: three concrete attack scenarios, then the architecture, then the honest-scope section. Designed so a CEO can read the first three paragraphs and a CTO can read the rest.
  • docs/architecture.md opens with a one-paragraph layman summary that names the junior-employee analogy before diving into the technical section. Trust model and out-of-scope list cleaned up.
  • docs/index.md matches the README's framing for the docs site landing page.
  • docs/checks/owner_resolution.md corrected — X-Agent-Id bare values are no longer accepted (the agent: prefix is required, fixed during pre-release security review).

0.1.1 — 2026-05-03

Fixed

  • signet serve startup banner used → (U+2192) which crashes Python's default cp1252 stdout on Windows with UnicodeEncodeError. Caught immediately on the first post-publish smoketest. Replaced with -> for portability across console code pages.

0.1.0 — 2026-05-03

First public release. Apache-2.0 OSS prior art for the gate-pattern thesis.

Hardened — pre-release adversarial review

Five rounds of self-review against a fresh "hater" lens before tagging, producing ~50 fixes across the audit chain, receipt format, proxy semantics, owner resolution, memory safety, and documentation honesty. None are wire-format breaking; some change response semantics in ways that match the documented intent.

Audit chain

  • HmacChain.append now holds an internal threading.Lock; concurrent FastAPI requests can no longer fork the chain by reading the same prev_hmac twice. Multi-process workers still need a custom backend (documented).
  • _serialize_for_signing rejects NaN / Infinity (allow_nan=False) and uses ensure_ascii=False so the canonical form is deterministic.
  • JsonlBackend.append calls os.fsync after every write by default (fsync_after_append=True); a crash mid-handler can no longer leave the chain shorter than the responses already returned.
  • Key.__repr__ redacts the secret. Earlier the default dataclass repr would print HMAC bytes into any log line that touched a Key.
  • Verifier docstring lists all four break kinds (MISSING_KEY_ID was missing from the module-level summary).

Receipts

  • Receipt format now carries alg=hmac-sha256. The verifier rejects receipts whose alg does not match the configured signer, blocking downgrade attacks against future ed25519.
  • ReceiptSigner is now a Protocol; concrete HmacReceiptSigner ships as the v0.1 default. Callers can pass their own signer to SignetApp(receipt_signer=...) for ed25519 or HSM-backed primitives.
  • Receipt symmetry caveat called out explicitly in the module docstring, SECURITY.md, and docs/architecture.md.

Proxy semantics

  • REDACT results from ADMISSION now actually modify the request body before forwarding (multimodal vision content preserved) instead of returning 403.
  • ESCALATE results return 202 Accepted with an audit_entry_id field instead of 403.
  • Audit Decision reflects the actual outcome (BLOCK / REDACT / ESCALATE / ALLOW one-to-one). Earlier any non-allow collapsed to BLOCK in the chain.
  • Inbound bodies are streamed with a configurable cap (SIGNET_MAX_REQUEST_BODY_BYTES, default 4 MiB). Anything larger gets a 413 before signet attempts JSON parsing.
  • Empty body returns explicit 400 instead of forwarding {} and producing an opaque upstream 400.
  • Pipeline crashes at admission or forward write a synthetic audit row before returning 500/502; earlier the chain was silent on the most security-relevant events.
  • Streaming generator wraps in try/finally and writes a terminal audit row with finish_reason="client_disconnect" when the caller bails mid-stream.
  • RECORD-stage non-allow results now persist as their own audit rows instead of being silently discarded.
  • Sessions are now actually loaded — _handle_chat calls session_store.get_or_create + touch on every request that asserts X-Signet-Session and stashes the Session on RequestContext.scratch["_session"]. Earlier the store was wired but nothing populated from it.
  • Unsupported OpenAI endpoints (/v1/embeddings, /v1/completions, /v1/audio/*, /v1/images/*) return an explicit 404 with a note, not FastAPI's generic body.
  • _extract_sse_content coalesces multi-line data: per the WHATWG EventSource spec (consecutive data: lines join with \n, blank line dispatches the event). Matters for OpenAI-compatible upstreams that stream multi-line events (LiteLLM, vLLM with prompt-streaming).

Owner resolution

  • Header lookup is case-insensitive and strips whitespace.
  • Precedence is documented and deterministic: already-resolved → X-Commit-Owner → X-Agent-Id → X-Policy-Name. Both human and agent present? Human wins.
  • Empty principal after a human: / agent: prefix is rejected.

Memory

  • InMemorySessionStore and InMemoryRateLimitState are now LRU-bounded (defaults: 10k sessions, 50k owner buckets). An attacker rotating session IDs / owner identities can no longer grow the stores without bound.
  • ResponseContext.accumulated_text is bounded (default 1 MiB) via extend_text. Long streams set accumulated_text_truncated and stop appending instead of doing O(N²) string growth on multi-MB responses.

Audit row contents

  • request_fingerprint is now populated (sha256 over raw request bytes, computed before any redaction). All audit rows from the same request share this value, letting downstream tools group entries without inventing a request_id.
  • accumulated_text_truncated and chunk_count propagate into pipeline.complete rows so consumers can flag entries where INSPECTION saw only a prefix.

Checks

  • ScopeDriftCheck markers are now overrideable via constructor; empty dict disables classification drift entirely. Default false-positive surface documented.
  • PromptInjectionCheck docstring lists the bypass surface explicitly: non-English, homoglyph (Cyrillic-lookalike), whitespace obfuscation, alt encodings, cross-turn attacks, adversarial suffixes — all known gaps.
  • SandboxResult.is_safe() keyword list explicitly marked as a placeholder; users should pass a real policy= callable.
  • TribunalCheck.inspect_tool_call switches to gather(return_exceptions=True) so one judge crashing no longer cancels the other mid-flight (which leaked the surviving HTTP connection and discarded a usable verdict). Reuses one httpx.AsyncClient across calls.

CLI

  • signet init writes a .gitignore (.env, .env.*, *.jsonl) alongside the scaffold so first-time users do not commit their HMAC secret or audit log on push. Existing .gitignore is left alone.
  • signet serve --config <path> prints a yellow warning that the flag executes arbitrary Python from the file. Function docstring also names it explicitly.
  • signet replay help text is honest: it reads + prints the audit row but does NOT re-execute the pipeline. Replay against historical traffic is roadmap.
  • signet replay <UUID> is now case-insensitive on the entry ID.
  • signet serve prints the pipeline checks loaded at startup so operators can verify the configuration without reading the file.
  • _parse_hex_secret gives a clear message when SIGNET_HMAC_SECRET is malformed: names the env var, accepts an optional 0x prefix and surrounding whitespace, suggests openssl rand -hex 32, refuses secrets shorter than 16 bytes loudly.

SDK adapters

  • wrap_openai / wrap_anthropic raise TypeError loudly when the SDK client lacks a writable base_url, instead of silently leaving the original endpoint in place.

Hygiene

  • _iter_entry_points loses the pre-3.10 fallback (project pins >=3.11).

Threat model and supply chain

  • SECURITY.md and docs/architecture.md call out two real limits up front: owner identity is caller-asserted (not authenticated) and the HMAC chain is tamper-evident (not write-once). Both have v0.2 roadmap notes (asymmetric receipts, anchor backends).
  • SECURITY.md reporting channel is now GitHub private security advisory with the gmail backed up as fallback only.
  • SECURITY.md hardening list adds explicit chmod 0600 guidance for the audit-log file (signet creates it with the OS umask).
  • publish.yml generates a CycloneDX SBOM and attaches it to each GitHub Release. SECURITY.md commits to v0.2 sigstore + SLSA.
  • README softens NIST 800-53 claim from "compatible" to "aligned with" and is honest about endpoint coverage (only /v1/chat/completions in v0.1).

New tests

  • 25-thread concurrent append confirms chain stays linked.
  • NaN-in-metadata confirms loud rejection.
  • Custom-marker ScopeDriftCheck confirms override path.
  • Receipt downgrade-attack confirms alg mismatch is rejected.
  • Body too large returns 413; empty body returns 400.
  • Pipeline exception writes a synthetic audit row.
  • ESCALATE returns 202 with audit_entry_id.
  • REDACT preserves vision-style image parts.
  • Audit rows from the same request share request_fingerprint.
  • accumulated_text truncates and flags at the cap.
  • LRU eviction caps RateLimitCheck memory.
  • Lowercase / mixed-case headers, human-wins-over-agent precedence, whitespace-stripped values, empty-principal blocked.
  • Multi-line SSE coalescing and [DONE] handling.
  • _parse_hex_secret rejects bad hex with a useful message; accepts whitespace + 0x prefix.
  • Session loaded into ctx.scratch["_session"] on every request.
  • signet init writes .gitignore and does not overwrite existing one.

Test count: 220 unit + adversarial green. mypy clean. ruff clean.

Added — core abstractions

  • signet.core.owner.Owner + OwnerType enum (human / agent / policy / unresolved)
  • signet.core.audit.AuditEntry + Decision enum (allow / block / redact / escalate)
  • signet.core.check.Check ABC + CheckResult with four hook timings (pre_request, inspect_response_chunk, inspect_tool_call, post_complete)
  • signet.core.stage.Stage four-tier hierarchy: ADMISSION / INSPECTION / COMMITMENT / RECORD
  • signet.core.context request / response / tool-call context dataclasses
  • signet.core.pipeline.Pipeline fail-closed sequenced executor

Added — HMAC audit chain

  • signet.audit.chain.HmacChain — append-and-sign coordinator with cached prev_hmac
  • signet.audit.verifier.ChainVerifier — distinguishes SELF_MISMATCH / LINK_MISMATCH / UNKNOWN_KEY / MISSING_KEY_ID break kinds
  • signet.audit.keyring.KeyRing — multi-era key management for verification across rotations
  • signet.audit.backend.JsonlBackend — default append-only JSONL storage
  • Tamper-detection tests covering modify, delete, reorder, forge-insert, key rotation

Added — 10 built-in checks

  • OwnerResolutionCheck (ADMISSION) — refuse if no resolvable commit owner
  • LoopbackTrustCheck (ADMISSION) — auto-resolve owner for loopback + Tailscale CGNAT
  • RateLimitCheck (ADMISSION) — per-owner token bucket with pluggable state backend
  • RegexContentCheck (ADMISSION) + RegexOutputCheck (INSPECTION) — block / redact patterns
  • ClassificationGateCheck (ADMISSION) — 5-level UNCLASS → TS/SCI gate
  • PromptInjectionCheck (ADMISSION) — pattern + heuristic + base64-decoded scan
  • TokenBudgetCheck (ADMISSION + RECORD) — per-owner output-token quota with reconciliation
  • ScopeDriftCheck (INSPECTION) — token / character / classification-marker drift
  • ContinuingConsentCheck (INSPECTION) — periodic mid-stream owner-authority revalidation
  • ToolCallInspectorCheck (COMMITMENT) — risk-tier gating + tool allowlist

Added — HTTP proxy

  • signet.server.app.SignetApp — FastAPI proxy with /health, /version, POST /v1/chat/completions
  • SSE streaming with mid-stream abort + trailer event on INSPECTION block
  • signet.server.config.ServerConfig — env-loadable runtime config
  • signet.server.session.Session + SessionStore — cross-request state
  • signet.server.receipt.ReceiptSigner — X-Signet-Receipt HMAC-signed decision summary, offline-verifiable by callers

Added — SDK adapters

  • signet.adapters.openai.wrap_openai — in-place reconfigure of openai SDK clients
  • signet.adapters.anthropic.wrap_anthropic — same shape for Anthropic SDK
  • signet.adapters.langchain.SignetCallbackHandler — LangChain-shaped observer surfacing receipts and refusal payloads
  • 3 runnable example scripts in examples/

Added — plugin interface

  • signet.plugins.discover + load_by_name — entry-point-based discovery (group: signet.checks)
  • signet.plugins.tribunal.TribunalCheck — reference dual-judge dissent (caller supplies judge endpoints)
  • signet.plugins.sandbox.SandboxPreviewCheck — reference preview-before-commit (caller supplies sandbox runner)

Added — CLI

  • signet serve — run the FastAPI proxy
  • signet audit verify — walk an HMAC-chained log and report tampering
  • signet replay — display the audit row for a given entry ID
  • signet init — scaffold a starter pipeline + .env

Added — tests

  • ~250 unit tests covering all 10 checks, the chain, the proxy, the adapters, the plugins, and the CLI
  • ~25 adversarial bypass tests across 6 attack categories
  • Integration suite targeting local Ollama + remote RigRun (skipped when endpoints not reachable)

Added — docs

  • README with one-paragraph what-it-is + quickstart
  • docs/architecture.md covering 4-stage hierarchy, continuing-consent + scope-drift patterns, trust model, what is intentionally not in scope
  • docs/checks/owner_resolution.md, docs/checks/classification_gate.md
  • docs/plugin_dev.md walkthrough
  • CONTRIBUTING + CODE_OF_CONDUCT + SECURITY (with explicit threat model)

Added — CI

  • Ruff lint + format + mypy --strict on every push
  • Test matrix: Python 3.11 / 3.12 / 3.13 × Linux / macOS / Windows = 9 jobs per push
  • mkdocs-material site builds + deploys to GitHub Pages