Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions .github/workflows/security.yml
Original file line number Diff line number Diff line change
@@ -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
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
60 changes: 60 additions & 0 deletions deny.toml
Original file line number Diff line number Diff line change
@@ -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"]
12 changes: 12 additions & 0 deletions docs/data/security.json
Original file line number Diff line number Diff line change
@@ -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"
}
91 changes: 91 additions & 0 deletions docs/hooks/security_scan.py
Original file line number Diff line number Diff line change
@@ -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 <!--security-summary--> 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 = "<!--security-summary-->"

_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"""<div class="at-scan at-scan--{state}">
<span class="at-scan__count">{count}</span>
<span class="at-scan__label">known vulnerabilities</span>
<p class="at-scan__detail">{_data.get('dependencies', 0)} dependencies scanned against {db.get('advisories', 0):,} RustSec advisories (database <code>{db.get('commit', '?')}</code>, {db.get('updated', '?')}) by {_data.get('tool', 'cargo-audit')} on {_data.get('checked_at', '?')}.{warning_note} The same figures are published as <a href="../data/security.json">JSON</a>.</p>
</div>"""
return markdown.replace(MARKER, panel)
26 changes: 26 additions & 0 deletions docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

<!--security-summary-->

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.
Expand Down
57 changes: 57 additions & 0 deletions docs/stylesheets/extra.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading