
SUNGLASSES
io.github.sunglasses-dev由 GitHub 帳號 sunglasses-dev 發布v0.6.8更新於 Oct 26, 2026
Input firewall for AI agents: scan text and files before an agent reads them. Local, no network.
概覽
本機輸入防火牆:讓助理在讀取文字或檔案前先掃描其中的提示注入與憑證樣式,全程不連網。
- 可以做什麼
- 掃描傳入的文字,回傳 allow、block、quarantine 或 allow_redacted 決策及命中項目與嚴重程度
- 掃描本機指定檔案,涵蓋文字與程式碼,安裝選用元件後還可處理圖片、PDF、QR 碼、音訊與影片
- 透過資訊工具回報掃描器狀態與設定
- 回傳明確的完整性標記,區分「已讀完且乾淨」與「有內容無法讀取」
- 適用情境
- 適合在助理處理不可信的文字、文件或程式碼儲存庫時,先做一次本機檢查,辨識注入嘗試與看似憑證的字串。也適合需要明確區分通過、發現威脅與檢查不完整的 CI 或腳本流程。
- 執行需求
- 本機需安裝 Python 與 sunglasses 套件,伺服器以本機程序方式透過 python -m sunglasses.mcp 啟動。選用元件可增加 OCR、PDF、QR 碼、音訊與影片掃描,需要 tesseract、zbar 與 ffmpeg。不需帳號、API 金鑰或網路存取。
安裝
在 SourceWeft 中
- 開啟 儀表板中的 SUNGLASSES,將其新增到工作區。
- 為需要使用其工具的對話啟用該服務。
Desktop only,透過 STDIO。 STDIO 服務會啟動本機處理程序,因此需要 SourceWeft 桌面主機。
其他 MCP 客戶端
參照 儲存庫 中的啟動說明。
README
SUNGLASSES
[pattern-integrity] [PyPI] [python: 3.9 – 3.14] [License: MIT] [OpenSSF Scorecard] [installs (incl. mirrors)] [Listed on mcpservers.org]
Open source input firewall for AI agents, beta. A local scanner checks text, code, PDFs, images, QR codes, audio and video with 1,513 patterns that carry a matcher (1,584 are listed) across 117 listed categories and reports findings and incomplete scans. A Claude Code hook blocks secret material in tool calls and the paths and hosts your policy lists, before a tool runs, best effort under its 10 second timeout.
What works today
- Scan text, files, PDFs, images and QR codes from the CLI or from Python
- An MCP server your agent calls, and a GitHub Action that scans every pull request
- A Claude Code hook that blocks the credential paths and policy violations your policy lists, before a tool runs
- A local MCP proxy that refuses every
tools/listandtools/calluntil a person approves the server at an interactive terminal. Once approved, it withholds a credential in a tool call or in a tool result, which is the credential lane and not general inspection of everything a tool returns. Installing it is not protection on its own. See What the proxy enforces. - Outside that lane this reads input, so a clean result is a confidence floor and not a guarantee
Sunglasses is a local, open-source scanner for text and supported files. It reports what it matched and what it could not read, so you can decide what to pass onward. It produces findings and an exit status; a CI job, a Claude Code hook or your own code acts on that result.
Sixty seconds
An activated virtualenv keeps the install and the sunglasses your shell resolves in the
same environment, and --upgrade matters if you already have an older version. curl -fsSL
fails on an HTTP error instead of saving the error page, so the script only runs if the
download actually succeeded.
The script writes three fixture files into a temp directory, and deliberately scans a fourth path that does not exist, five scanner invocations in total, because the archive is scanned twice (human output and JSON). The script's own commands execute; scanned content stays data (it is never executed, and the ZIP is not extracted).
Abbreviated output, recorded on 0.5.6. Finding rows 2-5 are omitted below; timings and presentation are not shown because they vary:
And the same archive as JSON. Selected fields from the scan document, not the whole of it:
decision: not_inspected with is_clean: false. The archive's table of contents was read and its
member names were scanned; the members themselves were not, and when no member name matches a pattern
the decision says so itself (before 0.6.17 it said allow beside is_clean: false).
A caller that tests decision == "allow" stops here; one that tests decision != "allow"
should read threat_found before treating this as a threat. The document exposes the
distinction; acting on it is the caller's job.
Timings and presentation vary. The demo checks the exit statuses and the ZIP coverage fields; report a mismatch against your installed version.
⭐ If this is useful, consider starring the repository.
🕶 Or try it in your browser, no install: sunglasses.dev/scan (scan text, GitHub repos, or images). Image OCR runs locally in your browser; the image never leaves your device.
What the proxy enforces
python -m sunglasses.proxy -- <your server command> runs a real MCP server as
a child process and mediates the stdio session between it and your client.
What it does depends entirely on whether that server has been approved, and
the two states are very different.
Before approval, your client's tool requests are refused
Out of the box, every tools/list and tools/call from your client is
refused. The client receives a typed JSON-RPC error like this one.
server_id and snapshot_sha256 are the two values the approve command
needs, and the refusal is where you get them. You do not have to look inside
the state directory to find out what to approve.
status: not_run and inspected_utf8_bytes: 0 describe your client's
request. It is not forwarded to the server and its arguments are not scanned.
The proxy does read the server's own tool list first. It fetches every
tools/list page from the server and scans the descriptors to build the
snapshot you approve. A page with a finding is refused on that finding before
any approval is looked up. Installing the proxy does not protect anything by
itself.
Approval is a deliberate human step and it requires an interactive terminal.
It prints the tool names and the first 16 characters of each descriptor digest, then asks. It records that someone at an interactive terminal answered yes to that list. From a pipe it refuses. Run it at a real terminal and answer the prompt.
An approval belongs to one server, not to a tool list. The snapshot hash
covers the descriptors, and two different servers exposing the same tools have
the same snapshot_sha256, but they get different server_ids, and the
approval is stored against the server_id. Measured: two servers whose
captures both read snapshot_sha256 26aeeb2f7c5b61aa… carry the ids
8740fa6360ce72946fa5a99e86974e01 and 0096972d2543ec556ad9eefe9c144b05, and
approving the first left the second refusing with APPROVAL_REQUIRED until it
was approved at its own terminal prompt.
So changing the command behind a familiar tool list does not inherit the approval you already gave. A server that presents the same descriptors as one you trust is still a server you have not approved.
After approval (both directions, in the credential lane)
With the snapshot approved, the mediator inspects messages in both directions and withholds one whose content the engine blocks, returning the reason code, the rule, the bytes inspected and the rule ids that fired:
- A credential in a tool RESULT is withheld from your client. The
-APIrules are the tool-result channel. A tool result is theapi_responsechannel: from the shell,sunglasses scan --channel api_responseruns those rules, and--channel tool_outputruns none of the credential rules. A test pins this wording and the three facts behind it (see the CHANGELOG entry). GLS-SD-010is line-anchored, and that is a limit in every channel. It matches an assignment at the start of a line, so aKEY=valuethat is indented by any whitespace, or sits inside a JSON string, or sits behind a quote, is not matched on any channel, not in a tool result, and not in a file or a message either, which are channels it does declare. Indentation alone is enough, which makes this wider than it sounds: a config block, a YAML mapping or an indented snippet all miss. Note the assignment's POSITION is what matters and not the quoting of its value.PASSWORD="hunter2"at a line start is matched,"PASSWORD=hunter2"is not. When the value is in a known credential format theGLS-SD-001family still catches it everywhere (GLS-SD-001on file, message and web content,GLS-SD-001-APIin a tool result), so what is actually uncovered is an assignment whose value has no recognisable shape (a password, a DSN, an internal token) once it is embedded. Closing it needs a different anchor, which is a new rule with its own fixtures rather than a channel added to this one.- A credential in a tool CALL does not reach the server. Verified by reading the receiving server's own input, not by asking the proxy.
- Ordinary traffic passes.
tools/listreturns the real list and a benign call returns its real result.
What this is not
This is the credential lane on tool results and tool calls. It is not general inspection of everything a tool returns, and no comparison with any other tool is claimed. See Not claimed in this release in CHANGELOG.md.
Four exit statuses, deliberately different signals:
0 and 3 never collapse into each other. "Everything I support reading here was read,
and nothing matched" and "this format was not inspected" are different facts, and the second
one is where agents get hurt. Exit 0 is not a guarantee that a file is safe, only that the
supported scope was covered and no pattern fired. In JSON the same split is explicit:
is_clean is not threat_found and inspection_complete.
Try the proxy in two minutes
You do not need an MCP client for this. The package ships a tiny server,
sunglasses/proxy/echo_server.py, bundled for the proxy self test; it works as a sample server, it is not a supported surface.
It has one tool, echo. Run all
of it from one empty folder, with the python3 that has SUNGLASSES installed.
Nothing outside that folder and ~/.sunglasses/ is touched.
1. Wrap it. install needs an existing .mcp.json to edit, so write one first.
2. Be the client. A client speaks newline delimited JSON-RPC on stdin. Put five lines in a file, an initialize, its notification, a tool list, a harmless call and a call that carries an AWS example key.
The proxy answers the initialize, then refuses ids 2, 3 and 4 with code
-32070, SUNGLASSES_WITHHELD and reason APPROVAL_REQUIRED. That is the
gate working, and it is the expected first result. Each refusal carries two
values in its data object, server_id and snapshot_sha256. Read them from
your own output. The server_id is computed from the command, its arguments and
the folder you ran it in, so yours will differ from anyone else's, and a
different folder gives a different one. Approval is stored against that id, so
stay in the same folder for the rest of this page.
3. Approve it as a person. This has to run at a real terminal. It is refused when its input is a pipe.
Both spellings run the same code. sunglasses --help lists proxy.
4. Run the same file again.
The tool list comes back and the harmless call returns its echo. The call
carrying the key is withheld with PROHIBITED_SECRET and never reaches the
server. A credential in a result is withheld the same way with
PROHIBITED_CONTENT. The checks behind this are the credential lane described
under What the proxy enforces, and this run is not
evidence beyond that lane.
5. Put it back. sunglasses uninstall echo restores your .mcp.json
byte for byte. The approval is kept on purpose, so it applies again if you wrap
the same command from the same folder. To revoke every approval on this machine,
remove the approvals folder.
What is SUNGLASSES?
Most AI agent attacks don't look like attacks. They hide inside normal-looking content (emails, web pages, images, audio, PDFs, QR codes) and try to hijack your agent's behavior.
SUNGLASSES is a free, open-source input inspection layer. It does not sit invisibly in front of your agent and sanitise everything it reads (nothing does). It gives you three surfaces you invoke deliberately:
sunglasses scan, inspect a file, a repo or a string on demand, in CI or at the terminal. Reports what it found and what it could not read.- The Claude Code firewall hook, inspects tool calls before they run and can block them. It is best-effort under load: the hook has a 10-second timeout, and a timed-out hook does not block the call (see
KNOWN_VERSION_GAPS.md). - The MCP server, exposes scanning to an agent as a tool it can call.
It flags; it does not silently strip. Content it cannot inspect (an archive, an image whose OCR is unavailable, a file over the size cap) is reported as not inspected, never as clean. Inside opened files, coverage is per format; see the PDF note.
What it scans:
- Text: emails, messages, files, APIs, web content, logs
- Images: OCR visible text, EXIF metadata, hidden text regions
- Audio: speech-to-text transcription, audio metadata tags
- Video: subtitle tracks, audio transcription, video metadata
- PDFs: page text, document metadata, annotations
- QR Codes: decode QR codes and barcodes, scan content
What it catches:
- Prompt injection (English-first; dedicated non-English patterns in 13 languages, two patterns each, see Language coverage)
- Credential exfiltration
- Command injection
- Memory poisoning
- Social engineering & authority spoofing
- Unicode evasion, RTL obfuscation, leetspeak, Base64-encoded attacks, homoglyph substitution
What it doesn't do:
- Doesn't touch authentication (OAuth, cookies, tokens, headers)
- Doesn't monitor agent behavior (that's SHIELD, coming later)
- Scans on your machine with no cloud, no API keys and no telemetry. Nothing is sent anywhere unless you pass
scan --report-to, which sends that scan's result to the URL you name (below). Text, image, PDF and QR scanning needs no network. Audio and video scanning downloads the Whisper speech model when a scan needs it and no intact copy is cached, and reuses an intact cached copy after that
Email screening: A real client sends a real email. But their PC is infected, malware injected hidden attack instructions before it left. The sender doesn't know. Without SUNGLASSES, your agent follows the hidden instructions. With SUNGLASSES, scanner.scan_email(body, attachments) returns a scan document (the findings, the three axes, and a named list of anything it could not read) and your code decides whether to pass the mail on, quarantine it or ask a human. Nothing is silently rewritten or stripped: SUNGLASSES flags, you act. An attachment that needs a DEEP scan is reported as not yet inspected rather than counted as clean.
We're Not the Only Ones, And That's OK
Tools like Lakera Guard, LLM Guard, NVIDIA NeMo Guardrails, and Azure Prompt Shields also protect AI agents from prompt injection. They're good at what they do, especially ML-based detection of novel attacks.
We built SUNGLASSES for a different use case: local-only, offline, zero-cost, no LLM needed. Your data stays on your machine unless you run scan --report-to URL, which sends that scan's result to the URL you name (details). No API keys. No cloud calls otherwise, apart from the optional audio extra, which downloads its Whisper model when a scan needs it and no intact copy is cached (details). Works air-gapped (cache the model first if you scan audio or video).
Use SUNGLASSES alone, or use it alongside cloud tools. We even built an adapter system to connect with other security tools in the same pipeline. Security is layers, we're the local foundation layer.
Privacy
This section is about the MCP server, python -m sunglasses.mcp, the process an agent or a Claude plugin starts.
The server reads only the text you pass to scan_text and the file you name in scan_file. The scan runs on your machine. The server opens no network connection and sends nothing anywhere, except that a deep audio or video scan downloads its Whisper model when the scan needs it and no intact copy is cached. It keeps no copy of what it scans. It collects no data, so there is nothing to store, share or retain.
Audio and video are the one exception, and only with an optional extra. If you install sunglasses[audio], sunglasses[video] or sunglasses[all] and ask for a DEEP scan, the Whisper library downloads its speech model when the model file is not in Whisper's cache folder (about 145 MB for the default base model, from openaipublic.azureedge.net). Before that download starts, Sunglasses tries to print fetching whisper model base from openaipublic.azureedge.net to stderr; the notice is best effort and is missing when stderr is unavailable or the model load takes a compatibility path. An intact model file in the cache folder is reused with no download, and with no notice unless the model load takes a compatibility path. A cached file that is damaged or incomplete is downloaded again by Whisper, without that notice. The notice is made by Sunglasses when it loads the model. Code of your own that calls Whisper directly is not coordinated with it: during a Sunglasses model load, such a call can be announced under Sunglasses's model name. A video scan also writes the sound track or subtitle track to a temporary file on your machine and deletes it once the track is read. What you scan still stays on your machine. Without those extras nothing is downloaded.
What the server sends back goes to your MCP client. A response can quote the matched part of your input. That quote can include a string that looks like a credential. Treat scan results as sensitive content.
The proxy is a separate process with its own state. It keeps the approvals you grant and its capture files on your machine, as What the proxy enforces describes.
Quick Start
Framework references on every finding
Each finding in --json output carries a taxonomy object listing where its category sits in the
OWASP Top 10 for LLM Applications (2025), MITRE ATLAS (2026.09) and NIST AI RMF / AI 600-1:
owasp_llm_top10, mitre_atlas and nist_ai, each a list of {"id", "name"}. SARIF output lists
the same entries under the run's taxonomies, and each rule points at them through relationships.
The mapping is by category, so it says which headings an attack of that kind is discussed under, not
that one finding is a confirmed instance of a technique. The full table is in
attack-db/TAXONOMY.md, with the source and version of each list.
Exit codes
Every scan exits through one contract, on every path (text, file, repo, deep
scan, and errors). 0 is a claim, so it is reserved for scans that earned it.
Precedence is 1 > 3 > 2 > 0: a threat we did find outranks the part we could
not read, and both outrank a usage complaint.
The distinction between 0 and 3 is the whole point. "I read the file and it
is clean" and "I could not open it and therefore saw nothing" must never be the
same signal to a CI job. In JSON output the same split is explicit as
threat_found, inspection_complete and is_clean (which is both), alongside
truncated and extraction_complete.
A path-shaped argument that does not exist is a usage error, not text. Pass
--text if you really do mean to scan the string ./typo.txt itself.
Scanning a whole directory or repository
scan --dir reads a local directory the way --repo reads a clone: the same walker, the
same skips, the same exit codes (a local tree can hold files a clone does not, such as
untracked ones). It does not change anything in the tree. One progress line per file
([3/120], the time spent, the file) goes to stderr, so -o json and -o sarif still
leave stdout as one document.
The JSON summary of scan --dir and --repo carries two counts, with different jobs.
total_threats is the number of findings the scan reports for the files it read, counted the
way --file counts them (its findings_count); a file the scan skips is not in it. raw_findings is the number of pattern fires before that, the sum of the
per-file threats in file_results; it can be higher. Show a user total_threats. Each
directory scan also gets an event_id, a fresh one per run, like a file scan.
Files the scan does not read are listed in skipped with the reason, and the scan is then
incomplete (exit 3, inspection_complete: false; a threat found elsewhere in the tree still
exits 1, which outranks 3): files over 1 MB, binary types, symbolic links, a directory that
cannot be listed, a folder found swapped for another folder or a link found where a file or folder
was before the scan opened or listed it (see below for what a change after that does), and every
file past --max-files N.
--skip-lockfiles adds package-lock.json, yarn.lock, pnpm-lock.yaml, poetry.lock,
Cargo.lock and go.sum (matched by exact file name) to that list; without it they are
scanned.
A file the scanner cannot finish within --file-deadline SECONDS (default 120, 0 = no
deadline) is reported in partially_read with the reason and the scan moves on. That file is
not called clean: the scan is incomplete (exit 3), unless a threat is found elsewhere (exit
1). The flag also applies to scan --file, where there is one file and the reason is in the
result's extraction_warnings. A --deep scan of an audio or video file has no process to
stop, so it refuses an explicit deadline with exit 2; a value that is not a number of seconds,
0 or more, is refused with exit 2 for any file. If the scanner cannot be started before it has
answered for any file (the engine does not load, or the process cannot be created, exits, or never
says it is ready), the command stops with exit 2 and one error line, scanner could not start: <reason>, and prints no report, banner or progress; with -o json or -o sarif the error is the
usual single refusal document. The reason is <type>: <message> when the engine or the process
says why, and a plain description when the process ended without saying. For a directory or a
repository the command is refused only when no scanner has answered for any file handed out so
far, and with --jobs an answer already received from another process for a file behind the
first counts; a repository scan has said that it is cloning before it is refused. A scanner that
cannot start once any has answered, such as the replacement for a process stopped at the deadline,
is not a refusal: that file is listed as not inspected, the scan goes on, and the exit code
follows the precedence above (a finding exits 1, otherwise 3). The scan runs in a separate process for a
file or a tree read with a deadline on a platform that can stop one; text, standard input,
--file-deadline 0 and an audio or video file read without --deep build the engine in this
process, and --deep builds its own, and all of them end the same way. A process that was ready and
then fails on a file is different: a reply counts only when it is a complete scan result or one
of the process's three error reports (a file that cannot be read, an operating-system error, a
fault), each with all of its fields; any other reply is treated as a failed process, which is
stopped, and the file is reported as not read (exit 3) and never as clean.
--jobs N reads up to N files at once with --dir and --repo (a whole number from 1 to 64;
default 1, one file at a time). When the tree does not change and every file is read to the end
in both runs, the report holds the same files, findings, lists and exit code as --jobs 1, in the
order the walk lists them. A file that reaches --file-deadline is reported as not read, which makes the
scan incomplete (exit 3, or exit 1 when a finding elsewhere reaches your --fail-on level, which at the default is any finding), and whether a file does
can differ between runs with different N, so the two reports can differ for that file. The timing fields differ from run to run, and so does the event id in SARIF output,
as they do between two runs with one file at a time. Each file has the whole --file-deadline to
itself. A progress line is printed when a file is
handed out, so progress lines can run ahead of the results they belong to; each stream keeps the
walk order. --jobs 1 reads one file at a time. --jobs is refused with exit 2 for a single
file or for text, and so is a value that is not a whole number from 1 to 64. The value is written
in plain ASCII digits, at most 16 characters once surrounding spaces are dropped, so 04 is
accepted and a longer run of zeros is refused even when its number is in range; a sign, a decimal
point and digits from other scripts are refused. The pool needs a file
deadline above 0 and a platform that can stop a process group; without them the files are read one
at a time, and a --jobs above 1 prints a note saying so.
A link is never followed, because it can point outside the tree: a link is named as skipped
whatever it is called (a link named node_modules included), and --dir refuses a path that is
itself a link, naming the real directory to use instead (the parent folders of the path as you
typed it are resolved by the operating system as usual). The scan opens the root folder once,
refusing a link, and keeps that one handle for the whole run; every folder and file below it is
opened relative to the step before, refusing a link at every step, and the scan checks each
folder's identity (device and inode) when it opens it and when it lists it, so a folder that was
swapped for another one, or for a link, before one of those steps is named as changed and not
read. A folder replaced after the scan has already opened it is still the one the scan holds, and
the scan judges what it read from that handle; it does not report the later change. A regular file
replaced by another regular file is not a detected change either: the scan judges the bytes it
actually opened. Each file (at
most 1 MB) is read once through those handles, and the scan judges exactly those bytes: they are
held in memory and go straight to content routing and extraction, so the scan writes no copy of
any file, anywhere (no temporary file or folder, nothing inside the scanned folder; the PDF,
image and QR extractors are imported before the folder is opened, so none is first loaded in the
middle of the walk), and no file's content is opened
a second time by routing or extraction (the folders on the way down are opened again to list
them and to read the next file). The findings carry the file's path in the tree. A file that
changes after it was read is judged as it was when it was read. With --jobs 1 the previous
file's bytes are let go before the next file is read; with a larger N a file read ahead is held only until its scan
has finished, and the results of finished files are kept until they are written. The 1 MB cap bounds each read; it is not a limit on the
process's total memory. One caveat outside the scan itself: reading text out of an image with
the optional OCR extra runs the OCR library, which makes its own short-lived temporary file in
your TMPDIR, as --file does for the same image; if you use that extra, keep TMPDIR outside
a folder you scan. Two limits of the same kind: if the folder you scan holds sunglasses' own source
and you start the scan with python -m sunglasses, Python can cache sunglasses' own modules there
at start-up, before any scanning code runs; and an image or PDF library that is first loaded
during the scan and lives inside the folder you scan can be cached there by Python. Set
PYTHONDONTWRITEBYTECODE=1 for either case. If the scan is interrupted (Ctrl-C) the process
exits and the operating system closes its folder handle; a program that calls the scan as a
library and swallows that interrupt keeps the handle open until it exits.
Where the platform has no such handles (Windows) the scan checks for a link and for a path that
leaves the tree before it reads and again against the file it opened. That is a weaker guarantee
against a tree that changes while the scan runs, and it has not been exercised on Windows.
In a --repo scan a link inside the clone is a named skip, so a repository that holds one and
used to come back clean (exit 0) now exits 3.
Dependency and build folders (node_modules, venv, dist, build and the like) are not
entered. Their files are neither scanned nor listed one by one; the folders are named in
excluded_folders (and under excludedFolders in SARIF; the human report shows the first ten)
and are not counted as skipped files. SARIF notInspected lists every file in skipped.
The files that were read, the results and the skipped list are sorted by path (the order the
folders are visited in is not part of that promise). File names shown to a person (progress lines, results, the banner)
have control characters escaped. A path that is not a directory is refused with exit 2, and so
is --dir together with --file (even an empty value), --text, --stdin or text to scan.
--file still refuses a directory: scanning a tree is a different claim from scanning one file,
and --dir is where it is made.
Failing CI only on what is new: --fail-on and --baseline
These four options change when a scan fails CI. They do not remove a finding from any report.
--fail-on LEVEL:any(the default: every finding exits1),low,medium,high,criticalornone(report only: exit1never comes from a finding, critical ones included).--write-baseline FILE: list the findings of this scan, then judge the scan against that list.--baseline FILE: findings the file lists are known and do not fail the scan.--accept-critical GLS-XX-000: with--baseline, let the baseline cover critical findings of that rule (repeat for more rules).
Findings below the --fail-on level are still printed and still in the JSON and SARIF. In the JSON of
a --dir or --repo scan each new finding under the level carries below_fail_on (a finding the
baseline lists is marked known instead). For --file, --text and
stdin the JSON carries fail_on and marks each finding under the level with below_fail_on; the
human screen prints a Fails on: line. SARIF output keeps every finding and does not mark the ones below the level.
A level given explicitly (even --fail-on any) turns the extra JSON keys on; a scan without these
options produces the same documents as before.
--baseline and --write-baseline work with --dir and --repo. A finding is known when the file
lists the same rule, in the same path under the scan root, for the same file content (a sha256). Edit
the file and the entry stops matching, so the finding is new again; move it and it is new again. Paths
are matched exactly as written: forward slashes, relative to the scan root, no . or .. parts, and
the baseline records every finding the JSON and SARIF documents list (one entry per rule, path and
content). Known findings are not hidden: the human report prints KNOWN, the JSON carries
"known": true plus known_findings, new_findings and baseline_stale (listed entries nothing
matched this run), and SARIF marks them with an external suppression.
A baseline never covers a critical finding on its own. To accept one, name its rule on the command
line, where it shows up in the CI config. That protection belongs to the baseline: --fail-on none
is a different statement ("report only"), and with it a critical finding does not fail the scan
whether or not a baseline exists.
A baseline file is a regular file of at most 16 MiB, with "schema": 1 (an integer), and entries made
of an id, a file path and a 64-character lowercase hex file_sha256. A baseline written inside
the scanned folder is scanned on the next run like any other file, so a name in it that matches a rule
can produce a finding in the baseline itself; keep it outside the scan root, or exclude it. Writing a
baseline over a file that is part of the scan replaces that file, so point --write-baseline at a path
nothing else uses. The destination must be an ordinary file (or a new one) in a writable folder, never
a symbolic link or a directory; it is checked before the scan starts and again just before the file is
moved into place, and the file is written beside it first. A baseline is only written if the same
scanner can read it back: a file name it cannot record (one with a backslash, say) or more entries than
fit in 16 MiB refuses the write: nothing is written, an existing file at the destination is left as it
was, and the scan's report is still printed in full with the refusal named in it (baseline_write_error
in JSON, a run property in SARIF, a Baseline NOT written line on the screen). The findings are then
judged as new, since nothing was recorded, and the exit follows the usual precedence: 1 for a finding
at the level, 3 for part of the input not read, otherwise 2.
Neither option touches coverage. Exit 3 (part of the input was not read) stays 3 under every
level and with any baseline, including --fail-on none. Anything wrong with the options at the start is exit 2
and nothing is scanned: a baseline that cannot be read, is not a regular file, is too large or is
malformed; an empty value for --baseline, --write-baseline or --accept-critical; a write
destination that is a symbolic link, a directory or in a folder that cannot be written;
--baseline or --write-baseline without --dir or --repo; both of them together;
--accept-critical without --baseline; and any of these options (--fail-on any included)
with --deep.
A write that fails after the scan (a destination that became unusable meanwhile, or a name or size the
baseline cannot record) is not a start-up refusal: the report is printed, no baseline is written, and the
exit is 1, 3 or 2 in that order of precedence, as described above.
Sending a scan to a server you name: --report-to
scan --report-to URL posts the finished scan, once, to a server you run or trust. It is off
unless you give it. It runs after the scan has printed its output and decided its exit code, and
it changes neither: a failed upload is one line on stderr, starting sunglasses: --report-to:, and
the exit code stays what the scan made it. A run that ends without a scan result (a refusal, or an
audio or video file) sends nothing and says so in one line. It works with --file, --text,
--stdin, --dir and --repo.
The URL must be https://; plain http:// is accepted only for this machine (localhost or a
loopback address), and a redirect is not followed. The token is read from the environment
variable SUNGLASSES_REPORT_TOKEN and sent as Authorization: Bearer ... to that URL only. There
is no flag for it, so it stays out of your shell history and process list. Without the variable,
nothing is sent.
What is sent is one JSON document, built from a fixed list of fields: the scan's counts and
flags, its file paths (as the scan output shows them: for a directory scan that includes the folder's own path, in source), and for each finding its rule id, severity, category, line and the
SHA-256 and length of the text it matched; plus the scanner version, the pattern count and a name
for the source (--report-source NAME; by default the directory or repository name). Every other
field of the scan's --json result (a rule's name, its explanation, the other rules that matched,
framework mappings, warning text) is left out, and a field the scan
result gains later is not sent until it is added to that list. --report-include-text sends the
matched text itself instead of its hash. line is the line where the matched text first appears in
the file as read, or null when it cannot be located (a scan of text, a format that is extracted
before it is scanned, a file too large to read again); the file is read again after the scan, so a
file that changed in between can give a stale line. --report-include-receipts adds the newest
segment of your signed hook log, from its first line, with the public key that signs it; without it
receipts is null. If the log pushes the report over the size limit it is left out and the line
says so.
The upload is attempted once and given up on after 15 seconds in all (name lookup, connect, send
and the answer together), whatever the server does; the scan's output and exit code are already
decided by then. The command stops waiting at that point and exits; a report that is already on
its way can still reach the server, because the attempt is abandoned, not recalled. A report over 1 MiB, or with more than 5,000 findings, is not sent. The size is
checked after the report is built, so building a very large scan's report uses memory in proportion
to the scan. While the scan runs, SUNGLASSES_REPORT_TOKEN is out of the command's own environment and out of
the environment it gives the programs it starts (the scan worker, git for --repo, ffmpeg for
--deep), so they are not handed a copy; the upload uses the token the scan began with. This
controls what those programs inherit. It is not isolation inside the process (the token is still
held by the command itself), and a scan worker that was already running before the scan began
keeps the environment it started with. The command supports one scan at a time per process; a
second --report-to scan started in the same process while one is open is refused (exit 2,
nothing scanned) without touching the token, and a scan without the flag does not take part.
The token is put back when the scan ends by returning, exiting or failing; a Ctrl-C that lands
while it is being put back is held until that is done and then raised, but two interrupts that land
one right after the other can still defeat it. The git and worker environments are stripped of the
token with or without --report-to.
Deep Scan Setup (Audio & Video)
Deep scan transcribes audio to text using Whisper, then scans the transcript for attacks. Two extra steps:
A deep scan of audio or video downloads the Whisper speech model (about 145 MB for the default base model) when the model file is not in Whisper's cache folder, and tries to print fetching whisper model base from openaipublic.azureedge.net to stderr first. When the installed Whisper does not expose its download hook, the line it tries to print before each load is loading whisper model base; Whisper may download it first if it is not already cached instead. Later scans reuse an intact cached file. To get the model before you need it offline, run one deep scan while you are online. If stderr is not available, or writing the notice fails with an OSError or ValueError, the model still loads and the notice is not printed anywhere else.
SUNGLASSES auto-detects file types. If you try to scan audio/video without --deep, it tells you what to do instead of crashing.
Input size cap. engine.scan() reads at most 1 MB by default. On ordinary prose scan cost is roughly linear in input length (~50 µs/byte), but not on every input shape: the matcher is quadratic on a single unbroken token, so a long token can cost far more than its length suggests (measured curve and consequences in KNOWN_VERSION_GAPS.md). Even at the linear rate, 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. A scan that hit the cap says so: result.truncated is True and result.bytes_scanned reports what was actually read, in the human output and in --json. Change it with SunglassesEngine(max_scan_bytes=N), or pass 0 to disable it.
Exit codes. 0 = read the whole input and found nothing that fails the scan (with --fail-on or --baseline, findings below the level or listed in the baseline are still reported). 1 = threat found (with --fail-on or --baseline, a finding below the level or listed in the baseline does not exit 1; see above). 3 = part of the input could not be read (a PDF text layer without sunglasses[media], say) and nothing was found in the rest. 3 exists because 0 is a claim: "I read it and it is clean" and "I could not open it and saw nothing" must not be the same signal to a CI job.
Integration
Connect the MCP server
The root mcp.json starts the server with command set to python3 and args set to ["-m", "sunglasses.mcp"]. A desktop client may not use the same PATH as your terminal, so set command to the absolute path of the Python that has Sunglasses installed. In a virtual environment that is /absolute/path/to/.venv/bin/python on macOS and Linux. On Windows it is the environment's Scripts\python.exe. Keep args as it is.
To check the connection, confirm that your client lists scan_text, scan_file and scanner_info. Call scanner_info with {}. Then call scan_text with {"text": "The team lunch is at noon on Friday."}. The answer has isError false. Its result reads "decision": "allow", "inspection_complete": true and "is_clean": true. scan_file takes an absolute path on the machine that runs the server.
The MCP server returns its decision to the caller and stops nothing itself. A decision of block, quarantine or allow_redacted is an answer to your client. The tool does not hold back or redact anything downstream. isError false means the call completed. It does not mean the input is clean, so read is_clean and inspection_complete as well.
Scan Images, Audio, Video, PDFs, QR Codes
Two Speed Modes
Performance
The Patterns count is the rules that carry a regex or a keyword matcher. The ruleset lists 1,584; the other 71 carry neither. Carrying a matcher is not proof that a rule fires. engine.info() reports both numbers (patterns_with_matcher and listed_patterns), and sunglasses info prints the difference.
Performance numbers are regenerated by tools/gen_perf_stats.py against a public in-repo corpus (no network, no randomness) and written to stats/current.json with the machine and timestamp they were measured on. Reproduce with python3 tools/gen_perf_stats.py. Last measured 2026-08-30. Your hardware will differ.
Benchmark, the receipts
Most scanners publish a pattern count. We publish precision and recall, with the command to reproduce them:
That command measures the source tree you just cloned. To measure the package a user gets from PyPI, install it and add one flag. The dataset still comes from the clone:
Both runs print which engine they measured. --installed refuses to run when the importable package is the checkout itself, for example an editable install, so a source tree run cannot be reported as an installed one.
Labeled dataset shipped in this repo: 38 real agent-input attacks (positives) + 77 famous open-source READMEs (react, kubernetes, numpy, ollama…) that must stay clean (negatives). No randomness, no network, no LLM judge (same clone + same command → byte-identical results, sealed by a SHA-256 of the metrics block).
The known gap, stated out loud: the one miss is curl … | bash. Seven of the 77 clean READMEs (deno, ollama, grype, ohmyzsh…) ship that exact install line, no text-level rule separates the legitimate one from the malicious one, so flagging it would buy 1 catch at the cost of 7 false positives. It belongs to a runtime control, not a text scanner, and a test asserts we do not flag it. If a scanner claims to catch it from text alone, ask what their false-positive rate on real READMEs is.
Language coverage (measured)
SUNGLASSES is English-first. This section used to say "23 languages", which counted every
language mentioned anywhere in the ruleset as if it were covered. Here is what is actually in the
shipped patterns, counted from sunglasses/patterns.py:
So a two-pattern seed is not language coverage, and you should not deploy SUNGLASSES expecting
non-English parity with English. Normalization runs before matching and is language-independent.
Obfuscation coverage is tested per technique and is not complete; see KNOWN_VERSION_GAPS.md.
Deepening this is a v0.6+ lane with per-language controls and per-language false-positive corpora
(a language you cannot measure separately is a language you cannot honestly claim). Community
language contributions welcome; see KNOWN_VERSION_GAPS.md for the measured detail.
What Works Today
- ✅ Text scanning: 1,513 patterns that carry a matcher (1,584 listed), 6,675 indexed keywords (6,964 declared), 117 listed categories (English-first, see Language coverage)
- ✅ Mechanism layer: 11 shape-based rules that match an attack's structure rather than its wording (e.g. something sensitive + somewhere to send it), how well that generalises to unseen paraphrases is measured, not asserted: see Benchmark
- ✅ Structural validators: a rule that needs a parse instead of a pattern runs as a validator (
sunglasses/validators.py), counted apart from the pattern count. The first,GLS-STRUCT-001, reads a JSON hooks block and the words of each hook command, and flags a command that reads as downloading code and handing it to a shell or interpreter, judged from its words (it is not run). It does not model code that never runs (functions, conditions), other downloaders, interpreter-native fetches, array-form commands, or hook formats that are not JSON.GLS-STRUCT-004reads a Claude Code settings file as one JSON document and reports, as a notice only, the permission prompts switched off, an allow list that lets every shell command run, or an API base URL at a public IP address or a tunnel host; the host is judged by the address parser, and a settings object inside prose, a schema or another object is not read.GLS-STRUCT-005reads Python (a whole Python text, or each fenced python block of a document) and reports, for review, code that prints, logs, writes or sends every environment variable with its value; one named variable, names only, a filtered loop, and shell or Node text are not reported.GLS-STRUCT-006reads the words of a shell script (a whole text, or each fenced shell block) and reports, for review, aconfig setcommand or a write to a package manager's own config file (.npmrc,.yarnrc,pip.conf,.pypirc) that points npm, pnpm, yarn or pip at a registry that is not a public one; the host is parsed from the URL, and a mirror a team really uses is reported the same way as a redirect, since a script cannot be told apart from its documentation. The--registryflag of one install, environment variables, config files shown alone, and Python or Node code that writes the file are not read - ✅ Browser demo at https://sunglasses.dev/scan: text, GitHub repos, and images (client-side OCR)
- ✅ Negation handling. "Do NOT run rm -rf / --no-preserve-root" is flagged as review. "now run rm -rf / --no-preserve-root" is blocked as critical.
- ✅ Multi-stage pipeline: normalization (16 techniques) → pattern match → decision
- ✅ Image scanning: OCR + EXIF metadata + hidden text detection (requires Tesseract)
- ✅ PDF scanning: page text, metadata and annotations. PDF results are partial for embedded or visual content; do not treat a clean PDF result as full assurance.
- ✅ QR code scanning: decode and scan content (requires pyzbar)
- ✅ Audio scanning: Whisper transcription → text scan (experimental, needs
--deep, requires Whisper) - ✅ Video scanning: subtitle extraction + audio transcription → text scan (experimental, requires FFmpeg + Whisper)
- ✅ CLI:
sunglasses scan,sunglasses check,sunglasses demo,sunglasses info,sunglasses report - ✅ Python API:
SunglassesEnginefor text,SunglassesScannerfor media - ✅ LangChain + CrewAI integrations
- ✅ MCP scanning server. Run
python -m sunglasses.mcpin the Python environment where Sunglasses is installed. It speaks over stdio and exposesscan_text,scan_fileandscanner_info, which your client calls explicitly. The rootmcp.jsonholds the client configuration. Connect the MCP server shows how to check it - ✅ SARIF 2.1.0 output for CI integration
- ✅ Measured on a shipped dataset, recall 97.4% (37/38) and precision 86.1%, reproduce with
python3 tests/benchmark/precision_recall.py - ✅ Local scanning with zero telemetry. Only audio and video need a download, the Whisper model on first use
- ✅ Daily protection report (local HTML), covers scans made through the Python API's
ProtectedEngine; CLI scans are not recorded - ✅ MIT License
The Firewall, from detector to control (v0.4)
Everything above this line reports. The firewall stops. It installs as a
Claude Code PreToolUse hook and answers one question before every tool call (best-effort: the hook runs under a 10-second timeout, and Claude Code lets a timed-out hook's tool call proceed, so on the pathological input shapes described in KNOWN_VERSION_GAPS.md a call can go through unscanned):
does this action violate a fact we can prove?
What runs, and what does not
Two sentences, because the difference matters and vague reassurance is worse than none:
- The static scanner does not execute scanned content. Files, text, images, PDFs and archives are read as data. Nothing in them is run.
sunglasses pinlaunches your configured MCP servers to read their tool lists (that is the only way to learn what a tool descriptor says) and it asks first. It prints the exact command lines it is about to start and waits for you. With no terminal to ask (a timer, aSessionStarthook, CI) it refuses instead of launching, unless you pre-consent with--yesorSUNGLASSES_PIN_CONSENT=1. That consent is read from your environment only, never from a repository, a.env, or project settings, so a scanned project can never authorise the launching of your servers.
Upgrading to v0.5.6: if you wired sunglasses pin --quiet into a timer or a
SessionStart hook, add --yes (or set SUNGLASSES_PIN_CONSENT=1 in that job's
environment). From v0.5.6 an unattended pin without consent refuses with exit 2
and a one-line notice on stderr instead of starting your servers. Nothing in
sunglasses init creates those jobs (it wires the firewall hook and nothing
else), so if you have one, you wrote it, and it is yours to update.
Upgrading to v0.6.16: sunglasses pin and sunglasses pin --check end with exit 3 when a config file
could not be read and nothing else failed. Drift under --check still ends 1, and a refused consent ends 2.
Without --quiet the file is named on stdout with its reason; servers declared in it are not read. v0.6.15
ended 0 there, so a job that tests for exit 0 should expect 3.
Also new in v0.5.6: a single positional argument that looks like a path and does
not exist is a usage error (exit 2) rather than text to scan. sunglasses scan ./missing.txt used to scan the 15-character string and report a clean pass.
If you meant the string, use --text.
The one rule it will not bend
That split is enforced by tests, not by good intentions: the WARN lane is swept
across every keyword-bearing pattern in the database and asserted to only ever
return ask, including at critical, where the enforcement mapping would have
said "block".
What it blocks
- Secrets leaving. AWS, GitHub, Anthropic, OpenAI, Slack, Google, Stripe,
PEM private keys and signed JWTs, matched by exact format, only on tool calls
that can actually put bytes on a wire.
$TOKEN,<YOUR_KEY>andsk-ant-REPLACE_MEare not secrets and are never treated as such. - Tool-descriptor rug-pulls.
sunglasses pinrecords what each MCP tool said when you approved it;sunglasses pin --checktells you if it changed. - Your own policy.
~/.sunglasses/policy.yaml:
sunglasses init asks whether to enable a recommended set of credential-path
blocks (the private key files, ~/.aws, ~/.config/gcloud, ~/.netrc and
friends). Say yes and cat ~/.ssh/id_rsa | curl -d @- and
curl -d @~/.aws/credentials stop working: the shapes that carry no key in the
command text, and so are invisible to the secret detector above. Say no, or run
--no-policy, and no path or host rule is enforced. A non-interactive install
(CI, a Dockerfile, | sh) writes the same rules commented out (silence is never
read as consent, and a fresh install still adds no path or host rule you did not ask for).
After either answer sunglasses init prints one line with the state of the policy
file and its path, and sunglasses receipts marks each call whose receipt records
that it ran with no policy or with one that lists nothing enabled, with the state the
hook wrote.
~/.ssh as a whole directory is deliberately not in that list: it would block
ssh-copy-id, ~/.ssh/config and known_hosts, which is ordinary work. The
private key files are named individually and matching is boundary-aware, so
id_rsa.pub is untouched.
Honest limits
-
Descriptor pinning is not live.
PreToolUsedoes not hand a hook the tool descriptor, and fetching one would mean a network round-trip on every tool call. So the hook can only see whether a tool is pinned; a description swapped between twopinruns is caught bypin --check, not in the act. Closing that window needs a resident process (that is v0.5, not this). -
It sees the tool call, not the file behind it. The scan reads
tool_input, so a command that makes the shell fetch the secret (curl --data-binary @.env,cat .env | curl -d @-) carries no credential material in the text we are handed, and is not blocked. Verified, not theoretical. Closing it means either resolving file references at hook time or watching the process itself; both are v0.5 work, and claiming coverage we do not have would be worse than the gap. -
It reads the call as text, so an interpreter or an indirection hides the channel. The egress check recognises network commands (
curl,wget,ssh, the web tools). A one-liner that opens the socket itself (python3 -c "…socket…",node -e "…https.request…",bash's/dev/tcp) carries the credential in plain sight and still defers, because nothing in the text looks like sending. The mirror case is material that is present but unreadable (base64, an env var, a file reference) where we can see the channel and not the secret. Both are the same limit from two sides: this is a text control on one tool call, not a runtime one. Widening it to "sensitive material anywhere near a command" was measured and rejected, it fires onaws configure setand ordinary credential setup, and a guard that shoots healthy work gets uninstalled. Resolving it properly needs the resident process in v0.5. Do not read the two fixes in 0.4.2 as closing this. -
A Bash command that only NAMES a protected path is still denied. If your policy lists a path under
blocked_paths, writing documentation about that path through a shell heredoc is refused exactly like writing to it. The file tools were repaired in 0.5.7 and read their documented path fields, soWriteandEdittreat their content as data. Bash was not, and that is deliberate. 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 actually runs, including a quoted heredoc piped intobash, an apparent opener inside a comment or inside an arithmetic shift and a delimiter word longer than the token matched. Judging the whole command costs a false positive on prose. Guessing at the structure cost real deletions, so a Bash command is judged on all of its text until a real grammar exists. A test asserts this limit is still here and it is what fails when the lane is repaired. -
The WARN lane is off by default, and the reasons are measurements, not taste: 1 of 39 ordinary tool calls escalates (a plain
curl -s pypi.orgreads as a dangerous shell command), and it costs ~902ms per call because the pattern database is rebuilt in every hook subprocess. Enable withtouch ~/.sunglasses/warn-laneif you want it anyway. -
On older Pythons one regex can outrun the WARN lane clock. The 7 second budget is a timer signal, and before Python 3.11.6 and 3.12.1 the regex engine only notices a signal inside one long match attempt. A single search that makes many short attempts over a long input therefore runs to its end first. The fix lives in CPython itself (issue 109631). If you turn the WARN lane on, use Python 3.11.6, 3.12.1 or newer. macOS ships 3.9 as /usr/bin/python3.
-
Large content passed as tool input can use up the WARN lane clock on any Python. On content of about 200 KB and up (a big file written in one call or a large pasted payload) the 7 seconds add up over hundreds of pattern checks plus building the pattern database on the first call, with no single slow pattern to blame. When the clock runs out the lane stops and asks you, and that call reaches you without a completed pattern check. Hard blocks do not use this clock and are unaffected.
-
It fails open, and says so. A crash falls through to Claude Code's own permission flow rather than wedging your agent. A dead control is different: if the policy file is missing, empty, unreadable or unparseable, or if the audit trail cannot be written, the firewall now ASKS and names which control is down, and unreadable includes the shapes that are not a file you can read at all: a FIFO, socket, device node or directory in that path is answered from metadata before anything opens it, because a FIFO with no writer blocks in the kernel and a blocked hook is timed out by the harness and fails open. A NUL byte anywhere in the policy counts as unparseable, comments included: YAML will happily keep one inside a value, and a path with a NUL in it silently matches nothing, which is the one answer indistinguishable from a clean scan. because an empty answer on the wire is indistinguishable from "checked, nothing found". A missing policy only counts as dead where one was installed; a machine that never configured one is not nagged. Those write a receipt saying the call was not checked, because a firewall that is quietly off is worse than no firewall, but the receipt is conditional on reaching the write with working storage, and two cases do not get one. The audit-trail state is itself the case where the trail cannot be written, so it ASKS and records nothing. A DENY under obstructed storage is enforced and may fail to record. "Every such event writes a receipt" would be false in exactly the states this section is about, so it is not claimed.
The precedence, so an unrecorded event is not read as an unchecked one. A later lane still decides: a dead policy does not short-circuit the rest of the call, and its failure rides along in whatever receipt that call produces. An audit-trail failure never weakens a DENY (the block is enforced whether or not it can be written down).
One failure cannot write that receipt at all: if the harness kills the hook on its timeout, nothing runs to write anything. So an
in_flightrecord is appended before the check begins, and the decision record references it. An opening record with no terminal partner is named bysunglasses receipts --verify, which exits non-zero. What that proves is that the pair is incomplete, and no more: the evaluation may still be running, the hook may have been killed, or the decision may have been made and enforced with only the terminal write failing. The record cannot tell those apart and does not pretend to. It does not make the hook fail closed, which is the harness's contract rather than ours, and it does not establish that the tool call ran. It makes the gap visible instead of silent.One more failure has its own answer: the secret check itself raising an exception on an outbound call. If that call also looks credential-shaped (a known key prefix followed by a long token, or a private-key line), the hook asks instead of passing it through, and the question says the secret check was down. With the default setting, any other call hit by that fault passes through when its receipt can be written, with the failure in the receipt; when the receipt cannot be written the hook asks in place of the pass-through. A deny stays a deny, and a block stays a failed hook. Not measured: what the host does with that question in an unattended run, when its permissions are turned off, or when an allow rule already covers the call.
sunglasses init --on-error {ask,deny,defer,block}writes~/.sunglasses/on-errorto change that:denyrefuses without asking,deferkeeps the quiet pass-through, andblockends the hook as a failed hook on every secret-check fault, even when the receipt cannot be written.Both records depend on the write succeeding. A full disk, a read-only volume or a kill between the two appends leaves a file that is missing lines or ends mid-line, so
--verifycounts every line it cannot read, prints it with its file and line number, and reports the run as incomplete rather than clean. Receipts are read as bytes and decoded a line at a time, so unreadable lines are located at the line boundary, including a write cut inside a multibyte character, and one damaged line never costs you the rest of the file.
Cost
~27ms per tool call (measured min-of-15 on an M-series Mac; bare Python startup
is 19ms of that). Zero network calls (nothing about your work leaves the
machine). An invocation appends two lines when both writes succeed, one when the check
starts and one when it decides, to ~/.sunglasses/receipts/YYYY-MM-DD.jsonl,
recording a SHA-256 of the tool input and never the input itself. A hook killed
between the two leaves only the first, which is the case these records exist to
make visible, so "every invocation appends two lines" is not a promise this
makes. sunglasses receipts --verify reads each day file into memory whole, so
its cost is memory rather than time. A 50 MB day file peaks near 380 MB of
resident memory, roughly seven times the file, measured on an M series Mac.
Reading it a line at a time instead is a later change, not one this makes.
Roadmap
Next, in progress
- 🔨 Drag-and-drop web UI,
sunglasses uiopens a local browser page to scan files visually - 🔨 URL scanning,
sunglasses scan --url https://example.com - 🔨 Email report delivery, daily reports to your inbox (your own SMTP, we never touch it)
- 🔨
sunglasses update, update pattern database without reinstalling - 🔨 Easy bug report form, non-technical users can report issues
Later, on the horizon
- 🔭 Bridge filter (scan agent-to-agent and file-handoff messages before the receiving agent ingests them)
- 🔭 Output scanning (scan what the agent SAYS back, not just what comes in)
- 🔭 PII detection (auto-detect sensitive data in content)
- 🔭 Public Threat Registry (accountability board for AI agent attacks)
- 🔭 Community pattern submissions (submit attack patterns, grow the defense)
- 🔭 Deeper audio analysis (speaker separation, hidden speech detection)
Community Help Needed
- 🙏 Attack patterns in non-English languages
- 🙏 False positive reports from real-world pipelines
- 🙏 Adversarial bypass attempts (break it and tell us)
- 🙏 Integration examples with other agent frameworks
- 🙏 Audio/video testing with real-world media files
Verify AI Agent Traffic In Your Logs
A user agent is a claim. Anyone can type ChatGPT-User into a request header. We found 2,437 fake AI agent requests in one week of our own logs, probing for AI coding agent credential files (full report).
verify_ai_citations.py checks every claimed AI agent request in your access log against the IP ranges the vendors actually publish (OpenAI, Anthropic, DuckDuckGo, Perplexity). One file, stdlib only, no install:
Output: verified / fake / uncheckable counts per claimed agent, plus the scanner tell (one IP wearing several vendor names). If you report AI citation numbers anywhere, run this first.
Wiring a route: install, uninstall, doctor
install rewrites your config. Read this before you try it.
sunglasses install <name> edits the named entry in your .mcp.json so the
server is launched with python -m sunglasses.proxy, and records the proxy
entry point's path and its sha256 under an x-sunglasses key in that entry,
the module form is what runs, and the recorded file is what it runs, which is
how uninstall and doctor can tell your wrapper from somebody else's. It
exits 0 and says Wrapped '<name>'. sunglasses uninstall <name> reads that
record and puts the original back byte-identical, exiting 0.
Wrapped is not the same as protected. A successful install means the
launch path now goes through us and nothing more: out of the box the wrapped
server enforces nothing, because the proxy's approval gate refuses until a human
has approved that server's tool snapshot at an interactive terminal. What it
inspects once approved is described under What the proxy enforces,
and is measured there rather than inferred from the fact that a wrap succeeded.
Content you route through the CLI, the Claude Code hook or the MCP server is scanned.
That wording is deliberate and it matches the site. A denial that spells out the claim it is denying still puts the claim in the file, and our claim gate matches substrings, so "we do not scan X" and "we scan X" look the same to it. Say what is true instead.
What each one will do
doctor tells you what is wired and runs a live self-test of the proxy. It does
not yet prove that one of your wrapped routes mediates anything, and it says so on
every run. It reads your config, classifies each entry WRAPPED / DIRECT /
UNVERIFIED against the recorded entry point and its digest, and names every
source it could not open instead of omitting it. It then starts the proxy in
front of the bundled echo server, in a private temporary folder, and checks five
things: the proxy answers initialize; the request bytes reach the server
unchanged; a withheld reply has the documented shape; replies come back inside
the bound; and closing the connection leaves no process in the proxy's or the
echo server's process group. It does not start
your own servers, it runs before any approval exists and writes none, and so it
does not exercise the content scan. With nothing else wrong it exits 3 (not
verifiable), because no route of yours has been verified:
That exit 3 means "I could not prove it", not "your routes failed", and the
report distinguishes the two in words. The alternative, reporting 0 because
the config looked tidy, is the one sentence this tool must never say. A doctor
that cannot demonstrate mediation must never imply it. If the proxy it starts
does not answer initialize, the system will not make its private folder or
start a process (the proxy, or one of the three rigged controls it is checked
against), or this Python has no process groups, the
self-test is reported as NOT RUN with the reason, and the exit is 3 as well.
When it is one of the three controls that could not be started, the report names
it, and each check keeps the state it really had (a check that passed shows as
passed and one that failed shows as failed) while the exit stays 3.
Point --config at a file that does not exist and doctor refuses and names
it, exactly as install does, rather than reporting "no server entries found"
about a file that was never there. An empty --config is refused too, by
doctor, install and uninstall. --config "$CFG" with CFG unset names no file,
and the one thing it must never do is quietly fall back to a wider source than
the one you asked for.
What doctor returns, and why 3 is not a failure
SUNGLASSES uses the same four codes on every command. What 1 means depends on
the command. For scan it is a finding. For doctor the table below gives each
code.
3 is the code for a machine where nothing is wired yet, and it is the right
answer. "I looked and nothing is protected" and "I could not look" are
different facts from "everything is fine", and a tool that collapses them into
0 is telling you that you are safe because it did not check. 0 and 3 never
mean the same thing here.
In this release 3 is what doctor returns when nothing else is wrong. The
per-route check is not built, so it can never say 0. A self-test that did not
run (the proxy never answered, or no process groups) is reported as NOT RUN
with no check marked failed, because "the instrument did not come up" and "your
self-test failed" are different facts. A failure it saw (1) or a file it could
not open (2) still outranks the 3. A script that tested for == 1 on a
machine with nothing wired now sees 3.
A self-test that ran and did not pass is always 1, whatever the rest of the
report says, because an instrument that failed has no standing to report on
anything else.
install keeps a copy of your original config and a record of what it changed,
under ~/.sunglasses/proxy/installs/. uninstall reads that record, checks the
copy still matches the digest taken at install time, and restores it. If the
record or the copy is not something it can vouch for, it refuses and changes
nothing rather than writing bytes it cannot verify.
install edits a config; it never creates one. Point it at a path that does
not exist and it refuses, naming the file:
A client's server list is that client's file. Creating one from a guess would put a config where the client was not looking, and leave you wondering why nothing is wrapped.
Why install may refuse when you think it should not
Each of these is a refusal with a message, never a silent partial change:
- the proxy artifact is missing, on a build where the entry point is not present. It is present in this one.
- that server is already wrapped, it says so rather than wrapping it twice
- it carries a wrapper we cannot verify, a rebuilt or foreign artifact; it will not nest a second wrapper inside someone else's
- a previous install is still recorded, uninstall it first, so the bytes that install retained are not the ones thrown away
- the config is not something we will rewrite, duplicate JSON keys,
NaN, or a shape we do not recognise. Rewriting a file whose meaning is ambiguous is how data quietly disappears.
Known Limitations
SUNGLASSES is risk reduction, not magic.
- Pattern-based: catches known attack patterns and variants. Novel zero-day attacks may pass until patterns are added.
- Negation-aware. "Do NOT run rm -rf / --no-preserve-root" is flagged as review. "now run rm -rf / --no-preserve-root" is blocked as critical. Edge cases exist, so report the ones you find.
- Multilingual depth varies, and it varies a lot: English has the full ruleset; 13 languages have exactly two dedicated patterns each; 7 more appear only as keywords inside English-scoped patterns; Persian and Bengali have neither. Measured counts in Language coverage. Community contributions welcome.
- OCR accuracy depends on image quality and font clarity.
- Audio/video: transcribes audio to text via Whisper, then scans text. Does not do frequency analysis or source separation. Hidden whispers that Whisper can hear will be caught; ultrasonic attacks won't.
installwraps; it does not by itself protect:sunglasses installrewrites the named entry to launch through the proxy entry point and exits 0, anduninstallrestores the original byte-identical. Out of the box the wrapped server enforces nothing until its tool snapshot is approved at an interactive terminal; what is enforced after that is stated under Proxy enforcement, measured rather than inferred. Do not read a successful wrap as a protection claim.- No web UI yet: deep scan is CLI/Python only for now. Drag-and-drop UI is on the roadmap.
Known in 0.6.2. The proxy can refuse an honest MCP server whose tool descriptions use words like redacts, hidden or overrides. Rule GLS-DFP-122 reads them as instructions smuggled into a tool schema, so the server is not activated and tools/list returns PROHIBITED_CONTENT with rule_ids ["GLS-DFP-122"]. No capture is written, so there is nothing to approve. 0.6.2 has no setting that lets one rule or one server through. To use that server anyway, sunglasses uninstall <name> restores its original entry, and its calls then reach it without passing through the proxy. 0.6.3 narrows the rule.
Known in 0.6.2 and earlier. An instruction written in Unicode tag characters was not scanned. Fixed in 0.6.3.
Integration Notes
- Verify signatures before cleaning. If content has a digital signature, verify it first, then run SUNGLASSES. Cleaning before verification breaks the signature.
- Only scan content fields. Feed SUNGLASSES the message body, text, and attachments, never raw HTTP headers, cookies, or auth tokens.
- A credential example in a tutorial is blocked. A published example key, such as the one in AWS's own documentation, is blocked as critical the same as a live key. The scanner cannot tell the two apart, so your code decides whether the message goes through.
Contributing
We need attack patterns in every language. If you find a bypass, open an issue with reproducible input. We patch in public.
See CONTRIBUTING.md for guidelines. See sunglasses.dev/thesis for our security philosophy.
License
MIT. Use it anywhere (personal, commercial, enterprise). Paid review, by scope call: https://sunglasses.dev/pricing
Links
- Website: sunglasses.dev
- Threat Database: attack-db/
- Issues: github.com/sunglasses-dev/sunglasses/issues
來源:README.md,提交 6a39777
工具
0版本歷史
1- v0.6.8最新Oct 13, 2026
評分
已滿足適用檢查項的 50%
該評分衡量此收錄條目的完整程度,依據註冊中心條目、端點與公開來源計算得出。它不是對伺服器本身的評價,也不表示伺服器安全或目前可用:可連通性是帶日期的檢查結果,評分也不包含安裝量或使用者評價。
- 近 30 天內可連通25 分
請保持端點能夠回應 MCP 握手;SourceWeft 每 30 天探測一次。
- 已宣告認證方式10 分
請在註冊中心條目中宣告憑證請求標頭,或保持端點能夠回應探測,以便判定其認證方式。
- 已列出工具15 分
請讓伺服器在無憑證時回應 tools/list,或發布可在沙箱中無憑證啟動的軟體包。
- 描述必需15 分
- 圖示10 分
- 文件15 分
- 授權條款10 分