diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml
new file mode 100644
index 0000000..c45a782
--- /dev/null
+++ b/.github/workflows/security.yml
@@ -0,0 +1,77 @@
+name: Security
+
+# Three checks, each answering a different question:
+#
+# supply-chain is any crate we depend on subject to a RustSec advisory, an
+# unexpected licence, or a source that is not crates.io?
+# code-scan does the code itself contain a pattern CodeQL recognises as
+# a vulnerability?
+# published does the vulnerability count on agenttop.dev still match what
+# a fresh scan says?
+#
+# The daily schedule is the point of the first one: an advisory published
+# against an unchanged Cargo.lock is a new vulnerability in a release that has
+# already shipped, and nothing in a push-triggered pipeline would catch it.
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ branches: [main]
+ schedule:
+ # 06:15 UTC daily. Off the hour, because everyone schedules on the hour.
+ - cron: "15 6 * * *"
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ supply-chain:
+ name: supply chain (cargo-deny)
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v5
+ - uses: EmbarkStudios/cargo-deny-action@v2
+ with:
+ command: check advisories bans licenses sources
+
+ code-scan:
+ name: code scan (CodeQL)
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ actions: read
+ security-events: write
+ steps:
+ - uses: actions/checkout@v5
+
+ # Rust needs no build for CodeQL: the extractor reads the source tree
+ # directly, so there is no toolchain step and no target cache here.
+ - name: Initialize CodeQL
+ uses: github/codeql-action/init@v4
+ with:
+ languages: rust
+ build-mode: none
+
+ - name: Analyze
+ uses: github/codeql-action/analyze@v4
+ with:
+ category: "/language:rust"
+
+ published:
+ name: published count is true
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v5
+
+ - name: Install cargo-audit
+ uses: taiki-e/install-action@v2
+ with:
+ tool: cargo-audit
+
+ # Fails when docs/data/security.json claims a vulnerability count that a
+ # fresh scan disagrees with, so the number on the site cannot go stale
+ # while still being presented as current.
+ - name: Check the published figures against a fresh scan
+ run: python3 scripts/security_report.py --check
diff --git a/README.md b/README.md
index abf5d13..6dc8fcb 100644
--- a/README.md
+++ b/README.md
@@ -3,6 +3,7 @@
**htop for local coding agents.**
[](https://github.com/kannandreams/agent-top/actions/workflows/ci.yml)
+[](https://github.com/kannandreams/agent-top/actions/workflows/security.yml)
[](https://crates.io/crates/agent-top)
[](LICENSE)
[](Cargo.toml)
@@ -475,6 +476,21 @@ The full account, including a worked example of why agent-top and your harness
can disagree on cost and how to reconcile them, is
[on the docs site](https://agenttop.dev/accounting/).
+## Security
+
+agent-top reads a developer's transcripts and process table, so the dependency
+tree and the code are both part of the security story. Three checks run in
+[the Security workflow](https://github.com/kannandreams/agent-top/actions/workflows/security.yml):
+`cargo-deny` over advisories, licences and sources; CodeQL over the code; and a
+check that the vulnerability count published on the site still matches a fresh
+scan. The supply-chain rules are in [`deny.toml`](deny.toml): crates.io only,
+no git dependencies, and a permissive-licence allowlist. Run them yourself with
+`mise run security`.
+
+The current count, with the advisory database revision it was measured against,
+is [on the docs site](https://agenttop.dev/security/), and the same figures are
+published as [JSON](https://agenttop.dev/data/security.json).
+
## Development
```sh
diff --git a/deny.toml b/deny.toml
new file mode 100644
index 0000000..3471add
--- /dev/null
+++ b/deny.toml
@@ -0,0 +1,60 @@
+# cargo-deny: the supply-chain gate for agent-top.
+#
+# cargo deny check # everything below, what CI runs
+# cargo deny check licenses # one section at a time
+#
+# The binary ships as one static file that reads a developer's transcripts, so
+# the dependency tree is part of the security story: every crate must come from
+# crates.io, carry a permissive licence, and be free of a RustSec advisory.
+
+[graph]
+all-features = true
+
+[advisories]
+# A crate with a RustSec advisory fails the build. The daily scheduled run in
+# .github/workflows/security.yml is the point of this: an advisory published
+# against an unchanged Cargo.lock is caught within a day rather than at the
+# next release.
+ignore = []
+
+[licenses]
+# Every licence in the tree today, and nothing else: a new dependency under a
+# copyleft or unknown licence fails rather than shipping quietly inside the
+# binary. An SPDX "OR" expression only needs one of its options allowed, so
+# MPL-2.0 and LGPL alternatives resolve to MIT without being listed here.
+#
+# Two entries below are not reached on every platform (a Windows-only crate,
+# a build dependency), and cargo-deny warns about an allowed licence it never
+# encountered. The list is kept whole rather than trimmed to one platform's
+# graph, so that warning is turned off instead.
+unused-allowed-license = "allow"
+allow = [
+ "0BSD",
+ "Apache-2.0",
+ "Apache-2.0 WITH LLVM-exception",
+ "BSD-2-Clause",
+ "BSD-3-Clause",
+ "BSL-1.0",
+ "CDLA-Permissive-2.0",
+ "ISC",
+ "MIT",
+ "Unicode-3.0",
+ "Unicode-DFS-2016",
+ "Unlicense",
+ "WTFPL",
+ "Zlib",
+]
+
+[bans]
+# Duplicate versions are a size and audit-surface problem, not a correctness
+# one, so they are reported without failing the build. A wildcard version
+# requirement is refused outright: it makes the tree unreproducible.
+multiple-versions = "warn"
+wildcards = "deny"
+
+[sources]
+# crates.io and nothing else. No git dependency, no alternative registry, so
+# every byte compiled into the binary is a published, checksummed crate.
+unknown-registry = "deny"
+unknown-git = "deny"
+allow-registry = ["https://github.com/rust-lang/crates.io-index"]
diff --git a/docs/data/security.json b/docs/data/security.json
new file mode 100644
index 0000000..75ea208
--- /dev/null
+++ b/docs/data/security.json
@@ -0,0 +1,12 @@
+{
+ "vulnerabilities": 0,
+ "warnings": 0,
+ "dependencies": 279,
+ "advisory_db": {
+ "advisories": 1243,
+ "commit": "b50980aad8b8",
+ "updated": "2026-09-09"
+ },
+ "checked_at": "2026-09-10",
+ "tool": "cargo-audit 0.22.2"
+}
diff --git a/docs/hooks/security_scan.py b/docs/hooks/security_scan.py
new file mode 100644
index 0000000..2bf81ae
--- /dev/null
+++ b/docs/hooks/security_scan.py
@@ -0,0 +1,91 @@
+"""MkDocs hook: publish the vulnerability count from a real scan.
+
+The site claims a number of known vulnerabilities, so the number has to come
+from somewhere checkable. `scripts/security_report.py` runs cargo-audit and
+writes docs/data/security.json; this reads that file and puts the figures in
+two places at build time:
+
+ * the "Security scan" item at the bottom of the sidebar, whose title becomes
+ the count itself rather than a static label;
+ * the marker on the Security page, which becomes a
+ panel with the full provenance: how many dependencies were scanned,
+ against which revision of the RustSec advisory database, by which tool,
+ on which day.
+
+Nothing here runs cargo or opens a socket: it reads one committed JSON file,
+so a docs build needs no Rust toolchain. When the file is missing the sidebar
+keeps its plain label and the panel says so, rather than reporting a zero
+nobody measured.
+"""
+
+import json
+import logging
+from pathlib import Path
+
+log = logging.getLogger("mkdocs.hooks.security_scan")
+
+# The nav label in mkdocs.yml that this hook rewrites, and the marker it
+# replaces in the page body.
+NAV_LABEL = "Security scan"
+MARKER = ""
+
+_data: dict | None = None
+
+
+def on_config(config):
+ global _data
+ path = Path(config["docs_dir"]) / "data" / "security.json"
+ try:
+ _data = json.loads(path.read_text())
+ except (OSError, json.JSONDecodeError) as e:
+ _data = None
+ log.info("security_scan: no scan data (%s); the count will not be shown", e)
+ return config
+
+
+def _phrase(n: int) -> str:
+ return "1 vulnerability" if n == 1 else f"{n} vulnerabilities"
+
+
+def on_nav(nav, config, files):
+ """Rewrite the sidebar label to the count a scan actually found.
+
+ "advisories" rather than "vulnerabilities": it is what the RustSec
+ database calls its entries, and it is short enough to stay on one line in
+ the sidebar, which "0 vulnerabilities" is not.
+ """
+ if not _data:
+ return nav
+ n = _data["vulnerabilities"]
+ for item in nav.items:
+ if item.title == NAV_LABEL:
+ item.title = f"Security · {n} advisor{'y' if n == 1 else 'ies'}"
+ return nav
+
+
+def on_page_markdown(markdown, page, config, files):
+ if MARKER not in markdown:
+ return markdown
+ if not _data:
+ return markdown.replace(
+ MARKER,
+ "No scan is recorded in this build. Run `mise run security:report` to produce one.",
+ )
+
+ count = _data["vulnerabilities"]
+ db = _data.get("advisory_db", {})
+ # The class carries the colour, and it is chosen here rather than in CSS
+ # because only this side knows the count: a green panel over a non-zero
+ # number would be worse than no panel at all.
+ state = "clean" if count == 0 and _data.get("warnings", 0) == 0 else "flagged"
+ warnings = _data.get("warnings", 0)
+ warning_note = (
+ "" if warnings == 0 else f" {warnings} advisory warning(s) (unmaintained or yanked crates) were also reported."
+ )
+
+ panel = f"""
+
{count}
+
known vulnerabilities
+
{_data.get('dependencies', 0)} dependencies scanned against {db.get('advisories', 0):,} RustSec advisories (database {db.get('commit', '?')}, {db.get('updated', '?')}) by {_data.get('tool', 'cargo-audit')} on {_data.get('checked_at', '?')}.{warning_note} The same figures are published as JSON.
+
"""
+ return markdown.replace(MARKER, panel)
diff --git a/docs/security.md b/docs/security.md
index a390cd3..bb4101d 100644
--- a/docs/security.md
+++ b/docs/security.md
@@ -6,6 +6,32 @@ description: "The security model behind agent-top: what it reads, what it never
agent-top's security model is a consequence of its design, not a policy bolted on afterward: it is read-only and local-only, so it never needs more trust than any other unprivileged process reading files you could already read yourself.
+## What the scans say
+
+
+
+That figure is not typed into the page. `scripts/security_report.py` runs [cargo-audit](https://github.com/rustsec/rustsec) against the workspace's `Cargo.lock`, writes what it found to `docs/data/security.json`, and this page and the sidebar are rendered from that file at build time. CI re-runs the scan and fails if the published number disagrees with a fresh one, so it cannot go stale while still reading as current.
+
+Three checks run in [the Security workflow](https://github.com/kannandreams/agent-top/actions/workflows/security.yml), each answering a different question:
+
+| Check | Question | When |
+|---|---|---|
+| [`cargo-deny`](https://embarkstudios.github.io/cargo-deny/) | Is any dependency subject to a RustSec advisory, an unexpected licence, or a source that is not crates.io? | every push and pull request, and daily |
+| [CodeQL](https://codeql.github.com) | Does the code itself contain a pattern recognised as a vulnerability? | every push and pull request, and daily |
+| `security_report.py --check` | Does the count published above still match a fresh scan? | every push and pull request, and daily |
+
+The daily schedule is the one that matters most. An advisory published against an unchanged `Cargo.lock` is a new vulnerability in a release that has already shipped, and nothing triggered by a push would ever notice it.
+
+The supply-chain rules live in [`deny.toml`](https://github.com/kannandreams/agent-top/blob/main/deny.toml) and are stricter than the default: every crate must come from crates.io — no git dependencies, no alternative registries — and carry a licence from an explicit permissive allowlist, so a new dependency under an unexpected licence fails the build rather than shipping quietly inside the binary. Wildcard version requirements are refused outright.
+
+To run the same checks yourself:
+
+```sh
+cargo deny check # advisories, licences, sources, duplicate versions
+cargo audit # RustSec advisories alone
+mise run security # both, the way CI does
+```
+
## What it reads
The process table (through `sysinfo` and, on macOS, `libproc` — not by shelling out to `ps`) and the transcript files each harness already writes. From a transcript it takes metadata: usage records (token counts), tool and MCP call names, ids, timestamps, session id, model, working directory. It does not read prompt text or tool output. [Accounting](accounting.md) is the arithmetic that follows from that boundary; [Context by source](accounting.md#context-by-source) is the clearest case of it — what a tool result added to the prompt is priced from the token counts alone, never from the result itself.
diff --git a/docs/stylesheets/extra.css b/docs/stylesheets/extra.css
index c679bbf..aab95e7 100644
--- a/docs/stylesheets/extra.css
+++ b/docs/stylesheets/extra.css
@@ -44,6 +44,8 @@
--at-blue-deep: #1a3fb3;
--at-blue-soft: #9db2ee;
--at-gold: #a8790a;
+ --at-green: #2e7d4f;
+ --at-warn: #c2410c;
--md-primary-fg-color: var(--at-paper);
--md-primary-fg-color--light: var(--at-paper);
@@ -86,6 +88,8 @@
--at-blue-deep: #8fadff;
--at-blue-soft: #2f4a9c;
--at-gold: #f0b93d;
+ --at-green: #57c98a;
+ --at-warn: #fb923c;
--md-primary-fg-color: var(--at-paper);
--md-primary-fg-color--light: var(--at-paper);
@@ -404,6 +408,59 @@
color: var(--at-blue);
}
+/* Security page: the scan result, rendered from docs/data/security.json by
+ * docs/hooks/security_scan.py. The state class is chosen by the hook, which is
+ * the only side that knows the count. */
+.at-scan {
+ display: grid;
+ grid-template-columns: auto 1fr;
+ gap: 0 1rem;
+ align-items: baseline;
+ padding: 1.1rem 1.3rem;
+ margin: 1.2em 0 1.6em;
+ border: 1px solid var(--at-line);
+ border-left: 3px solid var(--at-muted);
+ border-radius: 8px;
+ background: var(--at-panel);
+}
+.at-scan--clean {
+ border-left-color: var(--at-green);
+}
+.at-scan--flagged {
+ border-left-color: var(--at-warn);
+}
+.at-scan__count {
+ font-family: var(--at-display);
+ font-size: 2.4rem;
+ font-weight: 600;
+ line-height: 1;
+ color: var(--at-ink);
+}
+.at-scan--clean .at-scan__count {
+ color: var(--at-green);
+}
+.at-scan--flagged .at-scan__count {
+ color: var(--at-warn);
+}
+.at-scan__label {
+ font-family: var(--at-display);
+ font-weight: 600;
+ font-size: 0.85rem;
+}
+.at-scan__detail {
+ grid-column: 2;
+ margin: 0.3rem 0 0;
+ font-size: 0.72rem;
+ line-height: 1.55;
+ color: var(--md-default-fg-color--light);
+}
+/* The sidebar's scan link carries a number, so it reads as a figure rather
+ * than a page name. */
+.md-nav__link[href="/security/"] {
+ font-family: var(--at-mono);
+ font-size: 0.66rem;
+}
+
/* Blog index. */
.md-post--excerpt {
border: 1px solid var(--at-line);
diff --git a/mise.toml b/mise.toml
index 24fd668..1708ca9 100644
--- a/mise.toml
+++ b/mise.toml
@@ -27,6 +27,16 @@ description = "Run the TUI"
raw = true
run = "cargo run --"
+# Supply-chain checks. Needs cargo-deny and cargo-audit:
+# cargo install --locked cargo-deny cargo-audit
+[tasks.security]
+description = "Advisory, licence and source checks over the dependency tree"
+run = ["cargo deny check advisories bans licenses sources", { task = "security:report" }]
+
+[tasks."security:report"]
+description = "Rescan with cargo-audit and rewrite docs/data/security.json"
+run = "python3 scripts/security_report.py"
+
# The documentation site (mkdocs.yml at the root, pages in docs/). Every
# Python step goes through uv, which makes a throwaway environment from
# docs/requirements.txt; nothing is installed with pip and there is no venv.
diff --git a/mkdocs.yml b/mkdocs.yml
index 9608a7d..289606c 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -71,6 +71,7 @@ extra_css:
hooks:
- docs/hooks/changelog_links.py
- docs/hooks/cache_bust.py
+ - docs/hooks/security_scan.py
extra:
generator: false
@@ -129,3 +130,8 @@ nav:
- blog/index.md
- GitHub: https://github.com/kannandreams/agent-top
- crates.io: https://crates.io/crates/agent-top
+ # Root-relative on purpose: the Security page already sits under Project, and
+ # naming the file twice would make mkdocs read this as the page's position in
+ # the nav, which breaks its previous/next footer. docs/hooks/security_scan.py
+ # rewrites this label to the count the last scan actually found.
+ - Security scan: /security/
diff --git a/scripts/security_report.py b/scripts/security_report.py
new file mode 100644
index 0000000..4a43b76
--- /dev/null
+++ b/scripts/security_report.py
@@ -0,0 +1,94 @@
+#!/usr/bin/env python3
+"""Run cargo-audit and record what it found in docs/data/security.json.
+
+The site shows a vulnerability count, so that count has to come from a real
+scan rather than a claim someone typed. This runs the scan, writes the parts
+worth publishing, and in --check mode fails when the committed figures no
+longer match what a fresh scan says. CI runs --check, so the number on
+agenttop.dev cannot quietly go stale while still being presented as current.
+
+ python3 scripts/security_report.py # rewrite the data file
+ python3 scripts/security_report.py --check # verify it, change nothing
+
+Standard library only, so it needs no environment of its own.
+"""
+
+import json
+import subprocess
+import sys
+from datetime import date
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parent.parent
+DATA = ROOT / "docs" / "data" / "security.json"
+
+
+def scan() -> dict:
+ """cargo-audit's report, reduced to the fields the site publishes."""
+ proc = subprocess.run(
+ ["cargo", "audit", "--json"],
+ cwd=ROOT,
+ capture_output=True,
+ text=True,
+ )
+ # cargo-audit exits non-zero when it finds something, which is a result,
+ # not a failure. Unparseable output is the real failure.
+ try:
+ report = json.loads(proc.stdout)
+ except json.JSONDecodeError:
+ sys.exit(f"cargo audit produced no JSON report (exit {proc.returncode}):\n{proc.stderr.strip()}")
+
+ db = report.get("database", {})
+ warnings = report.get("warnings") or {}
+ # `cargo audit --version` prints "cargo-audit-audit ": the binary
+ # name with the subcommand appended. Keep the version, name the tool once.
+ raw = subprocess.run(
+ ["cargo", "audit", "--version"], cwd=ROOT, capture_output=True, text=True
+ ).stdout.split()
+ version = f"cargo-audit {raw[-1]}" if raw else "cargo-audit"
+
+ return {
+ "vulnerabilities": report["vulnerabilities"]["count"],
+ "warnings": sum(len(v) for v in warnings.values()),
+ "dependencies": report.get("lockfile", {}).get("dependency-count", 0),
+ "advisory_db": {
+ "advisories": db.get("advisory-count", 0),
+ "commit": (db.get("last-commit") or "")[:12],
+ "updated": (db.get("last-updated") or "")[:10],
+ },
+ "checked_at": date.today().isoformat(),
+ "tool": version or "cargo-audit",
+ }
+
+
+def main() -> None:
+ checking = "--check" in sys.argv[1:]
+ fresh = scan()
+
+ if not checking:
+ DATA.parent.mkdir(parents=True, exist_ok=True)
+ DATA.write_text(json.dumps(fresh, indent=2) + "\n")
+ print(f"{DATA.relative_to(ROOT)}: {fresh['vulnerabilities']} vulnerabilities, "
+ f"{fresh['warnings']} warnings, {fresh['dependencies']} dependencies")
+ return
+
+ if not DATA.is_file():
+ sys.exit(f"{DATA.relative_to(ROOT)} does not exist; run scripts/security_report.py")
+
+ published = json.loads(DATA.read_text())
+ # Only the security claim itself is enforced. The dependency count and the
+ # advisory-database revision move with every routine change and are
+ # refreshed by the next run; letting them fail CI would turn an honest
+ # number into a chore.
+ for field in ("vulnerabilities", "warnings"):
+ if published.get(field) != fresh[field]:
+ sys.exit(
+ f"{DATA.relative_to(ROOT)} publishes {field}={published.get(field)}, "
+ f"a fresh scan says {fresh[field]}. Run scripts/security_report.py."
+ )
+ print(f"published figures match a fresh scan: {fresh['vulnerabilities']} vulnerabilities, "
+ f"{fresh['warnings']} warnings")
+
+
+if __name__ == "__main__":
+ main()