Releases: sunglasses-dev/sunglasses
Release list
v0.5.8
Fixed
-
The release gate certifies one exact commit, not every commit that shares its first seven characters.
scripts/require_release_certification.pymatched CI run heads on a seven-character prefix, so a second commit with the same prefix (an independent review mined one in seven seconds) could borrow a certified commit's run and pass the gate on a different tree. The script now requires a full 40-character sha and compares it whole, in run selection and in the run detail.tests/test_release_certification_gate.pydrives the real script through aghshim: the certified sha passes, an uncertified sha is refused, the reviewer's colliding sha is refused, anything shorter than a full sha is refused before any API call. Both the laptop path and the new release workflow already pass full shas. -
A 27 KB document that is one long word took 255 seconds to scan, and now takes 0.03, with no rule changed. 27 kilobytes is a minified JS file or a base64 blob in a tool result, reachable by accident and on purpose in shipped 0.5.7. One rule owned it,
GLS-ENC-ALT-210, and the reason it ever ran is the part that generalises: the document contains neither "decode" nor "base64", so the prefilter should have skipped it unread. It could not, because the CNF deriver takes one clause from EVERY branch of an alternation and the rule's middle branch is a bare braille character class with no literal in it, so one literal-free branch dropped the requirement for the whole regex and kept the other two unskippable. Such a branch is not requirement-free: it requires a CHARACTER._prefilternow derives aClassClausefrom a bare character class under a repeat of at least one, and a document's codepoint pages answer it in the same pass that finds the literals. Skipping happens only when no branch can match, so detection is identical by construction, and proven: 2,746 documents across 7 channels, 19,222 cells, zero differences against main.sunglasses/patterns.pyis byte-identical to main. The matching case is unaffected at 0.019 s against main's 0.019 s. Still open and unchanged at 207 s: a document where the literal IS present and the blob never matches, which the prefilter cannot skip and which belongs to the bounding work order. -
The language count now has a generator, so it cannot drift back. The README claimed 23 languages in four places, counting every language NAMED anywhere in the ruleset as though it were covered, and the number had already been corrected once in v0.5.6 before a later change put it back.
tools/gen_language_stats.pycounts dedicated patterns per language fromsunglasses/patterns.py: 13 languages carry dedicated patterns, two each (Arabic, Chinese, French, German, Hindi, Indonesian, Japanese, Korean, Portuguese, Russian, Spanish, Turkish and Vietnamese).stats/current.jsonloses the barelanguageskey and carriesdedicated_pattern_languageswith the per language breakdown and the generator's name. Tests fail if the README states 23 as coverage again, if the stats number drifts from the patterns, or if a language is added to the patterns and no prose follows. SUNGLASSES is English first and the README now says so on the line that used to claim otherwise.
Changed
- Every regex now says what it cannot match, and the engine skips it unread. Step 3 used to evaluate all pattern regexes against the whole document on every scan, which is where a 1 MB scan spent most of its time.
_prefilterderives, from each regex's own parse tree, a conjunction of disjunctions of ASCII literals the regex cannot match without; a document missing one is skipped before the regex runs. Declared keywords are NOT used for this, because they are hints and a regex's own literals are necessary conditions (the keyword prototype went blind onGLS-CF-252for the plainest attack in the corpus). Literal derivation is restricted to ASCII, where the four codepoint case fold is exhaustive; a non ASCII literal ends a run and a clause that would carry one is dropped whole, never reduced by deleting an alternative. Measured: 419,748 regex and document pairs, 88.6% skipped, zero skips that hid a match; findings byte identical to the previous engine across 266 corpus documents and on a 1 MB document; a 1 MB scan drops from 37.3 s to 25.2 s. Small documents are neutral. The independent review rejected this change twice before it landed, both times correctly (a fold order bug in a second location, then a Unicode equivalence map that was not one), and the tests now assert exact finding ids through the publicscanso that forcing every skip to true fails 81 of them. - A rule may now declare the rare token it needs, and the engine searches only near it. A rule shaped like the api_response siblings opens with a marker that is cheap to find and common in adversarial text, then spends bounded gaps looking for an object that never arrives, so the cost is marker starts times gap work and the prefilter cannot help because the object is a disjunction.
anchor_termsandanchor_spanare an opt in fourth matching mode beside plain, guarded and windowed: no object anywhere means no search at all, one object at the far end means one window, and repeated objects merge their windows so repeating the anchor does not multiply the work. The search is bounded on the document rather than run on a slice, because a slice invents context at both cuts (\bat the left cut sees a string start where the document has a word character) and every offset it reports is relative to the wrong origin; a candidate is rerun unbounded before it counts. Where the longest match is derivable from the regex it wins over the declared span in both directions. Seven shipped rules declare the mode in this release:GLS-MCP-POISON-201and the six-APIsiblings. Measured on the review's eleven 1 MiB documents, full engine, the anchored siblings run at 1.01x to 1.14x of an engine without them, against up to 20x before. DISCLOSED LIMIT: a rule whose anchor list does not cover its object class loses matches silently and nothing in the engine can check that claim, so every rule adopting the mode brings its own timing fixtures and detection matrices. - An outside auditor now scores the repository, weekly, in public. A new
scorecardworkflow runs the OpenSSF Scorecard on every push tomainand every Saturday, publishes the result to the public Scorecard API and uploads the SARIF to the Security tab, so each finding is a line item with the remediation the auditor asks for. The README badge reads the published number. Every action in the workflow is pinned to a commit SHA with the tag beside it. This is the first "proof outside the agent" item of the parity plan and it changes nothing the scanner does. - A release now leaves receipts a stranger can check. A new
releaseworkflow runs on the version tag/shippushes: it refuses unless the tag equalssunglasses.__version__and the full CI matrix ran and succeeded on that exact commit (the same script the laptop path uses), builds the sdist and wheel, checks the wheel description is the repo README, installs the wheel in a fresh environment, records GitHub build provenance for the files, writes a CycloneDX SBOM of the installed package, sends the files to PyPI through Trusted Publishing with PEP 740 attestations, and attaches the SBOM and a SHA256SUMS to the GitHub Release. Every action is pinned to a commit SHA. Until the trusted publisher is registered on pypi.org the PyPI job fails with nothing sent and the laptop path keeps working, so the switch is one setting, not a flag day. This is the second "proof outside the agent" item of the parity plan and changes nothing the scanner does. GLS-MCP-POISON-201was catastrophic backtracking, and the repeat causing it was doing nothing. The rule paired a manifest key with a payload phrase as(?:KEY.+?){1,3}, three nested lazy gaps under DOTALL followed by an alternation. On a document that repeats one manifest-key fragment (rowR1_repeated_description_sendof the committed timing fixture) the alternation never completes, so every way of partitioning the text between three gaps is tried: about sixteen times the cost for each doubling of the input. Measured on 0.5.7, 2.0 s at 1 KB, 33 s at 2 KB, 531 s at 4 KB. The prefilter was never going to help, and it is not at fault: it derives both clauses correctly and that document carries one literal from each derived clause. Change the second literal and the same 2 KB scan is 0.005 s instead of 32.9 s. The repeat collapses toKEY.+?because the single unbounded lazy gap already accepts everything two more hops could, which is a cost change and not a detection change. DECISIONS are unchanged everywhere tested. SPANS are not, and an earlier draft of this entry said they were: "0 span diffs over 4,109 rows" was true of those rows and false of the rule, because that grammar never repeated a manifest key inside the gap. When a key repeats there and a phrase starts immediately after the inner key, the old form's second greedy hop has to consume a character, so it skips that phrase and reports a match ending at a later one; the one-hop form ends at the nearest phrase. On the reviewer's first counterexample, pinned in the fixture asreviewer_counterexamples[0], the reported match goes from[0,43]to[0,29]. Both still block. The shorter nearest-phrase span is better evidence, so the change is taken rather than reverted, and it is a PUBLIC change: the CLI's JSONmatched_textfor this rule moves on message, file and web_content. 264 generated rows of that class and the reviewer's two counterexamples are pinned with BOTH the old and the new span 4 KB now costs 3.89x the same engine without the rule, against about 33,000x before. The rule also declaresanchor_termsandanchor_span. The windowed matcher that reads them is on main now, so they are LIVE and this rule compiles to anchored...
v0.5.7
Fixed
- A
blocked_pathspolicy now asks what a call TOUCHES, not what its text mentions. ForWrite,Edit,MultiEdit,NotebookEditandReadthe firewall reads the documented path fields, socontentandnew_stringare data rather than targets. Writing documentation that NAMES a protected path is no longer denied like writing TO it. Narrowing applies only when the tool input matches the documented schema and carries a target; a missing target, a falsy one, or a key the schema does not list is judged on every value, as before.
Known limitation
- The same false positive is still present for
Bashand is deliberately unfixed. Two attempts to subtract quoted heredoc bodies before asking the path question both let real operations through: an independent review executed nine shapes where the parser removed text the shell runs, including a quoted heredoc piped intobash, an apparent opener inside a comment or inside$((1 << n)), and a delimiter word longer than the token matched. Subtracting from shell syntax safely needs a real grammar, so aBashcommand is judged on all of its text and one whose text only NAMES a blocked path is still denied. A test asserts this, and it is what fails when the lane is repaired.
Unchanged
- 1,540 patterns, 118 categories, 6,642 keywords. This release adds no patterns and no blog.
v0.5.6 — trust repair: no clean verdict without a complete inspection
Trust repair. No new patterns (1540 / 118 categories unchanged) and no new parsers.
The result schema IS extended, additively:threat_found,inspection_complete,
files_skipped/skippedand per-file skip reasons are new, andis_cleanchanges
meaning — it now requires a complete inspection as well as no findings, so a result
that wastruefor an uninspected archive is nowfalse. The changes below repair three
things: what the scanner DETECTS, what it truthfully reports as COVERED, and what it does
without asking your CONSENT.
Fixed
-
A scan that could not read the file no longer reports it as clean. Six paths returned
a success-shaped answer for content nobody looked at: a directory passed as a file, a
path-like argument that does not exist (the path string was scanned as prose and came
backallow), an archive whose compressed bytes were "scanned" as text, an input past the
1 MB cap, audio or video without--deep, and a deep scan
whose transcription failed, where the ffmpeg error message was scanned as if it were
the transcript and the file was reported PASS. Each now reports what actually happened. -
is_cleanmeans what it says. It used to bedecision == "allow", so "I found nothing
in the 5% of this file I could read" and "this file is clean" were the same value. A scan
result now answers three separate questions —threat_found,inspection_complete, and
is_clean(both) — in the API, and in every scan document emitted by the CLI, the library
and the MCP server. (Argument-parsing failures emit an error document, not a scan document.)
API change: if you branch onis_cleanmeaning "no findings", the equivalent condition
is nownot result.threat_found— note the inversion, sinceresult.threat_foundalone
is the opposite test.is_cleanadditionally requires a complete inspection. -
Exit codes are applied on every scan path and documented.
0complete and nothing
found ·1threat ·2usage or operational error — nothing scanned on single-file paths,
and on aggregate paths (repo, batch) the affected scope is reported, since work may have
completed before the failure ·3incomplete,
nothing found in what could be read. Precedence1 > 3 > 2 > 0. Previously only the file
and text paths used3at all; repo scan, deep scan and audio-without---deepreturned
only0/1, and the selector ignored truncation entirely. A missing file used to exit1,
indistinguishable from "threat found" to a CI job. -
File format is identified by content, not by the filename. A ZIP named
notes.txthad
its compressed bytes read as prose and was reported as a complete, clean scan. (Compressed
bytes are not reliably inert as input, either: scanning them as text has produced false
matches in our own repo-mode testing. The defect is that they were treated as document text
at all, in both directions.) Identification is now content-first with a suffix fallback
(dispatch.identify()): a real PDF named.txtstill reaches the PDF extractor, and an
archive is reported as uninspected whatever it is called. Repository traversal keeps its
documented extension exclusions, so this is not filename independence on every surface.
No archive parser was added; the tool stops claiming it read what it did not. -
--jsonand-o json|sarifemit exactly one document on stdout, on the CLI
machine-output invocations the acceptance matrix covers — the covered CLI cases for clean,
finding, incomplete, incomplete-with-finding, unreadable, missing and missing-dependency
states, plus argument errors. The deep-scan branch printed three progress lines in front of its payload,
soscan --deep --json | jqnever had a chance. Diagnostics now go to stderr.-o json
printed human text despite parsing the flag. Argument errors used to print a usage paragraph
to stderr and nothing to stdout; when a machine format is selected they now emit one JSON
error document. The claim is scoped to CLI machine-output invocations proven by that matrix and is
generated from it; library functions return result documents and MCP returns protocol
responses, which are separate interface contracts. Surfaces outside the matrix are listed in
KNOWN_VERSION_GAPS.md. -
sunglasses pinasks before starting your MCP servers. It launches every configured
stdio server with your full environment to read its tool lists — necessary, but it did it
with no prompt, printing "reading descriptors from N server(s)" while already doing it,
including from--quiet, which is the mode wired into timers andSessionStarthooks. It
now prints the exact command lines and waits. With no terminal to ask it refuses rather
than launching, unless you pre-consent with--yesorSUNGLASSES_PIN_CONSENT=1. Consent
is read from the environment only, never from a scanned repository or project settings. -
A left-boundary defect that hid the ordinary forms of five detection rules. A
\b
written immediately before a literal that is not a word character asserts only where the
PRECEDING character is a word character. After a space — or at the start of the input — it
cannot hold, so the ordinary whitespace-separated forms these rules exist to catch were
missed, while a form glued to a preceding word could still match.GLS-EX-007(curl
credential upload) had only such alternatives, so its ordinary command-line forms went
undetected. Also repaired:
GLS-SC-PKG-207(--extra-index-url,--trusted-host,.npmrc,.pypirc),
GLS-SESNR-001(/var/run/docker.sock,/proc/,--privileged),GLS-SBX-887
(container/sandbox escape), andGLS-DFP-041(the---front-matter fence). Each now uses
(?:\b|(?<!\S))— a left-boundary repair that also holds after whitespace or at the start
of input.GLS-EX-007additionally changes its description and narrows its expression beyond
a boundary substitution: it is now scoped to uploads whose payload is a credential file, so
an ordinarycurl --data @report.csvis not a finding.
Five patterns changed in total; none added, none removed — 1540 patterns and 118
categories are unchanged from 0.5.5. -
A non-empty list of extracted sources is no longer treated as proof that everything was
inspected. Five public extractorscan_*convenience functions and two retained
SunglassesScannerhelpers each folded their child scan results by hand, copying findings
and decision while dropping the child'struncatedandextraction_complete. So a
transcript or PDF text layer that ran past the 1 MB engine cap arrived at the normalizer
with the truncation already discarded, andscan_deep()returnedtruncated: false, inspection_complete: true, is_clean: true, exit 0for content it had read only part of.
The converted convenience functions and helpers no longer fold by hand: they now go through
sunglasses.result.aggregate(), which folds coverage pessimistically (any child truncated ⇒
truncated; every child complete ⇒ complete) while findings fold additively.scan_email()
keeps its explicitly retained fold beforenormalize(), a documented exception rather than
a missed conversion. -
A sub-parser that gives up no longer hides the content after it.
PDFExtractor
wrapped its whole annotation loop in oneexcept Exception: pass, so a valid PDF whose
annotation array began with a malformed element abandoned the rest of the loop — and an
instruction sitting in the NEXT annotation was never extracted, with the scan reported as
complete and clean. The guard now sits inside the loop, so one bad element costs only
itself, and the failure is recorded as lost coverage rather than dropped. Every other
silentexcept: passin the extractors (audio metadata, video subtitles, the video audio
track, image EXIF, hidden-text detection) now names what it could not read. -
OCR that could not run no longer returns a clean image.
ImageExtractorrecorded the
loss infailuresand returned normally, and_scan_image_fast()/scan_image()never
read that list — so with Tesseract absent fromPATHthey returned complete and clean,
with no warnings, for an image whose visible text was never read.scan_fast()on the
same file correctly said incomplete. -
Bytes that do not decode are no longer counted as inspected. The repaired scan text
reads usederrors="ignore", which silently drops undecodable bytes: a 256-byte file of non-UTF-8
pairs scanned 128 bytes and returned complete and clean. We still scan what decodes, but
the result is incomplete and the warning names how many bytes went unread. -
A named pipe no longer hangs the MCP server. The readability probe proved a path
readable by opening it, and opening a FIFO with no writer blocks in the kernel — so an
MCPscan_fileon one never returned a result at all. The file type is now checked on
os.statmetadata before any file object exists, and FIFOs, sockets, device nodes and
directories are refused operationally (NonRegularFile, a subclass ofUnreadableFile). -
Undecodable stdin is an operational error, not a crash.
sys.stdin.read()sat outside
every handler, so a byte stream that is not valid UTF-8 raisedUnicodeDecodeErrorout of
main()— and Python exits 1 on an uncaught exception, which is this package's code for
THREAT FOUND. A CI job piping a binary file was told it had been attacked, and got a
traceback instead of a document. It now exits 2 with one document naming the offset. -
-ojsonis honoured when the parse fails. The pre-parse format detector missed
argparse's attached short-option form, soscan -ojson --channel not-a-channelexited 2
with completely empty stdout — the one path where "exactly one document, always" matters
most to a machine caller. -
**Empty input is rep...
v0.5.5 — hotfix: firewall hook
Hotfix release: firewall hook fixes only, no new patterns (1540 patterns / 118 categories unchanged).
The first entry below shipped as code in 0.5.4 (PR #122) and is documented here.
Fixed
- Firewall hook: no more "approve to pin" prompt on every call for tools that cannot be
pinned.sunglasses pinreads stdio servers only; a browser extension (Claude in Chrome),
a hosted connector or an HTTP/SSE server has no descriptor to hash, and the changelog said so.
The hook nevertheless answeredask(GLS-FW-PIN-TOFU) for every unpinned MCP tool, so on
those servers the prompt came back on every single call, even in bypass mode, and approving
pinned nothing. Now the hook asks only where approving can pin something: a new tool on a
serversunglasses pindid read, or a machine wheresunglasses pinhas never run. A server
pin could not read gets no opinion and the receipt recordspin_reach: unpinnable, so the
blind spot stays on the audit trail instead of in the user's face. Measured on the maintainer's
machine: 224 such prompts between Aug 28 and Sep 4 2026, all on Claude in Chrome tools.
Fixed
- Firewall hook: a clean call now answers
{}instead of"permissionDecision": "defer".
deferwas the firewall's internal name for "no opinion, fall through to Claude Code's
own permission flow", and it was written to the wire as a literal. Claude Code documents
allow,denyandask. An interactive session tolerates the unknown value, but a
subagent or a headlessclaude -prun has nowhere to defer to: the tool call is marked
deferred, never executes, and the turn ends with an empty result
(terminal_reason: tool_deferred). Anyone running the hook with subagents, the Agent SDK
orclaude -pautomation hit this silently. The empty object is the documented
"no opinion" shape, so behaviour in interactive sessions is unchanged; deny / ask output
and the receipts (which still recorddefer) are untouched. Reproduced and fixed
2026-09-03;sunglasses firewallself-test accepts both shapes so an older installed
hook is not reported as broken.
v0.5.4
Added
- 80 new
agent_workflow_securitydetection patterns (GLS-AW-666throughGLS-AW-745, 80 IDs). Examples: Trigger Payload Relational Memory Backdoor; Digital Twin Command Context Drift; Reverse Shell Control Channel Smuggling; Search Time Benchmark Contamination. Every pattern fired on its own attack fixture, stayed silent on its benign twin and produced zero hits on the 78 document benign corpus at intake. - Pattern count 1460 → 1540, 118 categories.
- Companion write up: https://sunglasses.dev/blog/ai-agent-workflow-security-needs-proof
v0.5.3
Added
- 23 new
memory_retrievaldetection patterns (GLS-MR-041throughGLS-MR-072,
23 IDs). Second memory and retrieval release: stored state that outlives its
proof. Escape (best_of amplification, ZIP path traversal into memory staging,
poisoned knowledge graph facts), fusion (temporal fragment fusion, mid task goal
fusion, falsifier polarity inversion, routing telemetry camouflage, controllability
constrained transfer, embedding nearest neighbor collisions), verdict laundering
(stale self state snapshots, replayed financial mandates, self evolution fitness
gaps, primitive placement laundering, unprotected MCP auth gates, cross metric
denominators, fact check verdict propagation, synthetic task reconstruction, stale
Chroma matches), revocation (shared prompt delete path escape, remembered grants,
sparse evidence operationalization, semantic cache resurrection after Forget or
Rollback) and sink checks (data: URL scheme bypass). Every pattern fired on its own
attack fixture, stayed silent on its benign twin and produced zero hits on the 78
document benign corpus at intake. Category count unchanged at 118, pattern count
1437 → 1460. - Companion write up: https://sunglasses.dev/blog/ai-agent-memory-needs-current-proof
v0.5.2
Added
- 30 new
memory_retrievaldetection patterns (GLS-MR-001throughGLS-MR-040,
30 IDs). First release of the memory and retrieval category: records that gain
trust while losing information. Admission (redirected links, malformed resolver
answers, unsigned artifacts, last-write conflicts, spoofed authorship), binding
(identity, tenant, session, fail-open auth and policy gates), replay (stale approvals,
dormant activation predicates, stale containment proofs), laundering (dashboard and
stability scores, cascade confidence, decoy completions, telemetry spoofing) and
leakage (membership probes, paired subset attribution, memorized records, shared
serving caches, DNS-rebound local proxies). Every pattern fired on its own attack
fixture, stayed silent on its benign fixture and produced zero hits on the 78
document benign corpus at intake. Category count 117 → 118, pattern count 1407 → 1437. - Companion write up: https://sunglasses.dev/blog/ai-agent-memory-is-evidence-not-authority
v0.5.1
Audit remediation
Everything below came out of an independent clean-room audit of v0.5.0 run on
2026-08-30 by a session that had not built the product, followed by a second pass that
corrected the audit's own numbers. Findings are referenced by their audit id.
Fixed
scan --filenow reaches the extractors (C1).SunglassesEngine.scan_file()was
a rawopen().read(), sosunglasses scan --file document.pdf— the command printed
in the README quickstart — returned "PASS, no threats detected" on a PDF carrying a
prompt injection in a compressed content stream, while the Python
SunglassesScannerAPI caught the same file. One file, two surfaces, two verdicts.
Routing now lives inextractors/dispatch.pyand both file entry points use it; they
previously carried separate extension tables, which is the drift that produced the
bug. A file we cannot fully read carriesextraction_complete=Falseand a warning,
and new exit code 3 means "read incompletely, found nothing" —0is a claim,
and it must not cover both a verified-clean scan and an unreadable one.- Receipt fields are sanitized (H2).
tool_nameis chosen by the MCP server — the
party the pin lane exists to defend against — and was stored and re-rendered
verbatim. A name carrying ANSI escapes madesunglasses receiptsclear the screen
and print a forged all-clear; a newline forged an extra row. Control characters are
stripped and values truncated on write and on render, because a receipts file is
bytes on disk that may predate this build. - Mislabelled detections (H3/M9). A low-resource-language jailbreak pattern led its
regex with an English phrase and a navigation-constraints pattern carried the bare
2-gram "ignore previous", so every English injection was reported as a Swahili
jailbreak and a navigation attack. Both anchors removed; the languages and the
navigation shape still catch. Overlapping patterns on one span now fold into
also_matchedin the rendered output —findingsstays complete for API callers.
An 8-word attack reported 7 findings; it reports 4, correctly labelled, same verdict. corpus_releaseis derived, not echoed. It wasargs.release or "unfrozen"—
the caller's own argument recorded as fact, so a run claimed a frozen scoring corpus
whether or not one existed. It now reads disk and believes it over the caller.- The daily report's error message (M4) told a user to "run some scans first" when
they had just run scans; onlyProtectedEnginescans are recorded. It now names the
actual condition. sunglasses --helpprinted the literal==SUPPRESS==(L1).argparse.SUPPRESS
is honoured for options but not for subparsers.- The hook answered any event (L5). A
PostToolUsepayload could returndeny— a
veto on an action that had already run. Non-PreToolUse events defer and say why; a
missing event is still checked, since some harnesses omit the field. sitemap.xmllisted a redirecting URL (L7). Replaced with the canonical
destination; all 107 entries return 200 with no hops.
Added
tools/gen_perf_stats.py(H1). The published0.26msfigure had no generator
anywhere in the repository, and on the hardware the README named it was the cost of
scanning an empty string — the project's ownsunglasses demoprinted 2.78ms on
the same machine. Performance is now measured against a public in-repo corpus and
reported as a distribution, because scan cost is linear in input length and one
number cannot describe both an 18-character command and an 8 KB document.- Input size cap, 1 MB default, configurable (M8). At ~50 µs/byte an uncapped
filter handed a 10 MB page stalls an agent for minutes — a denial of service an
attacker triggers with a large benign document.result.truncatedand
bytes_scannedmean a partial scan can never read as a clean one. sunglasses receiptsreports a dead firewall (L4). The hook embeds an absolute
interpreter path (correct — a barepython3can resolve to an interpreter without
sunglasses), but a recreated venv leaves a hook that cannot start, and that is the
one failure mode which writes no receipt. The audit trail now says so.- Four ship gates: published performance must be measured and not stale (CHECK 25),
rendered site prose must match stats truth (CHECK 26), packaging claims must match
the package (CHECK 27), and the built wheel's description must equal the repo README
before upload (CHECK 27b) — the 0.5.0 wheel shipped a README labelled v0.4.9.
Changed
python_requires>=3.8→>=3.9, and CI now tests 3.9 through 3.13. Five versions
were claimed and one was tested; 3.8 has been EOL since October 2024.- PyPI classifier
3 - Alpha→4 - Beta. - The README publishes latency as a range, names the corpus beside the "0
false positives" figure, and prints the command for the test count instead of a
number that drifts (it read 444 against a suite of 855).
Removed
sunglasses/_version_check.py(M3). 6.5 KB of never-imported code that did
urlopen()inside a package whose headline claim is "zero network calls, zero
telemetry". The claim held because nothing imported it; it was one import away from
not holding, and any reviewer grepping the wheel found it first.
v0.5.0
Hardening milestone
- The Gauntlet: adversarial self-test suite (D_engine corpus + false-positive,
exfiltration-shape, and control suites) now runs nightly at 03:30 via launchd from a
pinned worktree — the artifact recordsbranch,dirty, and git sha so a score can
never silently come from an untested tree. - Honest miss aging: an open miss keeps its original
founddate across runs
(a regression re-stamps as a new find; a crashed run cannot wipe history). - Frozen scoring corpus:
gauntlet freeze --release 0.5.0promotes the case pool
into an immutable scoring set — published scores are measured against a corpus that
cannot drift after the fact.
Added
- MCP descriptor drift is now enforced, not just reported.
sunglasses pin --checkwrites
its verdict to~/.sunglasses/pin_state.json; the hook reads that file and denies a tool
whose descriptor changed since you pinned it (GLS-FW-PIN-DRIFT). Detection stays out-of-band
— the hook still makes zero network calls, and the measured hook cost is p50 0.37ms / p99 7ms
across 429 real receipts, against ~10-1,000ms for an MCPtools/listround-trip.
The lane escalates todenyrather thanaskbecause anaskis advice the harness may
decline to surface: measured 2026-08-28, a TOFUaskfor an unpinned MCP tool never reached
the user under their permission mode and the call simply ran. A rug-pull verdict that resolves
to advice is decoration. sunglasses pin --quiet— silent on a clean run, speaks up on drift. For the two unattended
callers: a launchd timer, or a Claude CodeSessionStarthook.sunglasses pinnow names what it could NOT read. Each server reports one ofok,
empty,unreachable,timeout,unsupported_transport, and the run prints a coverage line
(Coverage: 1/2 server(s) read). Pin coverage is recorded in the pin file too.
Fixed
-
A server we could not reach no longer looks identical to a server with no tools.
list_tools_stdioreturned a bare[]for four different facts — dead process, non-stdio
transport, timeout, and genuinely-empty — andbuild_pinsfolded all four into "pinned
nothing". Found by dogfooding on the author's own machine, where one of two configured MCP
servers had been silently unpinned whilepinprinted a success line.probe_servernow
returns a named status;list_tools_stdioremains as the descriptors-only wrapper. -
Plugin-declared MCP servers are discovered and pinned. Plugins ship real stdio servers and
we were blind to every one of them, for two boring reasons: their.mcp.jsonlives in the
plugin install directory, and it uses a flat{"<name>": {...}}shape with nomcpServers
wrapper — so a wrapper-only reader pointed straight at the file still returned nothing.
Discovery now reads the plugin install manifest (not the whole plugin cache, which also holds
checkouts the user does not run) and accepts both shapes. Servers are keyed the way the hook
will see them,plugin_<plugin>_<server>: pinning them under the bare server name would
produce a pins file that looks healthy and matches nothing at hook time. -
Gauntlet suite A is live — credential-exfil shapes driven through the firewall over stdin,
with two CONTROL cases (an ordinary outbound call, and a local write of credential-shaped
material) so the suite can tell "the right things are blocked" from "everything is blocked".
Fixtures live ingauntlet/corpus/suite_a.jsonand only there; a miss recordscase_idand
class, never the payload. Notably, no entry was added toKNOWN_PUBLIC_CANARIES— that
list exempts a literal credential for every user of this package, permanently, and buying a
global exemption to solve a local authoring problem is the wrong trade. It stays reserved for
genuinely published third-party revoked fixtures.
Known limits (stated plainly)
- Pinning reads stdio servers only — those declared in
~/.claude.json/.mcp.jsonand
those shipped by installed plugins. Non-stdio transports (HTTP/SSE) cannot be read yet, and
servers provided by hosted connectors or a browser extension have no local command to spawn and
no durable descriptor store, so there is nothing to hash: they cannot be pinned at all. On the
author's machine that is 3 discoverable servers, one of which was down, against 5 live hosted
connectors plus an extension. We pin what we can read; this is exactly what we cannot.
v0.4.9
PULSE Day 6, the final week-1 ship. 29 new patterns staged from the AZ gate (DAY16-DAY21) via
the gate-stage-to-db intake: every regex re-proven against its own hostile fixtures, silent on
benign fixtures and 0 hits across the 78-document real-world FP corpus. 18 gate cards were
rejected at intake with written reasons (TP_MISS, REDOS_SUSPECT, CORPUS_FP, NO_HOSTILE_FIXTURE)
and stay queued for repair.
Added
- 29 patterns across 12 categories: attestation_lineage_poisoning (3), cross_agent_injection (1),
denial_of_ai_service (5, new category), duplicate_key_shadowing (2), encoding_evasion (1),
indirect_prompt_injection (3), memory_state_replay (2), mlops_metadata_poisoning (2),
privilege_escalation (3), provenance_chain (4), structured_metadata_poisoning (2),
ui_injection (1). IDs GLS-ALP-002 through GLS-UINJ-004. - Blog: "AI Agent Evidence Must Stay Bound to Its Source" — merged evidence page covering all 29.