Skip to content

feat(discover): find the agents on this machine, and a window onto them - #81

Open
ExposureGuard wants to merge 6 commits into
mainfrom
feat/discover-and-console
Open

ExposureGuard wants to merge 6 commits into
mainfrom
feat/discover-and-console

Conversation

@ExposureGuard

Copy link
Copy Markdown
Owner

Branched from main — independent of the #77→#80 stack.

The register answers "what is governed". It cannot answer "what do I even have", and that is the question someone asks first. This scans the machine and shows the answer alongside the config that brings each ungoverned server under Haldir.

haldir discover

Read-only, local, stdlib. Reads the config files the known MCP clients use (Claude Code, Claude Desktop, Cursor, Windsurf, Zed, VS Code, Cline) and classifies running processes. On this machine:

MCP CLIENTS
  Claude Code  [client]        /home/anon/.claude.json
  VS Code      [client]        /home/anon/.config/Code/User/settings.json
MCP SERVERS THEY LAUNCH
  exposureguard  [mcp-server]  exposureguard-mcp  ·  via Claude Code
RUNNING NOW
  Claude #137568  [coding-agent]  claude
  Ollama #1316    [llm-runtime]   /usr/local/bin/ollama serve

Two rules, both tested:

  • Classification uses the command head — the executable and its first arguments — not the whole line. Matching the whole line is how a shell wrapper whose -c payload mentions .claude/shell-snapshots gets reported as an agent (it did, in the first run — that's the fix), and how python -c "import langchain" does. Six shapes must match, three must not, and both sets are pinned in the tests.
  • Everything reported is redacted. Command lines carry API keys in practice, and discovery output gets pasted into issues. Tokens behind --api-key/--token/password flags and recognisable shapes (sk-, hld_, ghp_, AKIA…) are stripped; ordinary text is left exactly as it was, because over-redaction makes the report useless.

A static test asserts the module cannot import anything that reaches the network — discovery that phones home is a different product with a different consent story.

haldir console

A tkinter window (stdlib — no new dependency) over the same rows: configured clients, their servers, running processes, governed agents. Selecting a row shows its next step and the config snippet to paste; Copy Haldir config puts it on the clipboard. console --json prints the rows instead, for tests and remote shells.

the console

(image added in a follow-up commit; the run is described below either way)

Verified by running it, not by importing it — the window was opened on this desktop and screenshotted, which is how the light-theme tree (ttk ignores the Tk palette without a Style) and the off-screen detail pane were found and fixed. A copy is at ~/Desktop/haldir-console.png.

build_rows() is pure, so the half with the logic is tested without a display; the half with the display has almost no logic to get wrong. With no display it says "run haldir discover for the same information in the terminal" rather than raising a TclError from three frames down.

Verification

1155 tests, flake8 clean, mypy clean over 33 files, wheel inspected — both modules and CLI.md ship. openapi.json untouched; these are CLI surfaces.

🤖 Generated with Claude Code

Sterling Ivey and others added 5 commits October 4, 2026 10:15
`agents` has been written on every session creation since migration 001
(`Gate.register_agent`, reached from POST /v1/sessions and the delegation
path) and **nothing has ever read it**. Meanwhile sessions, audit_log,
approval_requests and the delegation tree all carry `agent_id`. The register
already existed as state; this is the surface.

It is also the artifact the assurance story rests on: the question an auditor,
a customer or a regulator asks is "show me your register of AI systems" —
which agents exist, what each is allowed to do, what each actually did, and
which of them can spawn others.

  * `haldir_registry.py` — five grouped queries over one tenant, no N+1.
    Derived from **activity as well as registration**: an agent that acted but
    was never explicitly registered is still listed, because a register that
    silently omits agents reads as complete and is not.
  * `GET /v1/agents` and `GET /v1/agents/<id>` — tenant-scoped, `admin:read`.
    A miss is the same 404 whether the agent belongs to another tenant or to
    nobody.
  * `/cloud/overview` gains an **Agents** page (summary stats + table), and
    the contract test parses the real template against a real payload.
  * `haldir agents [--agent ID] [--json]` — the register as a terminal table.
  * The **evidence pack** gains an "Agent register" section (markdown + HTML),
    which is the form an auditor receives. Its recency fields
    (`last_seen`, `last_action_at`, `sessions.active`) are excluded from the
    digest exactly as `access_control.last_used` is — a session TTL expires
    with no write, and an auditor re-verifying an archived pack must not read
    that drift as tampering. Its substance (agent, scopes, caps, counts,
    spend, flags, delegation) stays in the digest; the test asserts both
    directions.
  * The register reports **two spend figures**: `session_spend_usd` (what the
    Gate metered against caps) and `audited_cost_usd` (what the log recorded
    per action). They move independently, and calling either one "spend"
    would quietly pick a side.

Verified: 1106 tests, flake8 clean, mypy clean over 30 files (the module is
in scope), wheel built and inspected. `openapi.json` regenerated for the two
routes. The `/docs` page lists them.

Co-Authored-By: Claude Code <noreply@anthropic.com>
…lause

The evidence pack has mapped to SOC 2 since it existed. That is one framework,
and the buyers this product is for are asked about more than one — so the
document now speaks to the three it can defend, with four rules enforced by
tests rather than by good intentions:

1. **"Contributes to", never "satisfies".** Every clause names what the
   evidence does *and what it does not cover*. A clause with a contribution
   and no gap reads as a claim that the evidence closes it; a test fails the
   build if one is missing.
2. **Who the duty falls on is recorded.** Article 12 is largely the
   *provider's* obligation (build the system so it can log); Article 26 is the
   *deployer's* (keep the logs, monitor the system). Haldir's customer is
   usually the deployer, and collapsing the two tells someone they have met an
   obligation belonging to whoever built the model.
3. **High-risk is a precondition, stated as one.** Arts 12 and 26 attach to
   systems classified high-risk under Annex III. Haldir cannot classify a
   system and does not pretend to.
4. **Only clauses that can be cited are cited.** A.6.2.x is miscatalogued by
   more than one vendor; the mapping uses the numbers confirmed across
   published catalogues and leaves the rest out. A wrong control number in an
   evidence pack is worse than a missing one.

`haldir_frameworks.py` holds the mapping; `framework_report()` renders it for
the pack and groups the readiness score's existing seven checks by the clauses
they speak to — no second scoring engine, no second set of signals. A clause
nothing measures is reported `measured: false` rather than scored, because
most of the Act and most of Annex A is organisational and a number invented
for it would be the overclaim the module exists to prevent.

The mappings render in both forms (markdown and HTML), so the auditor-readable
document carries the gaps next to the contributions. The signature section
moves to 10 in both, and the numbering assertions move with it.

Tests: `tests/test_frameworks.py` — clauses reference only real pack sections
(this caught `proxy` and `alerting` while it was being written; `alerting` is
the *score* key for the `webhooks` section), checks map to clauses that exist,
every regulatory clause states its gap, the provider/deployer split survives,
Article 26(6)'s six-month floor stays recorded, unmeasured clauses carry no
state, and pass wins over fail for a clause with two checks.

Verified: 1116 tests, flake8, mypy over 31 files, both rendered forms checked,
digest stable with the new section inside it.

Co-Authored-By: Claude Code <noreply@anthropic.com>
The register and the framework mappings are what an assurance buyer buys, so
which frameworks a plan includes, how long evidence is kept, and whether
delivery runs on a schedule are plan attributes now — and, more to the point,
they are **enforced**. A plan card promising framework coverage the product
gives everyone is a promise that means nothing, and the two can only be kept
in step by one table feeding both.

`haldir_tiers.assurance(tier)` returns the entitlements through the rename
alias; `feature_lines()` renders them, so the card is generated from the same
block the compliance routes filter by:

  free        SOC 2 · 30-day retention
  usage       + EU AI Act and ISO/IEC 42001 · 90-day retention · scheduled delivery
  enterprise  + custom retention

The evidence pack, the manifest, the score and the HTML admin view all pass
the tenant's entitlement, resolved from the subscriptions table — the only
thing that raises a tenant's tier, since `POST /v1/keys` deliberately always
mints a free key.

When a plan excludes a framework the pack says so: `frameworks_excluded` names
it, and both rendered forms print "Not included in this plan". An auditor must
not read an omission as "this evidence does not exist".

The round-trip of the reassurance: the manifest and the full pack filter
identically, so their digests still agree — a manifest that filtered
differently would make the verification instructions wrong.

Display names for the cards stay local to `haldir_tiers` with a comment
explaining why (that module may import only `typing`, which is what lets it be
the single definition), and `tests/test_assurance_plans.py` keeps them in step
with `haldir_frameworks` in both directions — a framework with no card name
would render its raw id on a pricing page.

Verified: 1126 tests, flake8 clean, mypy clean over 31 files.

Co-Authored-By: Claude Code <noreply@anthropic.com>
The register answers "which agents do we run". This answers "which of them are
we willing to be found by": an owner-published, capability-only card, listed
at `/.well-known/agents.json`. It is the first part of Haldir that faces
outward, so the rules that keep it honest are structural rather than
aspirational:

**The public projection is built by naming what it includes.**
`public_cards()` selects its columns; it does not copy a row and delete the
private fields. A column added to the table later cannot leak by default —
and a test asserts the card's key set is exactly the documented seven, for an
agent that has real spend and flags on the record.

**Opt-in per agent; withdrawal means deletion.** Publishing names one agent.
`unpublish` deletes the row, because "hidden" and "gone" are different
promises and only one of them is worth making to an operator.

**Published, not verified.** The index says so in its own document. A
directory that implies endorsement turns somebody else's overstatement into
our misrepresentation.

**An agent must exist to be published** — the route checks the register
first, so a listing always refers to an agent this deployment has seen — and
one tenant cannot see, change, or withdraw another's card. The 404 for a
foreign card is the same 404 as for a nonexistent one.

Also in this change: the register now says which of an operator's agents are
discoverable (`card_id` per entry, `discoverable_agents` in the summary) and
distinguishes **agents from principals** — `admin:<key-prefix>` is how
`_audit_admin` attributes an operator's action, and `system` is the
deployment's own bookkeeping. They acted, so they belong in the register; but
a register of *AI systems* that counts an operator's key as an agent is wrong
in the direction nobody notices. `summary.agents` counts systems,
`summary.principals` counts the rest, `summary.entries` is everything.

`haldir publish <agent> --name ... [--capability X]...` and
`haldir unpublish <agent>` for the terminal; `POST/DELETE
/v1/agents/<id>/card` for everything else. Migration 010, and a test that the
migration and the store create the *same* table — two definitions of one
table is how `approval_requests` ended up without its tenant column on one
path.

Two test-suite fixes this surfaced: the new modules release the shared
rate-limit budget when they finish (they were starving later modules into
429s), and the spend-leak assertion compares numbers rather than substrings
("50" appears inside the published_at epoch).

Verified: 1137 tests, flake8 clean, mypy clean over 32 files, wheel inspected
— the module and the migration both ship.

Co-Authored-By: Claude Code <noreply@anthropic.com>
… onto them

The register answers "what is governed". It cannot answer "what do I even
have", which is the question someone asks first — and until it is answered,
governance has nothing to attach to. So this scans the machine, and the
console shows the answer with the config snippet that brings each ungoverned
server under Haldir.

**`haldir discover`** — read-only, local, stdlib. It reads the config files
the known MCP clients use (Claude Code, Claude Desktop, Cursor, Windsurf, Zed,
VS Code, Cline) and classifies running processes. On this machine it finds the
`exposureguard` MCP server configured in Claude Code, plus Ollama and a Claude
process — which is what makes it worth having.

Two rules, both tested:

  * **Classification uses the command head** — the executable and its first
    arguments — not the whole command line. Matching the whole line is how a
    shell wrapper whose `-c` payload mentions `.claude/shell-snapshots` gets
    reported as an agent (it did, in the first run) and how `python -c "import
    langchain"` does. Six shapes must match; three must not; both are pinned.
  * **Everything reported is redacted.** Command lines carry API keys in
    practice, and discovery output gets pasted into issues. Tokens behind
    `--api-key`/`--token`/`password` flags and recognisable key shapes
    (`sk-`, `hld_`, `ghp_`, `AKIA…`) are stripped; ordinary text is left
    exactly as it was, because over-redaction makes the report useless.

A static test asserts the module cannot import anything that reaches the
network: discovery that phones home is a different product with a different
consent story.

**`haldir console`** — a tkinter window (stdlib, no dependency) over the same
rows: what is configured, what is running, what Haldir governs, with the
selected row's next step and the snippet to paste. `build_rows()` is pure, so
the half with the logic is tested without a display; the half with the display
has almost no logic. It reports "no display available, use `haldir discover`"
rather than a TclError from three frames down.

Verified by running it, not by importing it: the window was opened on this
desktop and screenshotted (`~/Desktop/haldir-console.png`), which is how the
light-theme tree and the off-screen detail pane were found and fixed.

`openapi.json` untouched — these are CLI surfaces. CLI.md documents both.

Co-Authored-By: Claude Code <noreply@anthropic.com>
The window rendered on this desktop — dark theme, the detail pane carrying the
next step and the snippet — committed so the reference in CLI.md and the PR
resolves rather than pointing at nothing.

Co-Authored-By: Claude Code <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant