Skip to content

Releases: sunglasses-dev/sunglasses

v0.5.8

Choose a tag to compare

@azrollin azrollin released this 14 Sep 12:54
b7e33c2

Fixed

  • The release gate certifies one exact commit, not every commit that shares its first seven characters. scripts/require_release_certification.py matched 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.py drives the real script through a gh shim: 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. _prefilter now derives a ClassClause from 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.py is 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.py counts dedicated patterns per language from sunglasses/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.json loses the bare languages key and carries dedicated_pattern_languages with 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. _prefilter derives, 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 on GLS-CF-252 for 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 public scan so 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_terms and anchor_span are 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 (\b at 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-201 and the six -API siblings. 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 scorecard workflow runs the OpenSSF Scorecard on every push to main and 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 release workflow runs on the version tag /ship pushes: it refuses unless the tag equals sunglasses.__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-201 was 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 (row R1_repeated_description_send of 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 to KEY.+? 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 as reviewer_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 JSON matched_text for 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 declares anchor_terms and anchor_span. The windowed matcher that reads them is on main now, so they are LIVE and this rule compiles to anchored...
Read more

v0.5.7

Choose a tag to compare

@azrollin azrollin released this 11 Sep 01:11
d3ec34f

Fixed

  • A blocked_paths policy now asks what a call TOUCHES, not what its text mentions. For Write, Edit, MultiEdit, NotebookEdit and Read the firewall reads the documented path fields, so content and new_string are 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 Bash and 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 into bash, 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 a Bash command 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

Choose a tag to compare

@azrollin azrollin released this 10 Sep 02:34
0557753

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/skipped and per-file skip reasons are new, and is_clean changes
meaning
— it now requires a complete inspection as well as no findings, so a result
that was true for an uninspected archive is now false. 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
    back allow), 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_clean means what it says. It used to be decision == "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 on is_clean meaning "no findings", the equivalent condition
    is now not result.threat_found — note the inversion, since result.threat_found alone
    is the opposite test. is_clean additionally requires a complete inspection.

  • Exit codes are applied on every scan path and documented. 0 complete and nothing
    found · 1 threat · 2 usage 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 · 3 incomplete,
    nothing found in what could be read. Precedence 1 > 3 > 2 > 0. Previously only the file
    and text paths used 3 at all; repo scan, deep scan and audio-without---deep returned
    only 0/1, and the selector ignored truncation entirely. A missing file used to exit 1,
    indistinguishable from "threat found" to a CI job.

  • File format is identified by content, not by the filename. A ZIP named notes.txt had
    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 .txt still 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.

  • --json and -o json|sarif emit 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,
    so scan --deep --json | jq never 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 pin asks 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 and SessionStart hooks. It
    now prints the exact command lines and waits. With no terminal to ask it refuses rather
    than launching, unless you pre-consent with --yes or SUNGLASSES_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), and GLS-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-007 additionally 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 ordinary curl --data @report.csv is 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 extractor scan_* convenience functions and two retained
    SunglassesScanner helpers each folded their child scan results by hand, copying findings
    and decision while dropping the child's truncated and extraction_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, and scan_deep() returned truncated: false, inspection_complete: true, is_clean: true, exit 0 for 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 before normalize(), 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 one except 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
    silent except: pass in 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. ImageExtractor recorded the
    loss in failures and returned normally, and _scan_image_fast() / scan_image() never
    read that list — so with Tesseract absent from PATH they 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 used errors="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
    MCP scan_file on one never returned a result at all. The file type is now checked on
    os.stat metadata before any file object exists, and FIFOs, sockets, device nodes and
    directories are refused operationally (NonRegularFile, a subclass of UnreadableFile).

  • 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 raised UnicodeDecodeError out 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.

  • -ojson is honoured when the parse fails. The pre-parse format detector missed
    argparse's attached short-option form, so scan -ojson --channel not-a-channel exited 2
    with completely empty stdout — the one path where "exactly one document, always" matters
    most to a machine caller.

  • **Empty input is rep...

