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.** [![CI](https://github.com/kannandreams/agent-top/actions/workflows/ci.yml/badge.svg)](https://github.com/kannandreams/agent-top/actions/workflows/ci.yml) +[![Security](https://github.com/kannandreams/agent-top/actions/workflows/security.yml/badge.svg)](https://github.com/kannandreams/agent-top/actions/workflows/security.yml) [![crates.io](https://img.shields.io/crates/v/agent-top.svg)](https://crates.io/crates/agent-top) [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) [![Rust 2024](https://img.shields.io/badge/rust-edition%202024-orange.svg)](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()