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 anHmacChain(..., 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-endto 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_canonmarker, 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. Requiressignet-sign[jcs](therfc8785package); legacy chains never load it. - Post-quantum ML-DSA-65 (FIPS 204) receipt signer.
MLDSAReceiptSignerjoinsEd25519ReceiptSignerfor receipts that must resist a harvest-now-decrypt-later adversary. New CLI:signet keys generate-mldsa. Requiressignet-sign[pq]. Marked EXPERIMENTAL (the backend is the pure-Pythondilithium-pyreference 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 noimport signetthat 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¶
ChainVerifiernow reports a non-canonicalizable entry (NaN/Inf in metadata, an unknown/tampered_canonmarker, or a missing JCS backend) as a structuredMALFORMED_LINEbreak instead of letting the exception escape the walk.- New optional extras:
jcs(rfc8785) andpq(dilithium-py); both folded intoalland 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_4srenamed totest_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-livedPYPI_API_TOKENsecret. 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.pymodule split: 2578 LOC, heavily interconnected. Splitting intoprompt_injection/{normalize,decoders,rules,check}.pyper 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_exceededdefault:"audit_warn"→"block"(reverts the v0.1.9.1 default flip).tests/unit/test_round18_hunt.py:test_deadline_policy_block_opt_in_blocksrenamed back totest_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_exceededdefault 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 — npmsha512-...==package-lock SRI integrity attributes, git commit SRI checksums, CSPsha256-...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, dropscan_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 pinon_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
\bboundary lettingPleaseignore previousslip 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=Falsemulti-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-levelid/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_logagainst 10 MB__repr__DoS, BFS short-circuit on(KeyboardInterrupt, SystemExit)re-raise.
Fixed (P0)¶
Streaming SSE¶
- SSE chunk-boundary bypass —
_extract_sse_contentwas stateless acrossaiter_bytes()chunks. Adata:line split across chunks (TLS records, HTTP/2 frames, MTU boundaries) silently allowed the marker through. New_SSEBufferclass with per-stream_pending_line/_pending_datastate; byte-level_pending_raw_sseholds 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'sJSONDecodeErrorcatch silently incrementeddropped_frame_countwhile letting raw event bytes flow to the client. Hostile upstream smuggled by appending a junkdata:line. Now setsmalformed_event_seen=True;_forward_streampolls it and aborts via_emit_upstream_error_abort(reason="upstream_sse_malformed"). _pending_raw_sseunbounded — no size cap meant 250 MB upstream / 720 MB peak under unterminated stream. New_MAX_PENDING_RAW_SSE_BYTES = 4 MiBcap; exceeded triggersupstream_sse_unterminatedabort with audit row.- SSE depth-recursion bypass —
_collect_inspectable_stringscap at depth 6 silently returned[]for content at depth 7+. Cap raised to_MAX_JSON_DEPTH(64); overflow returns_DepthSentinelListsentinel;_flush_eventdetects and aborts viaupstream_delta_too_deepreason token.
Server admission¶
- Invalid UTF-8 body → 500-no-audit — non-UTF-8 bodies (gzip-encoded,
latin-1, any high-bit byte) raised
UnicodeDecodeErrorpast the JSON-decodeexcepttuple, surfacing as bare 500 with no audit row, nocorrelation_id, no signet shape._admitnow catchesUnicodeDecodeError+LookupError; routes through_record_preflight_refusalwith_refusal_kind="invalid_encoding".
Pipeline checks¶
/v1/completions+/v1/embeddingsunscanned —PromptInjectionCheck._extract_textread onlybody["messages"]. Two of three gated endpoints had zero prompt-injection defense; embeddings are a common indirect-injection vector via RAG._extract_textnow also walksbody["prompt"](string or list),body["input"](string or list), plustools[*].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
\bboundary bypass — leading\bonignore_previous/disregard/forget_prompt/jailbreak_keyword/developer_mode/no_restrictionslet 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\bdropped; trailing\bretained soigniterstill doesn't matchignore. - 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_contentread onlydelta.content. Now harvestsdelta.tool_calls[*].function.{name, arguments, description},delta.refusal,delta.reasoning,delta.reasoning_content,delta.audio.transcript. Default-deny via_collect_inspectable_stringsrecursive walk over all string values; structural-key skip list (_SSE_DELTA_STRUCTURAL_KEYS) scoped to TOP-LEVELdeltaonly; nested keys always inspected. choices[i]sibling fields uninspected —_flush_eventwalked onlychoices[i].delta.choices[i].text(/v1/completionslegacy),choices[i].message.content(buffered-as-SSE),choices[i].logprobs.content[].tokenall 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_KEYSwith_validate_event_top_level_ structural_fieldchecking enum values forobject. - Realtime WS used wrong structural skip set —
_collect_inspectable_strings(event, _top_level=True)activated the DELTA-level skip on an EVENT-level dict, soevent.type/event.id/event.stop/event.tool_call_id/event.function_call_id/event.objectsmuggled 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 andpipeline.realtimeaudit row.
Audit chain¶
AuditEntry.from_dictlacked type validation —hmac/prev_hmactampered to non-string (null, true, 42) crashedChainVerifier.verify()with rawTypeError. Now tightened:hmac/prev_hmacmust bestr,ts_nsmust beintin[0, 10**19]; bad types route throughMalformedAuditEntryandBreakKind.MALFORMED_LINE._read_archiveexcept clause too narrow — caught onlyJSONDecodeError;KeyError/TypeError/ValueErrorfromAuditEntry.from_dictcrashed verify. Widened to all four;verify_with_archivesouter except matches.- Compaction marker MAC signature —
is_compaction_markerwas a pure shape check; any caller writing an entry withcheck_name="_compaction"permanently blocked compactions (persistent DoS). Nowis_compaction_marker(entry, *, keyring=)verifies an HMAC-SHA256 MAC over the marker's identifying fields with the active key, domain-separated byb"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_markeradds 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_hmacreturnlast.prev_hmac(the bridge value the marker commits to under its own signed HMAC) when the tail is a compaction marker. Works regardless ofcache_prevstate, survives process restart, closes the post-full-sweep LINK_MISMATCH acrosscache_prev=Falsemulti-process backends.
Pipeline checks (encoding arms race)¶
- Bounded-depth iterative BFS replaces single-pass decoder.
_extract_decodedBFS with_MAX_DECODE_DEPTH = 16,_PER_DEPTH_ BUDGET = 16 KiB, total budget 256 KiB, cycle detection viaseen: 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,
0xprefix, 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_decompresswith 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_driftoutput marker scan NFKC + zero-width strip — fullwidthSECRET//NOFORN, ZWSP-interleavedSECRET//NOFORN, circled-letterⓢⓔⓒⓡⓔⓣ//ⓝⓞⓕⓞⓡⓝall now block under UNCLASS request.- Markdown emphasis + backslash split —
i*g*n*o*r*e previousandi\g\n\o\r\e previousnow block via_strip_markdown_splitparallel scan. pipeline.post_completecatchesBaseExceptionwithasyncio.CancelledErrorre-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]}raisedAttributeErrorpast the outerexcept. Now defensively coerced; non-dict / non-listchoices/ non-dictchoices[0]route through_record_upstream_failurewithrefusal_kind="upstream_protocol_violation". - 3xx upstream redirects → bypass — sync + streaming both
forwarded 3xx verbatim to client. Now: audit row + signet-shaped
502 with
upstream_redirectedreason, 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_safeASCII-strict validator; 400 at admit with_refusal_kind="header_invalid_charset". pipeline.post_completein_forward_unaryunwrapped — a crashing RECORD plugin returned 502 after upstream 200. Now wrapped (matches streaming twin); outer fallback hides exception classname under strict mode, always emitscorrelation_id+X-Signet-Upstream.- All preflight 4xx responses unified shape — single
_preflight_response()wrapper +_upstream_attribution_headers. Every refusal carriescorrelation_id,X-Signet-Upstream, signet-shaped JSON with stable snake_caseerrortokens:{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_allowednow 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).ServerConfigimmutability + validate-first__setattr__—_VALIDATED_FIELDScoversupstream_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 BEFOREsuper().__setattr__so rejected values don't persist.
CLI¶
- Audit-log path symlink refusal — new
AuditLogSymlinkError+_assert_not_symlink+_open_audit_log_append(POSIXO_NOFOLLOW+ Windowsos.path.islinkpre-check). Centralized_open_jsonl_backendhelper across every chain-walking CLI command. - Windows reserved device names rejected at parse time —
_reject_windows_reserved_device_namecovers{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_terminalcovers 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 verifypretty 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-ed25519key-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_namehelpers catchBaseExceptionfrom 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 beforeBaseException— 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.pyprogrammatically walksdiscovery.py; any future barerepr(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 defaulttrust_env=Truelet process-envHTTPS_PROXY/SSL_CERT_FILE/CURL_CA_BUNDLEsilently MITM upstream. Now explicitlytrust_env=False, verify=Truewith configurable pool limits. from_envUnicode whitespace + bidi rejected —SIGNET_UPSTREAM_URLenv value strip rejects C1 controls, Cf/Zl/Zp Unicode categories, NBSP, bidi marks/overrides, BOM.anchor.pyAnchorProtocolError— defensive extraction with named exception type instead of rawAttributeError/TypeError.signet doctor --selfcorpus drift WARN line — stale install detection (compares against_CANONICAL_PROBE_IDS); WARN, not FAIL.signet bench --gate p100=/p0=rejected — gate parser enforces0 < pct < 100.signet bench --requestscapped at 1_000_000 — memory bound on the task-allocation up-front.pipeline.record.errorsynthetic audit row — RECORD-stage check that raises now appends a structuredpipeline.record.errorrow 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, npmsha512-...hashes, SHA-512 SRI, git commit messages, CSPsha256-...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_DEPTHraised from 8 to 16 (R14 introduced 8; R18 raises to 16 with_PER_DEPTH_BUDGET = 16 KiBkeeping total at 256 KiB).PromptInjectionCheck.base64_min_lengthfrom 24 to 4 (short attacks likeDAN,god mode on,disregard abovewere escaping encoded channels).ServerConfig.inspect_all_sse_linesdefaultFalse → True(retry:,event:,id:SSE field values now inspected by default).httpx.AsyncClientconstructed with explicittrust_env=False, verify=True, limits=...(was: defaults).ServerConfigis now effectively frozen post-__post_init__:__setattr__re-runs validation; rejected values don't persist.- Override-rule regex family in
prompt_injection.pyno longer anchors with leading\b(closes glued-prefix bypass; trailing\bretained soigniterdoesn't matchignore). pipeline.post_completenow catchesBaseExceptionwith explicitasyncio.CancelledError: raiseto 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.pyfiles 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_chunknow scans the currentchunkparameter directly whenaccumulated_text_truncated=True. Per-context cursor inctx.scratchprevents 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_englishsampled 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 entryrot13_english_prefix_bypassis the permanent gate. - N2 — PromptInjection truncation-tail bypass (new HIGH introduced
by v0.1.7 C6.6). The
scan_max_chars=512KBcap silently allowed injection past the cap. NowPromptInjectionCheckacceptson_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 entrytruncation_tail_bypassgates 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_refusalhelper 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=Trueplus a_refusal_kinddiscriminator (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.loadsacceptsNaN/Infinity/-Infinity; httpx'sencode_jsonrejects withValueError, which v0.1.7 misattributed as 502upstream_forward_failed. New_contains_non_finite_floatwalks the parsed body before forwarding and refuses with 400. Depth limit prevents pathological inputs from blowing the recursion limit. - V2 —
HmacChain.appendoutside 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, socache_prev=Falsewith concurrent Windows appenders could fork the chain on theos.replacerace. NewFileLockingJsonlBackend.append_locked_with_linkroutes the entire read-modify-write through one acquire.HmacChain.appendrefactored into_build_linked_entry(prev_hmac)so the locked path can call it inside the acquire.cache_prev=Truepath 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.pywas still 8. Now matches the docstring contract. - A13/F2 —
audit verify --jsonmissing fields. v0.1.7's Phase 2 agent addedsignet_version+verified_atto theVerificationReportdataclass; the CLI's JSON serializer omitted them. Wired through. - F1 —
audit compact --forcetraceback leak. Stacked-compaction errors no longer escape as raw Python tracebacks through the CLI; wrapped inClickExceptionwith the remediation hint. - F3 —
signet initscaffold missingPromptInjectionCheck. Scaffold pipeline now includes the check (Option A: scaffold is useful on first run).doctor --probe-injectionhelper 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=20msto catch regressions. JSON / Markdown / CSV output formats. Spec atdocs/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 dashboardexamples/kubernetes/: minimal Helm chart (deployment, service, configmap, secret, pvc) with/healthzliveness +/readyzreadiness, non-root container, RO rootfsexamples/github-action/: CI workflow withsignet lint --strict,signet doctor --probe-injection, andsignet bench --gateon 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_bypassandtruncation_tail_bypassso the v0.1.7-era regressions can't return silently).
Changed¶
PromptInjectionCheckno longer has a "natural English" fast-path for ROT13 (security > 1ms speedup).PromptInjectionCheck.on_scan_truncateddefaults to"block"(was: silent allow withscan_truncated=Truemetadata 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.8rc1wheel 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_chatno longer crashes with a 500 + Python traceback when the request body is a JSON list, scalar, ornull._admitnow 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-UpstreamandX-Signet-Upstream-Statusare 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 fromsignet.__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 promisedSIGNET_SHADOW=1would enable shadow mode in v0.1.6; it now actually does.
Audit subsystem¶
- A1:
verify --including-archivesno longer crashes with an uncaughtzlib.errorwhen the gzip body of an archive is corrupted._read_archivewraps decompression intry/except (zlib.error, UnicodeDecodeError)and surfaces the failure as a structuredARCHIVE_FORMAT_INVALIDbreak. - 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 verifyno longer crashes with a rawjson.JSONDecodeErroron malformed JSONL lines. Truncated lines, stray text, and UTF-8 BOM prefixes now surface asBreakKind.MALFORMED_LINEper-entry breaks.JsonlBackendopens the log withencoding="utf-8-sig"so a leading BOM is stripped silently. - A4:
signet audit compact --output <existing>refuses by default. Pass--forceto overwrite. Archive integrity is no longer silently lost to a typo. - A6: A single-byte tamper of
prev_hmacno longer doubles up as both aLINK_MISMATCHand aSELF_MISMATCH; the verifier short-circuits the self-check when the link already failed. - A7: The compactor and the appender now coordinate via a
<log>.compactingsidecar lock. Concurrent appends during compaction fail loudly (the documented contract) instead of silently dropping writes.
Checks layer¶
- C1:
OwnerResolutionCheckrejects header values containing CR/LF/NUL or exceeding the configured length cap. The v0.1.6 path admitted bare\r\ninjections into audit-row metadata. - C1.4: The
human:/agent:value prefix check is now case-insensitive soHuman:aliceresolves identically tohuman:alice. The block hint message tells operators about the case-insensitive prefix explicitly. - C1.5: An
X-Policy-Namevalue that itself starts with a literalpolicy:prefix is now stripped before recording so the audit row doesn't carry a doubly-prefixed identifier. - C2.1:
ClassificationGateChecktreats whitespace-only classification headers as missing instead of admitting them as the literal whitespace string. - C3:
RateLimitCheckfails closed (block) when the backing state store raises an exception. Previously a Redis outage would silently let traffic through. - C4:
RegexContentCheckandRegexOutputCheckuse the optionalregexpackage's wall-clock timeout to bound ReDoS surface. Therefallback path stays as the no-extra-deps default. - C4.2:
RegexContentaccepts aroles=filter so the matcher scans only specific message roles (e.g.roles=("user",)) instead of every message in the conversation. - C6:
PromptInjectionChecklowered 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:
TokenBudgetChecknow 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_tokensis now refused cleanly instead of silently falling through.
Plugin discovery¶
- D1:
discover_pluginsnow distinguishesstatus="duplicate_name"plus aduplicate_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 initdoes 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'srepr()so Windows paths with spaces / UNCs show cleanly. The command also writes a<out>.meta.jsonsidecar capturing the key-id, algorithm, and generation timestamp. - C5 (CLI):
signet serve --config <broken>surfaces a one-lineClickExceptioninstead 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 aClickExceptionwith 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 byRegexContent/RegexOutputfor ReDoS-bounded matching; falls back to stdlibrewhen not installed. signet plugins doctorsubcommand. 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 cascadingLINK_MISMATCHruns after a single tamper into a singleCASCADE_SUPPRESSEDsummary break, keeping large-chain reports readable (A11).ServerConfig.inspect_all_sse_linesfield. Opt-in tighter SSE scanning that inspects everydata:line in a streamed event, not just the first; defaultFalsepreserves v0.1.6 behavior.VerificationReportcarriessignet_versionandverified_at(A13). Stored verify reports tie back to the binary that produced them.- Sidecar
<out>.meta.jsononsignet keys generate-ed25519 --key-id. - New
BreakKindvalues:MALFORMED_LINE(A3) andCASCADE_SUPPRESSED(A11). - New
DiscoveredPluginstatus:duplicate_nameplus aduplicate_withfield (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-refusalreasoncontinues to coarsen to"refused". - Boolean env vars accept
{"1","true","yes","on","enabled"}case-insensitive acrossSIGNET_SHADOW,SIGNET_ALLOW_EPHEMERAL_KEY,SIGNET_EMIT_RECEIPTS, andSIGNET_STRICT_ERROR_REDACTION(H4). audit verifyon a chain-empty file no longer prints the dangling(last hmac=)sentinel parenthesis (A15).audit report --no-anonymizedrops 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:00ISO offset is trimmed before the human-readableUTCsuffix 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
--devbundle documentation across README,docs/index.md, andsignet serve --helpis 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--devflips on (M5). docs/index.mdcorrected to saysignet initwrites.env.example(not.env) (M7).docs/deploying.md/healthpayload table now includesserviceandshadowand the three-state semantics ofaudit_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 theOwnerTypeenum, leavingowner.owner_typeas a raw string and breaking downstreamis OwnerType.HUMANchecks. Now routed through a new_coerce_owner_typehelper that accepts the enum, lowercase, uppercase, and Title Case strings; invalid strings raiseValueErrorwith the list of valid values. - B2:
KeyRing(keys={"k1": b"x" * 32}, active_id="k1")previously stored raw bytes instead ofKeyinstances, causingkr.active.key_idto raiseAttributeError. The dict-shape constructor now wraps bytes values intoKeybefore falling through to the list-handling path. - B3: New
tests/unit/test_constructor_aliases.pyparametrizes 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 —
--shadowmode (the marketing-leverage feature). Run signet in non-enforcing pilot mode: pipeline runs, audit chain records every decision withmetadata.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, plusX-Signet-Correlation-Id). Newsignet_shadow_would_have_blocked_totalPrometheus counter tracks the would-be refusal rate during pilots./healthbody gains"shadow": truewhen shadow is on.--shadow/SIGNET_SHADOW=1/ServerConfig.shadow=Trueto 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 showcontinues to work as the canonical alias. - F3 — Case-insensitive header sweep via a new
get_header_cihelper insignet.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 coverX-Classification,X-Commit-Owner,X-Agent-Id,X-Policy-Nameacross canonical / lowercase / uppercase variants. - F4 — Three-state
audit_chain_head_hmacon/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 aperf_countertimer. Timeouts map todecision=block. Optional dependency:Pipelinewithout metrics observer just skips observation. - F6 —
signet lintSIG001 repurposed for explicitRateLimitCheck.priorityoverrides below the default 100. The original SIG001 was made moot by v0.1.5'sRateLimitCheck.priority=100default; 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--selfand asserts every one is blocked. Catches "someone mis-edited the rule list" regressions in CI. Corpus lives atsignet.cli_helpers.probe_injection_corpusfor reuse. - N2 —
signet_response_text_truncated_totalcounter. Increments the first timeResponseContext.accumulated_text_capis hit per response. INSPECTION-stage drift checks scanning a prefix instead of the full output is now visible in Prometheus. - N3 —
--upstream-labelround-trip integration test. Locks in the chain from CLI flag →ServerConfig.upstream_label→X-Signet-Upstreamresponse header on success, refusal, and escalation paths./health,/healthz,/version,/readyzcorrectly omit the header. - N4 — Multi-worker rate-limit caveat documented in
docs/deploying.md. Default in-process bucket store meansuvicorn --workers N>1gives effective per-owner rate limit of N× configured. BundledRedisRateLimitStatedocumented as the strict-limit fix.
Added (P3 architectural stretch — ship-in-0.1.6 boundary)¶
- A1 — Plugin entry-point convention + ABI versioning. Third-
party
Checkauthors register against[project.entry-points."signet.checks"]. Newsignet.plugins.discover_plugins()walkssignet.checks/signet.adapters/signet.anchorsgroups and returns a structuredDiscoveredPluginlist with status per entry (loaded,incompatible_abi,load_error).signet.core.check.CHECK_ABI_VERSION = 1anchors plugin ABI compatibility going forward. Newsignet plugins listCLI surfaces the discovery report. Spec atdocs/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 reportsMERKLE_MISMATCH/ARCHIVE_MISSING/ARCHIVE_FORMAT_INVALIDbreaks alongside the existing kinds. Spec atdocs/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_abortednotsignet_abort(wire spec mismatch), abort frame missedcorrelation_idandstage, 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 bydata: [DONE]. Strict redaction collapsesreasonto"refused"and omitscheck. Spec atdocs/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 ofsalt:owner_id). Shows deltas vs prior period. Includes audit- chain integrity check + head HMAC tail.--format jsonfor programmatic consumers;--no-anonymizefor authorized roles. Salt comes fromSIGNET_ANONYMIZE_SALTor--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_filefrom voice is the same gating problem as from chat), INSPECTION runs on text content events only (audio passes through with audit row taggedmetadata.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 structuredsignet.refusalshape that SDK callers can parse. Spec atdocs/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_chainsurface in COMMITMENT escalation. WhenToolCallInspectorCheckescalates an irreversible high-tier tool call, the audit row now carriesrequires_approval_from(full ordered approval chain) andcurrent_approver(first link, orNone) so downstream approval workflows can route without re-deriving them. Spec atdocs/escalation.md. Deferred to 0.1.7:signet escalation pending|approve|denysubcommand suite, multi-step chain walking, webhook config, timeout / auto-deny policy.
Changed¶
RateLimitCheckno longer rejectspriority < 100at construction;signet lintSIG001 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_namemetadata key) on everyCheckResultcontinues to flow through the pipeline; the new abort-frame contract surfaces it in shadow / streaming response envelopes alongside the strict-redaction-modecorrelation_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.pysubcommand. Static analysis on a configured pipeline. Catches the four most common misconfigurations: missingOwnerResolutionCheck(SIG002), rate-limit ordered before content checks (SIG001),ToolCallInspectorCheck(allow_unregistered=True)(SIG003),ClassificationGateCheckwithout a paired INSPECTION-stageScopeDriftCheck(SIG004).--strictpromotes warnings to a non-zero exit for CI invocations./healthzand/readyzendpoints./healthzis an alias of/healthmatching k8s convention./readyzactively 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./healthbody upgrade. Now includesversion,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.priorityattribute. Sub-orders execution within a singleStage. Lower runs earlier; defaults to0. 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.RateLimitCheckhard-quota mode. Passrefill_per_second=0for 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 carrieshard_quota=Trueandretry_after_seconds=Noneso callers can distinguish a recoverable rate-limit from a hard cap.ServerConfig.strict_error_redaction(defaultTrue). 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-redactionCLI flags;signet serve --devflips the default toFalsefor integration ergonomics.signet doctorauto-detection. When invoked inside asignet initworkspace,doctorreadsSIGNET_UPSTREAM_URLfrom.env/.env.exampleand defaults--selftohttp://127.0.0.1:8443. Mirrors the convenience thatserve --devalready had.Owner.create(type=, id=)factory andKeyRing(keys=[...], active_id=...)/KeyRing(keys={...}, active_id=...)constructor shapes. Backwards-compatible ergonomic aliases for the longer historical kwargs. TheOwner.human / Owner.agent / Owner.policyclassmethods remain the recommended path for typical use;createis the one-stop entry point for callers who want a single constructor.RequestContext.methodfield (default"POST"). Surfaced so checks that gate on HTTP verb don't have to read raw headers. Populated by the proxy fromrequest.method.ToolSpec.as_metadata()helper. Projects aToolCallInspectorCheckregistry entry into the dict shape expected byToolCallContext.tool_metadata, so consumers don't have to maintain two parallel registries.SandboxPreviewCheck(registry=...)parameter. When supplied, the sandbox plugin readsdryrun_supporteddirectly from the sharedToolSpecregistry rather than expectingToolCallContext.tool_metadatato 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¶
RateLimitCheckno longer rejectsrefill_per_second=0. The validator now requiresrefill_per_second >= 0and 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. OwnerResolutionCheckmodule docstring documents therequire_owner=True↔OwnerType.UNRESOLVEDflow with a 4-line example, including the strict-redaction body shape.LangchainSignetCallbackHandlerrecognizes 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 juststage.ordinal. Stable sort on registration order is preserved for any check that doesn't overridepriority.
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.mdthreat model updated: tamper-evidence now points at the actually-shippedRfc3161Anchor; receipt symmetry now points at the actually-shippedEd25519ReceiptSigner; multi- process writer warning now points at the actually-shippedFileLockingJsonlBackend. 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.Ed25519ReceiptSignerandsignet keys generate-ed25519CLI command. Verifiers hold only the public key and cannot forge. Optional deppip install signet-sign[ed25519]. - External anchor backends for tamper-proof audit chains.
signet.audit.anchor.AnchorBackendProtocol +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 withHmacChain(cache_prev=False)to run uvicorn--workers N>1safely. - Endpoint coverage:
/v1/embeddingsand/v1/completionsare 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¶
/metricsPrometheus 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 asignet_uptime_secondsgauge. 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 inasyncio.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 tailCLI subcommands.audit count --by check|owner|decision|owner_type|stagefor incident-response counts;audit tail -n 50 --filter decision=blockfor log inspection. Both support--jsonfor scripting.signet audit verify --jsonmachine-readable output mode for CI cron consumers.- Redis-backed state stores:
signet.server.redis_session_store.RedisSessionStoreandsignet.checks.redis_rate_limit_state.RedisRateLimitState— drop-in replacements for the in-memory defaults when running multiple replicas. Optional deppip install signet-sign[redis]. --log-format jsonflag onsignet serveswitches 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.snippetsmirrors 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.mdinternal 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 —
signetis a proper noun, not preceded by an article.
Tooling¶
pyproject.tomlextras:[ed25519],[redis],[prometheus].[all]includes them all.[dev]pulls them for dev-environment coverage.ruffper-file-ignores for the deliberately-non-ASCII confusables table inprompt_injection.py+ the obfuscation-attack tests.- Cross-platform mypy fix: file-locking implementation selected at
module import time via
sys.platformnarrowing — keeps mypy clean on both Linux and Windows.
Tests¶
- 242 unit + adversarial green. mypy
--strictclean. ruff lint clean. mkdocs--strictbuild 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 --devshorthand. Bundles--allow-ephemeral-key,--audit-log audit.jsonl, and--config pipeline.pyinto one flag. Each is only set if not otherwise specified. The most common local invocation drops from five flags to one.signet doctorcommand. 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 whatsignet replaydid. Thereplayalias still works but prints a deprecation warning; it will be removed in v0.2 alongside the actual pipeline-replay feature.signet initwrites aclient_example.pyalongside the pipeline scaffold. New users no longer have to read the README to find out how to call signet from Python — both raw httpx andwrap_openaipatterns are demonstrated.signet serveprints the ephemeral HMAC key on startup when--allow-ephemeral-keyis in effect. Lets the user save it externally if they want to verify the audit log later instead of losing the key on shutdown.OwnerResolutionCheckrefusal 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 anexamplesarray.X-Signet-UpstreamandX-Signet-Upstream-Statusresponse 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_labelfield 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.mdopens 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.mdmatches the README's framing for the docs site landing page.docs/checks/owner_resolution.mdcorrected —X-Agent-Idbare values are no longer accepted (theagent:prefix is required, fixed during pre-release security review).
0.1.1 — 2026-05-03¶
Fixed¶
signet servestartup banner used→(U+2192) which crashes Python's default cp1252 stdout on Windows withUnicodeEncodeError. 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.appendnow holds an internalthreading.Lock; concurrent FastAPI requests can no longer fork the chain by reading the sameprev_hmactwice. Multi-process workers still need a custom backend (documented)._serialize_for_signingrejectsNaN/Infinity(allow_nan=False) and usesensure_ascii=Falseso the canonical form is deterministic.JsonlBackend.appendcallsos.fsyncafter 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 aKey.- Verifier docstring lists all four break kinds (
MISSING_KEY_IDwas missing from the module-level summary).
Receipts¶
- Receipt format now carries
alg=hmac-sha256. The verifier rejects receipts whosealgdoes not match the configured signer, blocking downgrade attacks against future ed25519. ReceiptSigneris now aProtocol; concreteHmacReceiptSignerships as the v0.1 default. Callers can pass their own signer toSignetApp(receipt_signer=...)for ed25519 or HSM-backed primitives.- Receipt symmetry caveat called out explicitly in the module docstring,
SECURITY.md, anddocs/architecture.md.
Proxy semantics¶
REDACTresults from ADMISSION now actually modify the request body before forwarding (multimodal vision content preserved) instead of returning 403.ESCALATEresults return202 Acceptedwith anaudit_entry_idfield instead of 403.- Audit
Decisionreflects 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_chatcallssession_store.get_or_create + touchon every request that assertsX-Signet-Sessionand stashes theSessiononRequestContext.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_contentcoalesces multi-linedata:per the WHATWG EventSource spec (consecutivedata: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¶
InMemorySessionStoreandInMemoryRateLimitStateare 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_textis bounded (default 1 MiB) viaextend_text. Long streams setaccumulated_text_truncatedand stop appending instead of doing O(N²) string growth on multi-MB responses.
Audit row contents¶
request_fingerprintis 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_truncatedandchunk_countpropagate intopipeline.completerows so consumers can flag entries where INSPECTION saw only a prefix.
Checks¶
ScopeDriftCheckmarkers are now overrideable via constructor; empty dict disables classification drift entirely. Default false-positive surface documented.PromptInjectionCheckdocstring 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 realpolicy=callable.TribunalCheck.inspect_tool_callswitches togather(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 onehttpx.AsyncClientacross calls.
CLI¶
signet initwrites a.gitignore(.env,.env.*,*.jsonl) alongside the scaffold so first-time users do not commit their HMAC secret or audit log on push. Existing.gitignoreis 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 replayhelp 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 serveprints the pipeline checks loaded at startup so operators can verify the configuration without reading the file._parse_hex_secretgives a clear message whenSIGNET_HMAC_SECRETis malformed: names the env var, accepts an optional0xprefix and surrounding whitespace, suggestsopenssl rand -hex 32, refuses secrets shorter than 16 bytes loudly.
SDK adapters¶
wrap_openai/wrap_anthropicraiseTypeErrorloudly when the SDK client lacks a writablebase_url, instead of silently leaving the original endpoint in place.
Hygiene¶
_iter_entry_pointsloses the pre-3.10 fallback (project pins>=3.11).
Threat model and supply chain¶
SECURITY.mdanddocs/architecture.mdcall 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.mdreporting channel is now GitHub private security advisory with the gmail backed up as fallback only.SECURITY.mdhardening list adds explicitchmod 0600guidance for the audit-log file (signet creates it with the OS umask).publish.ymlgenerates a CycloneDX SBOM and attaches it to each GitHub Release.SECURITY.mdcommits 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/completionsin v0.1).
New tests¶
- 25-thread concurrent append confirms chain stays linked.
- NaN-in-metadata confirms loud rejection.
- Custom-marker
ScopeDriftCheckconfirms override path. - Receipt downgrade-attack confirms
algmismatch 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_texttruncates and flags at the cap.- LRU eviction caps
RateLimitCheckmemory. - Lowercase / mixed-case headers, human-wins-over-agent precedence, whitespace-stripped values, empty-principal blocked.
- Multi-line SSE coalescing and
[DONE]handling. _parse_hex_secretrejects bad hex with a useful message; accepts whitespace + 0x prefix.- Session loaded into
ctx.scratch["_session"]on every request. signet initwrites.gitignoreand does not overwrite existing one.
Test count: 220 unit + adversarial green. mypy clean. ruff clean.
Added — core abstractions¶
signet.core.owner.Owner+OwnerTypeenum (human / agent / policy / unresolved)signet.core.audit.AuditEntry+Decisionenum (allow / block / redact / escalate)signet.core.check.CheckABC +CheckResultwith four hook timings (pre_request,inspect_response_chunk,inspect_tool_call,post_complete)signet.core.stage.Stagefour-tier hierarchy: ADMISSION / INSPECTION / COMMITMENT / RECORDsignet.core.contextrequest / response / tool-call context dataclassessignet.core.pipeline.Pipelinefail-closed sequenced executor
Added — HMAC audit chain¶
signet.audit.chain.HmacChain— append-and-sign coordinator with cached prev_hmacsignet.audit.verifier.ChainVerifier— distinguishes SELF_MISMATCH / LINK_MISMATCH / UNKNOWN_KEY / MISSING_KEY_ID break kindssignet.audit.keyring.KeyRing— multi-era key management for verification across rotationssignet.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 ownerLoopbackTrustCheck(ADMISSION) — auto-resolve owner for loopback + Tailscale CGNATRateLimitCheck(ADMISSION) — per-owner token bucket with pluggable state backendRegexContentCheck(ADMISSION) +RegexOutputCheck(INSPECTION) — block / redact patternsClassificationGateCheck(ADMISSION) — 5-level UNCLASS → TS/SCI gatePromptInjectionCheck(ADMISSION) — pattern + heuristic + base64-decoded scanTokenBudgetCheck(ADMISSION + RECORD) — per-owner output-token quota with reconciliationScopeDriftCheck(INSPECTION) — token / character / classification-marker driftContinuingConsentCheck(INSPECTION) — periodic mid-stream owner-authority revalidationToolCallInspectorCheck(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 configsignet.server.session.Session+SessionStore— cross-request statesignet.server.receipt.ReceiptSigner—X-Signet-ReceiptHMAC-signed decision summary, offline-verifiable by callers
Added — SDK adapters¶
signet.adapters.openai.wrap_openai— in-place reconfigure of openai SDK clientssignet.adapters.anthropic.wrap_anthropic— same shape for Anthropic SDKsignet.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 proxysignet audit verify— walk an HMAC-chained log and report tamperingsignet replay— display the audit row for a given entry IDsignet 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.mdcovering 4-stage hierarchy, continuing-consent + scope-drift patterns, trust model, what is intentionally not in scopedocs/checks/owner_resolution.md,docs/checks/classification_gate.mddocs/plugin_dev.mdwalkthrough- 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