Read more

v0.5.5 — hotfix: firewall hook

Choose a tag to compare

@azrollin azrollin released this 04 Sep 10:00
0225a4e

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 pin reads 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 answered ask (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
    server sunglasses pin did read, or a machine where sunglasses pin has never run. A server
    pin could not read gets no opinion and the receipt records pin_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".
    defer was 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, deny and ask. An interactive session tolerates the unknown value, but a
    subagent or a headless claude -p run 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
    or claude -p automation 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 record defer) are untouched. Reproduced and fixed
    2026-09-03; sunglasses firewall self-test accepts both shapes so an older installed
    hook is not reported as broken.

v0.5.4

Choose a tag to compare

@azrollin azrollin released this 04 Sep 07:39
f5f1653

Added

  • 80 new agent_workflow_security detection patterns (GLS-AW-666 through GLS-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

Choose a tag to compare

@azrollin azrollin released this 03 Sep 05:55
d2a4318

Added

  • 23 new memory_retrieval detection patterns (GLS-MR-041 through GLS-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

Choose a tag to compare

@azrollin azrollin released this 02 Sep 00:23
0e4e30f

Added

  • 30 new memory_retrieval detection patterns (GLS-MR-001 through GLS-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

Choose a tag to compare

@azrollin azrollin released this 31 Aug 12:11
4ce420c

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 --file now reaches the extractors (C1). SunglassesEngine.scan_file() was
    a raw open().read(), so sunglasses 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
    SunglassesScanner API caught the same file. One file, two surfaces, two verdicts.
    Routing now lives in extractors/dispatch.py and 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 carries extraction_complete=False and a warning,
    and new exit code 3 means "read incompletely, found nothing" — 0 is a claim,
    and it must not cover both a verified-clean scan and an unreadable one.
  • Receipt fields are sanitized (H2). tool_name is 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 made sunglasses receipts clear 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_matched in the rendered output — findings stays complete for API callers.
    An 8-word attack reported 7 findings; it reports 4, correctly labelled, same verdict.
  • corpus_release is derived, not echoed. It was args.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; only ProtectedEngine scans are recorded. It now names the
    actual condition.
  • sunglasses --help printed the literal ==SUPPRESS== (L1). argparse.SUPPRESS
    is honoured for options but not for subparsers.
  • The hook answered any event (L5). A PostToolUse payload could return deny — 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.xml listed 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 published 0.26ms figure 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 own sunglasses demo printed 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.truncated and
    bytes_scanned mean a partial scan can never read as a clean one.
  • sunglasses receipts reports a dead firewall (L4). The hook embeds an absolute
    interpreter path (correct — a bare python3 can 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 - Alpha4 - 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

Choose a tag to compare

@azrollin azrollin released this 30 Aug 21:42
a535b2c

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 records branch, dirty, and git sha so a score can
    never silently come from an untested tree.
  • Honest miss aging: an open miss keeps its original found date across runs
    (a regression re-stamps as a new find; a crashed run cannot wipe history).
  • Frozen scoring corpus: gauntlet freeze --release 0.5.0 promotes 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 --check writes
    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 MCP tools/list round-trip.
    The lane escalates to deny rather than ask because an ask is advice the harness may
    decline to surface: measured 2026-08-28, a TOFU ask for 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 Code SessionStart hook.
  • sunglasses pin now names what it could NOT read. Each server reports one of ok,
    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_stdio returned a bare [] for four different facts — dead process, non-stdio
    transport, timeout, and genuinely-empty — and build_pins folded 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 while pin printed a success line. probe_server now
    returns a named status; list_tools_stdio remains 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.json lives in the
    plugin install directory, and it uses a flat {"<name>": {...}} shape with no mcpServers
    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 in gauntlet/corpus/suite_a.json and only there; a miss records case_id and
    class, never the payload. Notably, no entry was added to KNOWN_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.json and
    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

Choose a tag to compare

@azrollin azrollin released this 24 Aug 20:53
8a51003

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.