The secure-agentd daemon exposes an HTTP API over a local Unix domain socket.
- Default Socket Path:
~/.config/secure-agent/daemon.sock - File Permissions:
0600(Owner read/write only)
The generated route registry lists the current canonical routes and access classifications. The sections below describe request and response contracts.
| Endpoint | Method | Description |
|---|---|---|
/status |
GET |
Returns daemon running state, uptime, active agent count, and proxy status. |
/resources |
GET |
Returns attributed session-family RSS, CPU, process topology, history, diagnoses, and reclaim estimates. |
/resources/control |
POST |
Applies or dismisses a pending resource action, or resumes a paused session family. |
/flags |
GET |
Returns recent security correlation flags (accepts optional ?limit=N). |
/events |
GET |
Returns recent raw system events (accepts optional ?limit=N). |
/incidents |
GET |
Returns incident reports and remediation checklists (?id=ID, ?format=markdown). |
/incidents/remediation |
POST |
Records reported/pending remediation steps against the viewed incident evidence and revision. |
/kill |
POST |
Terminate an agent process tree by PID ({"pid": 12345}). |
/worktrees |
GET |
Every git worktree found, with a remove/review/keep/prune verdict and its reasons (?refresh=1 rescans). |
/worktrees/repos |
POST |
Add a repository to the worktree hunter's saved list, or hide it ({"path": "...", "hidden": true}). |
/worktrees/remove |
POST |
Remove a worktree whose fresh verdict is remove ({"path": "..."}), or prune missing ones ({"repo": "...", "prune": true}). |
/worktrees/review-trash |
POST |
Move a keep or review row's folder to Trash and unregister it; fresh facts must still match, and a live session, lock, loose commits, conflicts or partly staged files refuse it. |
/worktrees/advise |
POST |
Ask the local advisor for a note on one worktree ({"path": "..."}); advisory only. |
/worktrees/reveal, /worktrees/reconnect, /worktrees/trash |
POST |
For a folder whose repository moved or was deleted: open it in Finder, link it again with git worktree repair, or move it to the Trash. |
/cleanup/ledger |
GET |
What cleanups removed and the bytes each gave back, with all-time and 30-day totals. |
/cleanup |
GET |
.tmp and .quarantine folders, build output, tool and app caches: size, last touched, project, how to clear. |
/cleanup/trash, /cleanup/clean |
POST |
Move one item to the Trash, or run a tool cache's own clean command. |
/cleanup/advise |
POST |
Ask the local advisor for a cleanup plan for one project ({"project": "<repo path or machine>"}); advisory only. |
/worktrees/ask |
POST |
Ask a currently active, identified agent in a keep/review worktree to open a PR for its work or say the worktree can go (GET /worktrees/asks lists answers). |
/agent/status, /agent/skills, /agent/runs |
GET |
The system agent's model and harness readiness, its skills, and its dispatches. |
/agent/chat |
GET, POST, DELETE |
Direct Ollama conversation; POST {"message","workdir"} sends one (the reply lands asynchronously). A harness field is rejected. |
/agent/analyze |
POST |
Build a bounded, masked local summary from stored flags, evidence, and operator actions and ask Ollama for an advisory recommendation. No command runs. |
/agent/worktree |
POST |
Ask the chat about one worktree ({"path"}): the question carries the checker's facts and the repository data as untrusted evidence; follow-ups keep it in context. |
/agent/recommendations |
GET, POST |
Review queued analysis replies; POST {"message_id","state":"dismissed"} dismisses a pending item. |
/agent/actions |
POST |
Start the exact local command stored on an assistant message: {"message_id":123}. The caller cannot supply command text. |
/agent/plans |
GET, POST, DELETE |
Plans with whether each can run now; save a reply's proposal ({"message_id"}) or write one. |
/agent/dispatch |
POST |
Run a plan's harness on the local Ollama: `{"plan_id","mode":"headless |
Query the owner-scoped Unix socket from a terminal:
curl --unix-socket "$HOME/.config/secure-agent/daemon.sock" http://unix/status
curl --unix-socket "$HOME/.config/secure-agent/daemon.sock" 'http://unix/flags?limit=10'The browser console uses a separate token on the proxy listener. See peer authentication and console access before integrating a client.
Returns daemon operational status, system uptime, and active tagged agent process count.
GET /status HTTP/1.1
Host: unix{
"running": true,
"uptime": "1h24m05s",
"active_agents": 2,
"advisor_health": {
"enabled": true,
"circuit_open": false,
"queue_depth": 0,
"model": "qwen3:8b"
}
}advisor_health also includes state (idle, preparing, answering, inspecting, or
paused), active_kind, active_subject, active_tool, elapsed_ms,
timeout_ms (the active task's time budget), last_duration_ms, input_bytes
(complete serialized request bytes), tool_calls, and
retry_at while paused. These fields describe activity, not enforcement.
advisor_health reports the local triage advisor's live state: circuit_open
means the model server has failed repeatedly and verdicts are paused
(last_error says why) β the UIs render this so advisor actions never look
like dead buttons. Absent on older daemons.
otlp_dropped counts spans lost to export capacity limits or failed exports
since daemon start. storage_health.read_failures counts failed session and
harness-activity reads; read_active lists the operations still failing.
Coverage retains its last known activity after a failed read and reports that
it may be stale through posture and Doctor. /sessions and /snapshot
return HTTP 503 when their session query fails rather than returning an empty
population. Read-health fields are omitted when empty.
Each trees[].root whose pid roots a recorded session carries that session's
session_id, workspace, repo, branch and origin (the spawning agent,
as on /sessions); each is omitted when empty.
coverage.sessions separates observations for each live root. It includes
session_id only when harness, root PID and process start time match one
live durable session. guard, trace and payload each expose supported,
state, optional last_seen, and detail. States are observed,
not-observed, unsupported, unattributed, off and stale. An
observation is not a claim that the entire session is protected. The current
proxy hit stream normally lacks session identity, so machine-level inspection
matches cannot establish session inspection. sessions_stale marks a failed
refresh; sessions_truncated reports that more than 128 live roots exist.
Older daemons omit these additive fields.
Pinned UI/console mutation, unavailable to agent peers. Start with
{"harness":"claude"} or {"harness":"cursor"}. The reply includes an
ephemeral id, canonical installed hook_path, inert path, and
expires_at. The UI invokes that hook with a PreToolUse Read payload for
the supplied path and secure_agent_probe set to the ID. The hook uses
POST /guard/decision with probe_id to obtain an inert deny receipt. A
probe can never authorize a real path or create session evidence.
Complete with {"id":"<challenge-id>","passed":true} only after verifying
the hook exited successfully and returned the matching deny receipt. Use
passed:false for a failed hook invocation. A claimed pass without a daemon
receipt, expired challenge or wrong fixture returns 409; replay after
completion returns 404. Missing installed dependencies return 503. Only
Claude and Cursor are supported; other harnesses return 400.
The receipt contains harness, hook_path, checked_at, state and
detail. /status.coverage.probes exposes these manual results separately
from session activity. A passed receipt becomes changed when checked files
change or become unreadable, and expired after 24 hours. Restart clears
receipts. The check does not exercise resource policy or prove the harness's
hook registration executes automatically.
Returns a point-in-time rollup of resources attributed to tagged agent process
families. Each session is anchored to the root PID and process start time, so
PID reuse cannot splice two runs together. Samples are taken every five
seconds and retained in memory for one hour; daemon restarts begin a new
history. The session totals remain agent-attributed; host provides the
whole-machine context needed to tell whether that usage is safe or is crowding
out the rest of the workstation.
{
"observed_at": "2026-09-15T20:00:00Z",
"host": {
"total_memory_bytes": 17179869184,
"free_memory_bytes": 2147483648,
"available_memory_bytes": 4294967296,
"compressed_memory_bytes": 1073741824,
"used_memory_bytes": 12884901888,
"agent_memory_bytes": 5368709120,
"non_agent_memory_bytes": 7516192768,
"swap_total_bytes": 8589934592,
"swap_used_bytes": 2147483648,
"headroom_percent": 25,
"agent_memory_percent": 31.3,
"system_cpu_percent": 75,
"agent_cpu_percent": 8.9,
"non_agent_cpu_percent": 66.1,
"logical_cpu_count": 16,
"memory_pressure": "normal",
"thermal_state": "nominal",
"headroom_score": 25,
"capacity": "constrained"
},
"rss_bytes": 5368709120,
"cpu_percent": 142.5,
"process_count": 3,
"session_count": 1,
"sessions": [{
"key": "58210:1789502400000000000",
"name": "claude",
"workspace": "/Users/dev/project",
"root_pid": 58210,
"root_started_at": "2026-09-15T19:30:00Z",
"rss_bytes": 5368709120,
"cpu_percent": 142.5,
"process_count": 3,
"orphan_count": 0,
"estimated_reclaim_bytes": 5368709120,
"processes": [
{"name": "claude", "pid": 58210, "ppid": 1, "rss_bytes": 1073741824, "cpu_percent": 22.5},
{"name": "node", "pid": 58211, "ppid": 58210, "rss_bytes": 4294967296, "cpu_percent": 120}
],
"samples": [{"at": "2026-09-15T20:00:00Z", "rss_bytes": 5368709120, "cpu_percent": 142.5}],
"diagnoses": [{
"code": "heavy-memory",
"severity": "critical",
"summary": "Session is using at least 4 GiB of resident memory.",
"threshold": "RSS >= 4 GiB",
"confidence": "high",
"estimated_reclaim_bytes": 5368709120
}]
}]
}Host CPU values are percentages of the machine's complete logical-CPU
capacity (0β100). Session and process CPU values continue to use 100% per
fully occupied core. agent_cpu_percent converts the attributed session total
to machine capacity; non_agent_cpu_percent is the saturating difference from
the measured system total. Memory attribution is likewise saturating, so a
racing process sample can never produce a negative non-agent value.
headroom_score is the most constrained available signal: available-memory
percentage, CPU idle percentage, unused-swap percentage, or the thermal cap.
Scores below 15 are critical, 15β49 are constrained, and 50β100 are
ample. Memory pressure is critical below 10% available memory or at 80%
swap use, warning below 20% available or at 50% swap use, and normal
otherwise. Fields that the operating system does not expose are omitted and
the corresponding state is unknown; secure-agent does not manufacture a
healthy reading. macOS and Linux use native kernel/proc metrics, with thermal
state collected best-effort. The first CPU sample has no delta and is omitted.
Diagnoses are deterministic and may include heavy-memory (RSS β₯ 4 GiB),
heavy-cpu (CPU β₯ 100%), rapid-growth (β₯ 1 GiB and β₯ 25% over 15
minutes), idle-heavy (RSS β₯ 2 GiB after 15 minutes without attributed
activity), runaway-child (a child holds β₯ 1 GiB and β₯ 60% of family RSS),
and orphan-drift (an attributed process remains after its parent exits).
estimated_reclaim_bytes is an estimate of memory associated with the
diagnosed scope; it is not a promise that the operating system will reclaim
that exact amount immediately.
Resource budgets are configured in the private overlay and hot-reload within one config-watch cycle:
resource_control:
mode: prompt # observe | prompt | terminate
max_rss_mb: 4096 # 0 disables this dimension
max_cpu_percent: 200 # 0 disables; 100 is one full core
sustain_seconds: 30 # continuous breach before action
cooldown_seconds: 300 # suppress repeat prompts/failed retries
interventions: # optional ordered delays after sustain_seconds
- action: notify
after_seconds: 0
- action: lower_priority
after_seconds: 30
nice: 10 # 1..19; larger values get less CPU priority
- action: pause
after_seconds: 60
- action: terminate # must be the final step
after_seconds: 120
workspace_overrides:
- cwd_prefix: /Users/me/workspace/critical-service
mode: terminate
max_rss_mb: 8192
max_cpu_percent: 300
sustain_seconds: 60
cooldown_seconds: 600Workspace overrides cover the exact normalized path and its descendants. If
multiple prefixes match, the longest prefix wins. Every override is a complete
policy so its effective behavior does not depend on hidden field inheritance.
The Resource Mission Control editor writes the full policy document with
PUT /resources/policy; the daemon validates and atomically persists the YAML
before applying it. An unsuccessful write leaves the active policy unchanged.
The response also includes episodes, the newest 20 locally persisted
resource-pressure captures. An episode is recorded when a diagnosis first
appears, its diagnosis set changes, or resident memory rises another 25%.
Each capture contains whole-session totals, diagnostic evidence, effective
control state, the root plus at most 64 highest-RSS processes, and at most 120
five-second samples (a ten-minute prelude). It also includes the captured
host snapshot, so later review can distinguish a large but safe session from
one that exhausted machine headroom. The database retains the newest 500
episodes, and each /resources response returns the newest 20.
Episodes may also contain activities and correlations. The daemon selects
events from PIDs in the captured process family, and the episode session's own
rows (tool calls, model calls and turns carry no PID), only between the
retained prelude and capture time, then rejects any family event outside that
exact nanosecond window or before the captured process instance started. It
converts the survivors into short references such as process starts, tool
names and outcomes, model call token counts, file basenames, and network
destinations; payloads and secret values are never copied. A tool call that
returned carries ended_at (one still running at capture ends at the
capture); tool and model calls carry a ref, so a later read replaces the
earlier copy once the stored row is completed or its counts rise.
At most 80 references are retained: those the growth interval matched, then
the newest. correlations identifies the largest positive sample-to-sample RSS
change and any recorded activity overlapping that interval, naming a tool or
model call ahead of a process, file or network reference:
{
"activities": [
{"at":"2026-09-15T19:59:55Z","kind":"process-start","pid":58211,"process":"node","summary":"node started"}
],
"correlations": [
{"summary":"Memory rose 1.4 GiB in 5s while node started.","confidence":"observed-correlation","from":"2026-09-15T19:59:50Z","to":"2026-09-15T19:59:55Z","rss_delta_bytes":1503238554,"activity_count":1}
]
}observed-correlation is deliberately not a causal verdict. The console says
so beside every explanation and preserves the underlying activity rows for
operator review. New episodes report activity_status: "settling" for at
least 30 seconds. Reads re-enrich and persist that evidence so events which
reached SQLite after the pressure capture are included; a successful refresh
after the settling window, once the daemon has stored or skipped ES events
from the capture time on (or ten minutes after capture), marks the episode
complete.
observe only annotates sessions. With a configured ladder, prompt applies
notify automatically and adds an approval to control.pending for each
state-changing step. Resolve it with
POST /resources/control {"id":"resource-1","decision":"apply|dismiss"}.
Resume a paused family with
POST /resources/control {"session_key":"β¦","decision":"resume"}.
terminate mode executes every configured step automatically. Priority,
pause, resume, and termination always target the complete recognized session
family with a fresh process-start identity check immediately before action.
Failed steps stop escalation and enter cooldown; no later destructive step is
silently skipped to. Without an interventions list, the legacy behavior is
preserved: prompt requests termination approval and terminate invokes the
recognized-agent containment path automatically. Termination is never enabled
by default. Policy changes, operator decisions, automatic attempts, and
failures are recorded in /audit.
Retrieves recent security correlation flags.
limit(optional, integer): Maximum number of flags to return (default:50).
GET /flags?limit=10 HTTP/1.1
Host: unix[
{
"id": 42,
"rule": "sensitive-read-then-connect",
"severity": "high",
"pid": 58210,
"process_name": "fake-cursor",
"details": "PID 58210 (fake-cursor) read sensitive file /Users/dev/project/.env and opened network connection to 192.168.1.50:443",
"timestamp": "2026-08-12T19:42:00-04:00"
}
]processβ the raising process as it was when the flag was raised, kept after the process exits:{exe, name, args0, ppid, launcher}.args0is argv[0] with secret-shaped values scrubbed; no further argv, no environment.launcheris the nearest app bundle above the harness root, then the harness root ("Claude.app βΊ claude-code 2.1.281"). Absent on flags raised before the field existed. Also onGET /flags/{id}/explain.evidence[].chainβ recorded ancestor process IDs on file reads and connections; used to distinguish ancestor correlation from descendant activity.evidence[].ownersβ onreaditems, the orgscredential_ownersnames for the file;subisagent tool readwhen an agent tool (hook-reported) read it, elsesensitive read. Newconnectitems also recordexewhen the event or process tagger identifies the connecting executable.evidence[].pid,evidence[].exeβ onreaditems, the process that opened the file; onconnectitems (pidonly), the process that connected. The reader may differ from the flag'spid. Absent on flags raised before the fields existed.repeats,last_seenβ later occurrences of the samesensitive-read-then-connectpattern (agent, reader executable, first file read, first destination's org, else its host) within an hour of the flag, folded into it instead of raising new flags;last_seenis the newest. Each fold re-publishes the flag delta. Patterns count a flag as1 + repeatsoccurrences.status.expected_flagsβ connections an operator-expected pattern covered (/expected). Counted, never flagged.status.credential_owner_usesβ connections judged a credential used with its owner (credential_owners, docs/CONFIGURATION.md): the process that opened the file (not an agent tool read), one of its ancestors or one of its descendants connected to an org that owns it. Counted, never flagged; any other connection in the same window is still cited.- Reads that seed no
sensitive-read-then-connectflag: an open that is not a read of a regular file (a directory, or a write-only open); a read by a programcredential_ownerslists inprogramsfor that path; and a read of a file the same agent root's tree opened for writing within the last ten minutes, matched by path, inode and birth time. Acredential_ownerspath is never own data. Opens that carry no flags or file mode count as reads. ack_reasonβ why the daemon acknowledged the flag itself. At start the daemon acknowledges opensensitive-read-then-connectflags none of whose reads counts as a secret read, with reasonreclassified at start: β¦and oneflag-reclassifyaudit entry. Not a secret read: a glob that no longer counts (guard rules withread_sensitive: false:shell-rc,harness-config), a.envtemplate, anot_secret_pathsdirectory, the macOS trust store (system-trust), a credential file read by a program itscredential_ownersentry lists inprograms(the read item'sexe), and a keychain file opened by anything but a byte-copy tool (cat,cp,ditto,tar,curl,base64, β¦). Empty when the operator acknowledged.
GET /flags stamps explain (below) on the first 25 flags, including acknowledged history, without network lookups (endpoint identity comes from the CIDR/suffix tables and the reverse-DNS cache only). Rows past the cap stay raw; their individual explain route remains available. /snapshot stamps its flags the same way.
GET /reviews?limit=100&after={cursor}&state={state} returns reviews, an optional next cursor, and degraded. States are unreviewed, reviewed, and closed_reported; omit state for history. Each page contains at most 100 records. Review records link detector flags and incident reports; they contain no copied payloads or permission grants. A served flag's optional review_id identifies its matching projection. Missing or stale projections fall back to source findings.
POST /reviews/decision accepts {"id":"review-id","revision":1,"action":"acknowledge"} or action close_reported. The decision and source acknowledgment are atomic. Identical requests return the same receipt. HTTP 409 means the evidence changed: refresh the facts and require a new explicit choice. HTTP 404 means the review or its source evidence is unavailable. These routes exclude agent peers and use the existing operator/console mutation gate.
Repeated read/connect evidence updates one review only when the stored session identity and exact reader/resource/destination coordinates match. Counts and timestamps do not reopen it; stronger evidence or identity promotion does. Unknown attribution remains source-specific. A new destination or port creates a separate review. Review and reported closure preserve observed risk and never verify remediation. Legacy receipts are labeled with their source; absent historical timestamps are not invented. Expired source evidence remains explicitly unavailable.
explain.assessment separates detector evidence from workflow and optional advice. Its fields are evidence_basis, risk (informational, review, high, critical, unknown), control, residual_risk, review_state, optional recommendation_id, reason, limits, and optional advice. Acknowledgment changes review_state to reviewed; it does not remove exposure, lower risk, or verify remediation. Legacy incident closure is user-reported. The existing disposition and action recommended fields remain for older consumers; current clients use the assessment and its recommendation ID.
For sensitive-read/connection findings, OS reads and model-visible tool reads are distinct. A later connection by the reader or its descendant is stronger than sibling timing, but neither proves that file bytes were transmitted. Unsupported or legacy text stays qualified as unknown; it cannot establish a payload match. control remains unknown until evidence establishes the operation's actual outcome. Optional advisor opinion never changes detector enforcement or the current risk projection. Patterns and routine groups carry the same assessment for their strongest member, with review state derived separately from open members.
Returns one flag (with title and advisor) plus explain, the daemon's plain-language reading of it. Destinations may take one bounded reverse-DNS lookup. 404 for an unknown id or any other shape under /flags/; 405 for a non-GET. Console-admitted on the proxy listener in exactly this shape (non-empty id, not ./..). POST /flags/acknowledge is a separate exact route and is unaffected.
{
"id": "3f9c2a1b7d4e6f80",
"rule": "sensitive-read-then-connect",
"title": "Agent read a secret, then connected out",
"explain": {
"what": "Claude read a sensitive file in Claude skills (~/.claude/skills), then reached AWS 3 s later.",
"subject": {"path": "/Users/me/.claude/skills/β¦/config", "display": "~/.claude/skills/β¦/config", "basename": "config",
"category": "other_sensitive", "category_label": "sensitive file", "owner_label": "Claude skills (~/.claude/skills)"},
"egress": [{"host": "2600:1f10:β¦:fd73", "port": 443, "org": "AWS", "kind": "ipv6", "allowlisted": false, "gap_seconds": 3}],
"context": {"session_id": "β¦", "harness": "claude", "repo": "api", "branch": "main", "tool": "Read", "tool_status": "ok", "tool_at": "β¦", "model": "β¦"},
"disposition": {"state": "benign-likely", "text": "Likely benign (advisor 93 %)", "why": "<advisor rationale, first sentence>"},
"actions": [{"id": "allow-host", "label": "Allow 2600:1f10:β¦:fd73 (AWS) for claude", "consequence": "β¦",
"method": "POST", "path": "/allowlist", "body": {"agent": "claude", "host": "2600:1f10:β¦:fd73"}, "recommended": true}]
}
}| Field | Content |
|---|---|
what |
One sentence per rule; no pids; the destination's org over its address. sensitive-read-then-connect names the reading process before the agent when the read item's exe is not the agent itself (gh (Claude) read β¦). |
subject |
The file from the first read/keychain/transcript item (or a violation carrying a path). category: env_file, ssh_key, aws_credentials, keychain, keychain_system_trust, other_sensitive, transcript. owner_label: Claude skills (~/.claude/skills), Claude Code config (~/.claude), Cursor config, opencode config, repo <name> (under the session workspace), temp directory, home directory, system. display: ~-abbreviated, middle-truncated to 64 runes. |
egress |
Every connect item, deduped by host:port, in evidence order. org/name/kind from the endpoint identity table. allowlisted: the host is approved for the flag's agent (exact or dot-suffix match). gap_seconds: connect time β read time (negative when the connection came first; 0 without a read item). |
context |
The flag's session (harness, repo, branch, workspace), the same-session tool call nearest the read time within Β±60 s (tool, tool_status, tool_at), and the nearest model call within Β±60 s (model). Absent when the flag has no session. |
disposition |
One verdict, in precedence order: acknowledged ("Reviewed") β benign-likely (advisor benign with confidence β₯ 0.85; "Likely benign (advisor N %)", why = the rationale's first sentence) β critical (severity β₯ 3, "Act now") β warning ("Needs a look"). why is otherwise the rule title, except sensitive-read-then-connect with a recorded reader: a neutral read/connection observation with configured credential destinations, an unconfigured credential-owner observation, <org> owns <file>, but an agent tool read it into the model's context., or a different-process observation without asserting that bytes were transmitted (owners from the read item's owners). |
actions |
In order, only those that apply: expect (sensitive-read-then-connect with a recorded reader whose pattern is not expected yet, unacknowledged; label Expected: <reader> β <destination>, POST /expected {"flag_id", "path"?, "host"?}; see Expected secret reads), review-local (selected stored finding into local Ollama review, no execution), expect-file (exact test/non-secret .env exception for this agent), allow-host (per destination host not yet allowlisted), allow-path (env/ssh/cloud/keychain files not already approved for that agent and guard rule; guard rules env-files, ssh-keys, cloud-creds, keychain), mute-rule-host (first destination POST /mute accepts β IPv6 literals are not), mute-class (keychain rules, host: "*"; label "Mute keychain access for "), both with the flag's agent in body when it has one, open-incident (an incident holds the flag), dismiss (unacknowledged), kill (the pid is a live agent). Each carries the request (method, path, body) and a one-line consequence. The console acknowledges the current flag after a successful allow-host or allow-path request; future suspicious behavior remains monitored. recommended marks the action matching the advisor's suggested_action (allow-host β first allow-host; mute-rule β mute-rule-host, else mute-class; kill-agent β kill; rotate-credentials β open-incident). |
What the daemon knows about one evidence file, and two local actions on it. path must be absolute and clean, and a stored flag's evidence, an incident's touched_files or an agent-session file event must name exactly that string; any other path is 404. The daemon never reads or opens a path outside stored evidence. NoAgent routes (see Peer authentication).
GET /files/detail returns:
| Field | Meaning |
|---|---|
path, display |
The path and its ~-abbreviated, middle-truncated form. |
exists, size, mod_time, owned_by_user |
From stat; a file deleted since the flag has exists: false and nothing is read. |
subject |
Category, category label and owner label, as in the flag explanation. |
session |
The session that touched it or owns the transcript. |
findings |
Up to 20 flags and 20 incidents naming the path: kind, id, rule, severity or risk, time, agent, session; flags carry the matched evidence item's kind, rule and line offset. |
accesses |
Up to 20 agent-session open/write/delete events on the path. |
hits |
Up to 3 transcript secret hits: flag id, rule, byte offset of the line (0 = recorded before offsets; the daemon scans the first 64 MiB to find it). |
excerpt |
At most 4 KB: 1.5 KB either side of each masked secret, every fingerprint and pattern hit replaced by [REDACTED:<rule>]. Text away from a mask marker is never shown. |
excerpt_withheld |
Why there is no excerpt: masking unavailable, a secret still detected after masking (an encoded copy), or the line not found. |
POST /files/reveal {"path": "β¦"} runs open -R (Finder selects the file); POST /files/open {"path": "β¦"} runs open -t (the default text editor, so nothing is executed; folders are 400). A deleted file is 410; off macOS both are 501. Each success writes an audit entry (file-reveal, file-open) with the path.
What to do about one finding. subject is flag:<id>, incident:<id> or file:<path> (a path stored evidence names); anything else is 404. NoAgent route.
GET returns subject, playbook (the rule's fixed response: title, why, now, prevent steps with a kind of guard-rule, config, secret-hygiene, agent-instruction or workflow, and served actions), flag (the subject's flag with its explanation, for the action buttons), advisor_ready and reason, plan when one is stored, and status: none, pending, ready, stale (new evidence since the plan was written; the old plan is still returned) or disabled.
POST {"subject": "β¦"} builds the plan context on this machine (see the advisor threat model), queues it for the local advisor and answers 202 pending; while a plan for the subject is pending a second request queues nothing. 409 disabled with the reason when the advisor is off, not loopback, paused or its queue is full.
The plan: summary, why (β€ 4), risk (low, medium, high), prevent (β€ 5 steps), behavior (β€ 3), remediate (β€ 4), actions (only the offered served action ids), confidence, model, created_at, evidence_key. Output off that schema is dropped and the playbook stands alone.
An operator judgment on a finding's subject: {"subject": "flag:<id>|incident:<id>|file:<path>", "label": "ok|not_ok", "reason": "β¦", "source": "mark|kill"} (reason β€ 200 characters; source defaults to mark). NoAgent route. Unknown subject 404, other values 400.
Labels are also written by the daemon: POST /allowlist (ok, allow-host), POST /mute (ok, mute), POST /expected (ok, expect, pattern = the file), POST /guard/path-allow (ok, allow-path), POST /guard/resolve (guard-allow ok / guard-deny not ok, from the pending prompt's agent, rule and path). Acknowledging a flag writes none. A label is keyed by rule, agent and pattern (the evidence path, else the destination host); the newest 5,000 are kept.
Where they show: a flag's explain.labels counts ok and not_ok on the same case (same agent and pattern, or same rule and agent without a pattern); /advisor/plan carries labels (summary, up to 5 similar ranked exact case β agent and pattern β rule and agent β pattern β rule, and a suggestion after 3 consistent labels: ok β the offered allow-path or allow-host; not ok β the offered kill and the playbook's guard rule); plan and triage prompts carry the similar labels.
Repeating findings: the flags one agent raised under one rule on one subject in the window, as one row each. Read-level; console-allowed.
| Query | Meaning |
|---|---|
hours |
Window ending now, 1..720, default 24. |
min |
Flags a pattern needs, >= 2, default 3. |
400 for hours or min out of range. Sorted by unacked, then count, descending; [] when none.
| Field | Meaning |
|---|---|
key |
agent|rule|subject; the id of the pattern's /posture item. |
agent, rule, title |
Agent, rule id, served rule title. |
subject |
Evidence item: the file explain.subject names (label = display path, sub = category), else the first destination host (kind: "connect", sub = org), else empty. |
count, flags, unacked |
Occurrences in the window (each flag plus its repeats); flags; unacknowledged flags. min compares against count. |
first, last |
First and last flag timestamps. |
median_gap_s, bursts |
Median seconds between consecutive flags; gaps under 5 s. |
cadence |
Phrase for median_gap_s: in bursts under a second apart, in bursts a few seconds apart, about every N seconds (or minutes, hours). |
hourly |
24 equal buckets over the window, oldest first (one hour each at hours=24). |
pids, pid_count |
Busiest 5 pids; distinct pids. |
sessions, session_count |
Busiest 5 session ids; distinct sessions. |
processes |
Distinct raising processes {name, launcher, count} from the flags' process, count = distinct pids; busiest 5. [] when no flag carries one. |
destinations |
sensitive-read-then-connect only: {org, host, count} per org (else host) the flags' connect items reached, count = flags citing it; busiest 5. |
disposition |
Worst among unacknowledged flags (critical > warning > benign-likely); acknowledged when unacked is 0. |
summary |
One sentence: agent, action, count, local time window, processes and sessions, cadence. No flag ids. sensitive-read-then-connect: <reader> (<agent>) read <file>, then reached <org> (<host>) [and N more destinations] β¦. |
actions |
explain.actions shapes, in order, only those that apply: allow-host (egress subject not yet allowlisted), mute-rule-host (egress subject) or mute-class (keychain rules), both with the pattern's agent in body, dismiss-all (POST /flags/acknowledge {"flag_ids"}, the open ids, at most 500), kill (busiest live pid). recommended: benign-likely β allow-host, else the mute; critical β kill. |
flag_ids |
Covered flag ids, open first, newest first, at most 500. |
/snapshot carries patterns (24 h, min 3) next to flags.
/snapshot also carries routine: open sensitive-read-then-connect flags of the last 24 h grouped by reader and area across agents, kept when a group holds at least 3 flags and spans agents or files, most flags first. The area is the home dot-directory the read sits under (~/.docker), else the exact file. The attention queue shows each group once, as a routine item in group routine, ahead of agent groups of equal priority; the flags it covers leave the agent groups.
| Field | Meaning |
|---|---|
key |
`routine |
reader, area, files, count, agents |
Reader label ("" when the reads recorded none); the one file's display path, or the area; distinct files; open flags; raising agents, most flags first. |
destinations, destination_count |
Busiest 5 {org, host, count}; distinct destinations. |
expectable |
Flags whose every read is the group's reader on its area. |
disposition, summary |
Worst open flag's; one sentence: reader, file or area, destinations, count, agents. |
actions |
expect-all (POST /expected {"flag_ids"}, the expectable ids, at most 500; absent when none) and dismiss-all (POST /flags/acknowledge {"flag_ids"}, at most 500). |
flag_ids |
Covered flag ids, newest first, at most 500. |
{"flag_id":"<id>"} β {"status":"ok","acknowledged":<bool>}, or {"flag_ids":["<id>",β¦]} (1..500 ids) in one transaction β {"status":"ok","acknowledged":<bool>,"count":<rows>}. Ids match ^[A-Za-z0-9_.-]+$. 400 for both fields, neither, more than 500 ids, or an invalid id.
Retrieves raw system telemetry events captured by the file watcher and network sampler.
File opens, writes and deletes are stored for processes inside an agent family, and otherwise only as the evidence of a flag.
Trace rows (kind 12 tool call, 13 turn, 14 model call) carry pid 0 and session_id.
A model call carries model, tokens_in, tokens_out, cost_usd and price_class (priced, plan, local, unknown-model or unpriced-model); price_class is set when served, never stored.
limit(optional, integer): Maximum number of events to return (default:50).kind(optional, integer): Only events of this kind.pid(optional, integer): Only events for this pid; values<= 0are ignored.since(optional, string): Only events withtsat or after this timestamp.session_id(optional, string): Only events attributed to this session. Withoutpage, the response remains the event array below.
GET /events?limit=20 HTTP/1.1
Host: unix[
{
"kind": 1,
"ts": "2026-08-12T19:41:59-04:00",
"pid": 58210,
"session_id": "sess-abc123",
"path": "/Users/dev/project/.env"
},
{
"kind": 14,
"ts": "2026-08-12T19:42:04-04:00",
"pid": 0,
"session_id": "sess-abc123",
"model": "claude-sonnet-4-5",
"provider": "anthropic",
"tokens_in": 12000,
"tokens_out": 340,
"cost_usd": 0.0412,
"price_class": "priced"
}
]| Field | Meaning |
|---|---|
kind |
Event kind: 0 open, 1 write, 2 delete, 3 exec, 5/6 connect open/close, 7 TCC modify, 8 plugin tool-use, 9 proxy hit, 10/11 guard prompt/resolved, 12 tool call, 13 turn, 14 model call. |
ts, pid, session_id |
When it happened, the process, and the agent session (trace rows carry pid 0 and session_id). |
path, exe_path |
File or executable path, for file and exec kinds. |
remote_host, remote_port |
Destination, for connect kinds. |
tool, tool_status, duration_ms, call_id |
Tool call fields (kind 12): name, ok|error|running|incomplete, startβresult duration, the harness's own call id. incomplete marks a Claude/Codex pairing retired after 24 h or beyond the 1,024 pending-call limit per transcript; it never overwrites a recorded completion. |
model, provider, tokens_in, tokens_out, cost_usd |
Model call fields (kind 14). A Claude model call also carries call_id: the API message id, one row per call. |
price_class |
priced, plan, local, unknown-model or unpriced-model; computed when served, never stored. |
Use GET /events?page=1&session_id=SESSION_ID to read retained records in pages. This opt-in response is an envelope rather than the legacy event array. It supports ended sessions without a live process family.
| Parameter | Contract |
|---|---|
page |
Required value 1 to select pagination |
session_id |
Required, nonempty, at most 512 bytes |
limit |
Positive integer; defaults to 200 and is capped at 200 |
kind, pid |
Optional nonnegative integers |
since |
Optional RFC 3339 timestamp, including fractional seconds; normalized to UTC |
before |
Opaque next_cursor from a previous page; retain the same session, kind, PID and time filters |
{
"session_id": "sess-example",
"rows": [
{
"id": "42",
"event": {
"kind": 0,
"ts": "2026-10-10T00:00:00Z",
"pid": 1234,
"session_id": "sess-example",
"path": "/Users/dev/project/README.md"
}
}
],
"has_earlier": true,
"next_cursor": "<opaque cursor>"
}The example cursor is a placeholder; use the value returned by the daemon. Row IDs are decimal strings ordered by retained database identity descending, not occurrence time or causal order. Continue with before=next_cursor while has_earlier is true. When false, next_cursor is omitted. Retention can remove older records; reaching the last page does not establish complete historical coverage.
Duplicate page parameters, malformed filters, invalid cursors and cursors from a different filter scope return 400. Unavailable event data returns 503, preserving the distinction between failed reads and an empty page.
Terminates an active agent process tree by PID using SIGKILL.
POST /kill HTTP/1.1
Host: unix
Content-Type: application/json
{
"pid": 58210
}{
"status": "ok",
"pid": 58210
}HTTP/1.1 500 Internal Server Error
Content-Type: text/plain; charset=utf-8
Kill failed: process not foundTo query the API from the command line:
# Check daemon status
curl --unix-socket ~/.config/secure-agent/daemon.sock http://unix/status
# Get recent security flags
curl --unix-socket ~/.config/secure-agent/daemon.sock http://unix/flags
# Terminate process 12345
curl -X POST --unix-socket ~/.config/secure-agent/daemon.sock \
-H "Content-Type: application/json" \
-d '{"pid": 12345}' \
http://unix/killReturns rotation-intel incident reports. ?id=ID fetches one (&format=markdown renders the remediation checklist as markdown); without id, lists recent reports (?limit=N, default 50).
Returns the policy audit trail (rule promotions, fingerprint ingest, guard-rule changes). ?limit=N, default 100.
Returns this node's fleet-telemetry summary: hostname, OS/arch, build version (set via ldflags; dev on untagged builds), node_id, running state, active agents, recent flag count, proxy status.
GET /firewall/patterns returns active pattern definitions with effective modes. POST /firewall/patterns accepts {"op":"add","pattern":{"id":"custom-key","type":"vendor-key","re":"example_[A-Z]{16}","mode":"monitor"}}, {"op":"edit","id":"custom-key","pattern":{...}}, or {"op":"remove","id":"custom-key"}. IDs remain stable during edits. Mode changes use /firewall/mode. Validated patterns are saved to firewall.patterns in the config overlay and applied to new inspections immediately. In-flight streamed requests retain their detector snapshot. Invalid edits or failed persistence retain the running policy.
GET /guard/config returns the effective {"rules":[],"dir_scan":[]} document. POST /guard/config uses add/edit/remove operations with a rule object: {"id":"private-files","paths":["~/private/**"],"mode":"monitor","read_sensitive":true}. The user policy replaces the shipped rule list and is stored as guard-rules.json beside the daemon socket. Added rules take precedence over existing rules; the first matching path rule applies. The hook reads it on each invocation; corrupt policy denies operations instead of reverting to defaults. Restart the daemon after path edits to update background file correlation. Existing per-rule and per-workspace mode overrides retain precedence.
Both endpoints require a non-agent owner or the pinned UI on the Unix socket. They are unavailable on the browser console listener. Removing a rule removes its detection/protection; independent built-in shell safety checks still apply.
Promotes or demotes a firewall rule at runtime and persists the override. Payload: {"rule":"<id>","mode":"monitor|block"}. Owner-role only.
Re-applies persisted secret fingerprints to the running engine.
Scans configured ingest sources, registers HMAC fingerprints (never plaintext), applies them live, returns registered labels.
Lists (GET) or edits (POST, {"source":"<path>","op":"add|remove"}) the fingerprint ingest sources. Config-defined sources are read-only; adds are validated against system paths.
A hook's prompt-mode query. A cached (agent, rule) decision returns instantly (reason:"cached"); otherwise a pending prompt is enqueued and the request blocks until the menubar resolves it or the broker deadline elapses (fail-safe deny). Payload: {"agent","tool","path","rule_id"}; agent/rule_id must match ^[A-Za-z0-9_.-]+$.
Returns the queued guard prompts oldest-first. Each item carries a scope_text disclosing what "Allow Always" would approve.
Resolves a pending prompt: {"id","verdict":"allow|deny","scope":"once|always"}.
Lists stored guard decisions (GET); revokes one (DELETE ?agent=&rule_id=), forcing a fresh prompt next time.
GET|POST|DELETE /guard/path-allow lists per-path exceptions (GET), adds one (POST {"agent","rule_id","path"}) and revokes one (DELETE ?agent=&rule_id=&path=).
- Console-admitted on the proxy listener.
POSTis a mutation: the pinned UI, or the owner uid when no UI is pinned.DELETEstays owner-level.
Model-call spend over a window, grouped by one dimension, across every traced harness.
GET /costs?since=24h&by=repo
| Param | Values | Default |
|---|---|---|
since |
lookback (24h, 90m, 7d) or RFC3339 timestamp |
24h |
until |
RFC3339 timestamp | now |
by |
repo, branch (repo@branch), harness, session, model, provider, day (YYYY-MM-DD) |
repo |
tz |
local offset in minutes east of UTC, -840..840 (by=day buckets on this calendar day) |
0 |
cached |
1: answer at once from the usage cache (see below) |
off |
A malformed since/until, an unknown by or a tz that is not a whole number in range returns 400 with a one-line body.
{
"since": "2026-09-21T12:00:00Z",
"until": "2026-09-22T12:00:00Z",
"by": "repo",
"total": {"key": "", "calls": 4, "sessions": 3, "tokens_in": 400, "tokens_out": 40,
"cost_usd": 0.85, "unpriced_calls": 1, "unknown_model_calls": 0,
"unpriced_model_calls": 1, "plan_calls": 0, "local_calls": 0},
"rows": [
{"key": "api-service", "harness": "claude", "calls": 2, "sessions": 1,
"tokens_in": 200, "tokens_out": 20, "cost_usd": 0.75, "unpriced_calls": 0,
"unknown_model_calls": 0, "unpriced_model_calls": 0, "plan_calls": 0, "local_calls": 0},
{"key": "(no repo)", "harness": "codex", "calls": 1, "sessions": 1,
"tokens_in": 100, "tokens_out": 10, "cost_usd": 0, "unpriced_calls": 1,
"unknown_model_calls": 0, "unpriced_model_calls": 1, "plan_calls": 0, "local_calls": 0}
],
"generated_at": "2026-09-22T12:00:00Z"
}Rows are sorted by cost, then calls (at most 200); by=day rows run oldest first (the newest 200 days); rows is [] when the window is empty. harness is the harness with the most calls in the group (omitted for by=harness). Missing repo, branch, harness or model values group as (no repo), (no branch), (unknown). unpriced_calls counts the calls a price entry could fix (unknown_model_calls + unpriced_model_calls); plan and local calls are counted apart; a cost is never estimated. Read-level. CLI: secure-agent cost [--since 24h] [--by repo] [--tz <minutes>] [--json] (--tz defaults to this machine's offset; day rows print oldest first); the table view adds a plan: P Β· local: L Β· unpriced: U β K unknown model, M unpriced model line when any is non-zero and one add a price for <model> under pricing: in ~/.config/secure-agent/config.yaml line per unpriced-model id.
Usage cache: a report is kept per parameter set as asked (since=24h is one set however late it is asked) and answers the same parameters again for 30 s; generated_at is when it was computed. Without cached, a report older than that is computed again before the answer. With cached=1, the last report for the parameters answers at once however old, and one older than 30 s is computed again in the background (refreshing: true until then; ask again for it). A cold cache also answers immediately with refreshing: true, empty rows and no generated_at: its zero counters are a loading placeholder, not computed usage. The 32 most recently asked reports are saved in the store, so a restarted daemon answers cached=1 from them. The console keeps saved results visible with a quiet refresh status.
Every row and the total count their zero-cost calls by price class:
| Field | Class | Meaning |
|---|---|---|
unknown_model_calls |
unknown-model |
the harness recorded no model id |
unpriced_model_calls |
unpriced-model |
model id known, no price entry: add one under pricing |
plan_calls |
plan |
subscription provider (chatgpt, kimi-for-coding, kimi-code-plan-global); not in unpriced_calls |
local_calls |
local |
local runtime (ollama, lmstudio, lm-studio, llama.cpp, mlx, or a loopback provider); not in unpriced_calls |
A Codex model call on a ChatGPT-plan login (its token_count line reports rate_limits.plan_type) records provider chatgpt; an API-key login keeps the rollout's provider; rows stored before this rule keep theirs. A zero-token call of a priced model is in no class counter and not in unpriced_calls. A price entry wins over the provider: a priced model is priced whatever the provider. A vendor-prefixed id (z-ai/glm-5.3-flash) is looked up without its prefix when the full id has no entry. by=model rows also carry provider (the provider with the most calls for the model; omitted when none is recorded) and class (priced when every call carries a cost, else the model's class).
by=provider keys each call by:
- the provider the harness recorded (
openai-codex,kimi-for-coding), kept as written; - else the vendor whose built-in price table resolves the model id (
anthropic,openai,google), by the same exact/dated/vendor-prefix rule as the price; the operatorpricingtable names no vendor; - else
(unknown).
Claude Code model calls record provider anthropic. At most 500 distinct unrecorded model ids are resolved per report; the rest group as (unknown). sessions stays a distinct count per provider.
The zero-cost calls by harness, provider and model with their class; priced groups are left out. Same since/until as /costs. Sorted by calls; rows is [] when nothing is unpriced. Read-level; console-allowed.
{
"since": "2026-09-22T06:30:00Z",
"until": "2026-09-23T06:30:00Z",
"rows": [
{"harness": "opencode", "provider": "kimi-for-coding", "model": "k3", "class": "plan",
"calls": 12, "tokens_in": 48000, "tokens_out": 2100},
{"harness": "codex", "provider": "custom", "model": "gpt-5.6-sol", "class": "unpriced-model",
"calls": 3, "tokens_in": 9000, "tokens_out": 400}
]
}The newest observed plan headroom per identified Codex account and limit, read from token_count lines on a ChatGPT-plan login. Homes sharing a verified account use the latest snapshot once; percentages are never summed or averaged. account_key is an opaque account identity fingerprint and homes lists the sharing home labels. Unknown identities remain separate; current API-key logins and snapshots from a previous account are excluded. Per-home observations remain saved in the store; a restarted daemon restores snapshots seen in the last 7 days until a newer line replaces them (seen_at says when a snapshot was read). Read-level; console-allowed; other methods return 405.
{
"plans": [
{"harness": "codex", "home": "codex", "home_path": "/Users/dev/.codex", "plan_type": "pro", "limit_id": "codex",
"windows": [{"window_minutes": 10080, "used_percent": 52, "resets_at": "2026-09-29T14:30:38Z"}],
"unlimited": false, "seen_at": "2026-09-24T10:00:00Z"}
]
}| Field | Meaning |
|---|---|
home |
codex for a .codex home, <name> (openclaw) for β¦/.openclaw/agents/<name>/agent/codex-home, else the home's directory name |
home_path |
the home directory the snapshot is keyed by; two homes can share a home label, never a home_path |
windows |
the primary window, then the secondary when reported; resets_at RFC3339, "" when not reported |
unlimited |
rate_limits.credits.unlimited |
seen_at |
timestamp of the line that carried the snapshot; the newest per home is kept |
The daemon's self-check: whether hooks, file telemetry, collectors and traces are producing data, and whether stored sessions and events are attributed, paired, priced and retained.
GET /doctor
Each check has a state of pass, fail or skip, a detail, and on fail a one-line fix (bus reports only and has none). grace is true while uptime is under 10 minutes; checks that need steady state then skip with detail inside the 10-minute boot window. checks is always an array, in this order:
id |
Fails when | Skips when |
|---|---|---|
config |
config.yaml could not be read or parsed in full at start (the settings it failed to set use built-in defaults until restart; firewall, guard and paths load only at start), or the latest hot reload was skipped (unreadable, unparsable or invalid), or the file changed start-only settings since start (detail changed since start, applied after restart: <keys>); a reload that reads the same problem as start is reported once; values the error quotes are replaced with a value, credentials are scrubbed, and the detail is one line |
β |
hook-registered |
~/.claude/settings.json does not register the guard hook for PreToolUse and PostToolUse |
home directory unknown |
hook-active |
agents are running and no hook event landed in 24h | no agents |
file-telemetry |
root ES service not-loaded, in a spawn/exit state, or running with agents active and the spool unwritten for over 10 min (past grace); a service launchd will not start (last exit 78, EX_CONFIG, or spawn scheduled after a nonzero exit) names Re-register in its detail |
file telemetry is not spool-based |
collectors |
a collector is stopped or abandoned, or (with agents active) silent; passes with each polling collector's database, watermark and last poll | grace |
trace-coverage |
a harness had sessions with a transcript line read or a hook event since boot, but no tool-call, turn or model-call rows were written since boot (whatever their own timestamps); a session whose process only stayed alive is not counted; passes listing each traced harness with its session count | grace, or no such sessions since boot |
hermes |
a Hermes state.db could not be read (detail names the database and error); passes with each database's message watermark and the last poll time |
no state.db under the Hermes root (not installed) |
session-identity |
under 80% of sessions carry a harness | no sessions |
session-repo |
under 50% of named sessions with a workspace since boot carry a repo, or named sessions since boot carry no workspace at all | grace, or no named sessions since boot |
session-rate |
sessions created in the last hour exceed 2 Γ agents + 10 | grace, or no agents |
tool-pairing |
any (session_id, call_id) pair is stored twice, or a tool-call row since boot has no call id |
β |
pricing |
under 90% of claude-* model calls carry a cost (detail also reports unpriced calls over all models) |
no Claude model calls |
retention |
a row cap keeps under 24h: any kind's record rows (a flag's own event, a file event that counts as a secret read) at their 20,000-row budget, or a kind other than file-open, file-write, file-delete and exec at its row budget; passes naming how far back those four kinds' newest rows reach |
β |
egress-routing |
the proxy is on and endpoints were reached outside it (proxy off passes as proxy off β egress not inspected); a pass names the connections routed since start and how many were decrypted |
β |
bus |
subscribers dropped events on full buffers | β |
storage |
an evidence write failed since start (detail names operations still failing, or says new writes recovered) | storage health unavailable |
{
"generated_at": "2026-09-23T09:00:00Z",
"version": "0.9.0",
"uptime": "3h12m4s",
"grace": false,
"summary": {"pass": 11, "fail": 1, "skip": 1},
"checks": [
{"id": "hook-registered", "title": "Guard hook registered", "state": "pass",
"detail": "registered for PreToolUse and PostToolUse"},
{"id": "file-telemetry", "title": "File telemetry", "state": "fail",
"detail": "root service state: spawn scheduled",
"fix": "System Settings β Privacy & Security β Full Disk Access β Secure Agent, or the Setup card"},
{"id": "pricing", "title": "Model-call pricing", "state": "skip", "detail": "no Claude model calls"}
]
}(The example shows three of the sixteen checks.) Read-level. CLI: secure-agent doctor [--json] prints one PASS/FAIL/SKIP line per check, a fix: line under each failure and a summary line, and exits 1 when any check fails.
What one session did β tools, models, spend, files, hosts, guard decisions, findings and secret-rule hits β as JSON or markdown. It carries tool names, model ids, paths, hosts, rule ids and counts; never content or matched text.
GET /sessions/{id}/report?format=json|md
format defaults to json; md returns Content-Type: text/markdown; charset=utf-8. An unknown session id, or any /sessions/{id}/β¦ leaf other than timeline, report, memory and overview, returns 404; another format returns 400.
{
"session": {"id": "7f3a9c21-β¦", "harness": "claude", "repo": "api-service", "branch": "main",
"started_at": "2026-09-23T09:00:00Z", "last_seen_at": "2026-09-23T09:42:10Z",
"status": "active", "confidence": "hook"},
"duration_s": 2530, "events": 214,
"turns": 6, "tool_calls": 41, "model_calls": 19,
"tokens_in": 812000, "tokens_out": 9100, "cost_usd": 3.12, "unpriced_calls": 0,
"tools": [{"key": "Bash", "count": 22, "errors": 2, "duration_ms": 61000}],
"models": [{"model": "claude-sonnet-4-5", "calls": 19, "tokens_in": 812000, "tokens_out": 9100,
"cost_usd": 3.12, "unpriced_calls": 0}],
"files": [{"key": "/work/api-service/go.mod", "count": 4}],
"hosts": [{"key": "api.anthropic.com", "count": 19}],
"guard": [{"ts": "2026-09-23T09:10:02Z", "kind": "guard-resolved", "label": "allow/session"}],
"secret_hits": [{"ts": "2026-09-23T09:20:40Z", "kind": "transcript-hit", "label": "aws-key", "status": "typed"}],
"flags": [],
"timeline": [{"ts": "2026-09-23T09:00:04Z", "kind": "tool-call", "label": "Bash", "status": "ok", "duration_ms": 1500}]
}- Events are read oldest-first, at most 20,000 per report;
eventsis how many were read. toolsare sorted by calls (errorscountstool_status: "error",duration_msis summed);modelsby cost;files(file open/write/delete) andhosts(connections) by count, at most 50 each.secret_hitsare transcript hits:labelis the rule id,statusthe detection layer.flagsare the session's findings;timelineis the first 500 events, labelled by tool, model, path, host or detail.- Every list is
[]when empty, nevernull.
The markdown form:
# harness> Β· <repo>@<branch, or the workspace> β <started, local> β <ended | live> (<duration>)
Session `<id>` Β· <status> Β· identity: <confidence>
## Summary
- Turns N Β· tool calls N (E errors) Β· model calls N Β· tokens N in / N out Β· cost $X (N unpriced)
- Files touched N Β· hosts contacted N Β· guard decisions N Β· findings N Β· secret hits N
## Models table: model | calls | tokens in | tokens out | cost
## Tools table: tool | calls | errors | time
## Files touched - `path` Γ count (top 25)
## Network - host Γ count
## Guard decisions - HH:MM:SS kind label
## Findings - severity N Β· rule Β· timestamp
## Secret hits - rule Β· layer Β· HH:MM:SS
## Timeline - HH:MM:SS kind label [status] [duration] (first 100, then "β¦ N more")
Empty sections read none. Costs use the console's rule: two decimals, <$0.01 under a cent. Read-level; admitted on the proxy listener with the console token. CLI: secure-agent session <id-or-prefix> [--json] prints the markdown (or the JSON); a unique id prefix of at least 6 characters resolves, an ambiguous one lists its candidates and exits 1.
GET /sessions (the session list) narrows with exact-match harness, repo and branch, and since (24h, 7d or RFC3339, as /costs; a session matches when it started or was last seen at or after it), alongside status (active, idle, ended; default: live sessions, then the 25 most recent ended ones) and limit (default 100). A malformed since returns 400. CLI: secure-agent sessions [--harness H] [--repo R] [--branch B] [--since D] [--status S] [--limit N] [--json].
A session row carries origin when an agent spawned it: "<agent> (openclaw)" for a Codex session whose rollout is under an openclaw agent's Codex home (β¦/.openclaw/agents/<agent>/agent/codex-home). It is omitted for the user's own ~/.codex and every other harness, set on first sight, and kept by later updates that carry none. The same field is on /snapshot sessions and the /sessions/{id}/report session.
One session's retained history in time order: activity, findings, incidents, guard decisions and resource episodes. Rows carry allowlisted summaries only; raw paths, event detail, evidence and incident narratives stay in their source tables.
GET /sessions/{id}/memory?limit=200&before=<cursor>
{"rows": [{"id": "β¦", "at": "2026-09-27T14:02:11Z", "kind": "incident", "title": "Incident: β¦", "detail": "3 related findings", "severity": "high", "status": "open"}],
"has_earlier": true, "next_cursor": "β¦"}kindβactivity,guard-audit,flag,incident,guardorresource.limitβ 1β500, default 200; a larger value is capped at 500.beforeβ thenext_cursorof the previous page; the next page is older.rowsare oldest first within a page;next_cursoris set only whenhas_earlieris true.- An unknown session returns
404; a badlimitor cursor returns400.
Read-level; console-allowed. The Sessions tab shows it for the selected session.
Current access requests, retained findings, observed coverage and machine impact for one durable session. Read-level; console-allowed.
session_id,observed_atidentify the session and response time.requestscontains only pending guard requests carrying that exact session ID; existing approval endpoints and scopes apply.findingscontains the latest 20 session-attributed findings, including reviewed findings. Each carriesid,title,atand the existing findingassessment; review does not erase risk.findings_truncatedsignals additional history.coverageis the existing session coverage projection, ornullwithout an unambiguous live process match.resourcescarrieskey,observed_at,rss_bytes,cpu_percent,process_count,diagnosesand optionalcontrol. It requires the same root PID, start time and harness as the live session; otherwise it isnull.- Ended sessions retain findings without claiming live coverage or resources. Workspace similarity does not confer attribution.
- An unknown session returns
404; a failed session or findings read returns503. The console retains the previous response with a stale warning and disables its controls until refreshed.
The environment that routes Claude Code through the proxy, for the menu bar app to write into ~/.claude/settings.json. NoAgent: the answer carries the proxy token.
{"ready": true,
"env": {"HTTPS_PROXY": "http://inspect:<token>@127.0.0.1:8443", "HTTP_PROXY": "β¦", "https_proxy": "β¦", "http_proxy": "β¦",
"NO_PROXY": "localhost,127.0.0.1,::1", "no_proxy": "β¦", "NODE_EXTRA_CA_CERTS": "/Users/dev/.config/secure-agent/ca.crt"},
"bash_env_path": "/Users/dev/.config/secure-agent/agent-env.sh"}ready is false, with a reason, while the proxy is off, not yet bound, or without its token or snippet. /status carries proxy_tunneled and proxy_decrypted: routed CONNECTs since start, tunneled unopened and decrypted. See CONFIGURATION.md, Routing modes.
What the menubar's advisor setup renders from; loopback only, nothing is downloaded.
| Field | Meaning |
|---|---|
servers |
OpenAI-compatible servers answering on loopback ports 11434, 8080, 8799, 8081, 1234: endpoint, kind (ollama or openai-compatible), model ids, and for Ollama sizes (bytes, from /api/tags). |
managed_models |
The catalog ids the daemon can run itself with mlx_lm.server. |
machine |
chip, ram_bytes, free_disk_bytes (home volume). |
recommendations |
Installed chat models and the catalog, ranked for this machine: id, label, source (installed or managed), endpoint, bytes, fit, note, recommended. A model needs its size plus 20 %; fits within half the RAM, tight within three quarters, else too-big; unknown when a size is missing. The recommendation is the installed model of a catalog family that fits, else the best catalog model that fits, else the smallest tight one. Embedding, OCR, rerank, speech and music models, component checkpoints (encoders, decoders, tokenizers) and bare content hashes are left out; an uncensored, abliterated or heretic variant never counts as its catalog family. |
Every git worktree the daemon can find, with a verdict on whether it can be removed.
GET /worktrees
GET /worktrees?refresh=1
Repositories come from four sources: absolute session workspaces, the worktree directories agent apps use (~/.codex/worktrees/*/*, ~/.cursor/worktrees/*/*, ~/.claude/worktrees/*, ~/conductor/workspaces/*/*), worktrees.roots in the config (searched to depth 3), and the saved list (POST /worktrees/repos). Each repository's worktrees come from git worktree list; each repository's .worktrees, worktrees, .claude/worktrees, .claude/.worktrees, .codex/worktrees and .cursor/worktrees directories are also read for worktrees git no longer knows (orphan). Found repositories are saved.
The scan runs git read-only: GIT_OPTIONAL_LOCKS=0 (no index refresh), core.fsmonitor=false, no fetch, no network, no object writes. A GET answers from the last scan (cached: true); a scan older than 10 minutes still answers while a background rescan replaces it (refreshing: true). The last scan and the measured sizes are saved in the store, so a restarted daemon answers from them at once (a scan saved under other worktrees: options is not used). refresh=1 rescans and waits, unless the last scan is under 30 seconds old.
state |
Meaning |
|---|---|
main |
the repository's main worktree |
prune |
the directory is gone; git still lists it |
keep |
locked, conflicts, uncommitted changes, untracked files, detached-HEAD commits no ref keeps, or a live agent session under the path; a branch that is neither merged nor idle past stale_days; anything otherwise removable that was active in the last 24 hours |
review |
nothing git-tracked is lost, but something only lives here: precious ignored files (.env*, *.pem, *.key, .tmp/, .claude/, .remember/, with file count and size), commits on no remote and not in the default branch, stashes on the branch, an orphan directory, or an inspection error |
remove |
none of the above, no activity in the last 24 hours, and contained in the default branch (ancestor, squash, or no net change) or every commit on a remote and idle past stale_days |
Merge detection is local. The default branch is origin/HEAD's target, else the first of origin/main, origin/master, main, master. merged is ancestor (HEAD reachable from it), squash (the branch's zero-context diff from the merge-base has the patch id of one of the newest 1,000 non-merge commits on it), empty (no net change), content (compared with the same file on the default branch, or the one with the same name and the longest common trailing path including a directory: every line the branch adds is there at least as many times as it adds it, every line it removes is there no more often than at the branch tip, every added binary is a blob it has, every deleted file is gone and every changed file mode matches; the branch's merge-base, or the commit its reflog says it was created at when the histories share none, is the base; an added line of three or more words also counts when the file has a longer line, one the base did not have, holding all of its words, for up to 3 lines or a tenth of the lines, whichever is more), no, or unknown (no merge-base and no creation entry, a diff over 1,000 files, 100,000 added lines or 8 MiB, over 32 MiB of default-branch content to read, a type change or submodule pointer, or a git error; with a merge-base whose squash check said no, the answer stays no). content_lines counts the non-blank lines the branch adds, content_missing those the default branch lacks, content_extended those it has only in a longer form and content_other the removed lines or files, binaries and file modes that still differ, set when the content check ran to the end. unrelated_history is true when HEAD shares no commit with the default branch; its review reason then measures the lines added since the branch was created instead of counting the old history's commits. A merged row that is keep or review for another reason lists the merge as its last reason. stale is true when idle past stale_days, or merged or its upstream branch gone with no activity in the last 24 hours. Last activity is the newest of HEAD's commit time, the worktree index's mtime and the newest session seen under the path.
{
"generated_at": "2026-09-23T19:40:02Z",
"duration_ms": 36211,
"cached": false,
"stale_days": 14,
"summary": {"repos": 31, "worktrees": 131, "remove": 42, "review": 64, "keep": 21, "prune": 4, "stale": 114},
"repos": [
{"path": "/Users/me/code/app", "source": "session", "default_branch": "origin/main", "worktrees": [
{"path": "/Users/me/code/app", "branch": "main", "state": "main", "reasons": ["main worktree of the repository"], "idle_days": 0},
{"path": "/Users/me/code/app/.worktrees/ui", "branch": "feat/ui", "head": "518e4bb2c1d0",
"state": "review", "reasons": ["ignored files that only live here: .tmp/ (660 files, 207.3 MB)"],
"stale": true, "last_activity": "2026-09-23T06:30:41Z", "idle_days": 0,
"upstream": "origin/feat/ui", "merged": "squash", "other_ignored": 3,
"precious_ignored": [".tmp/ (660 files, 207.3 MB)"]}
]}
],
"errors": []
}Sizes: each linked worktree's size_bytes is the allocated size of its directory (st_blocks, symlinks not followed, other worktrees nested inside left out), measured by a background pass (two walks at a time, each bounded at 30 s / 1,000,000 entries β size_partial: true when a bound hit) and kept; a size older than an hour still answers while it is measured again (refreshing: true). A report taken before a worktree is first measured has sizing: true and lower-bound totals. size_bytes on each repository and summary.size_bytes / summary.removable_bytes sum the measured rows; volumes lists each disk holding a scanned repository (mount, total_bytes, free_bytes; volumes reporting the same capacity and free space, like APFS volumes in one container, are one row with their mounts joined by +); reclaimed sums the cleanup ledger (bytes, count, bytes_30d, count_30d).
Agent processes cannot reach any /worktrees* or /cleanup/* route (NoAgent).
Read-level. CLI: secure-agent worktrees [--state S] [--repo R] [--stale] [--refresh] [--json] prints one block per repository with a line per worktree (state, stale, idle days, branch, path) and its reasons, then the summary.
Adds the repository containing path to the saved list (source manual, unhiding it), or hides it from reports with "hidden": true.
{"path": "/Users/me/code/app/sub/dir"}
{"path": "/Users/me/code/app", "hidden": true}200 {"status":"ok","path":"<main worktree>"}; 400 for a relative path; 404 when path is not inside a git repository, or a hidden repo is not on the list. Mutation (pinned UI or owner). CLI: secure-agent worktrees add|hide <path>.
Removes one worktree, or prunes a repository's entries for worktrees whose directory is gone.
{"path": "/Users/me/code/app/.worktrees/done"}
{"path": "/Users/me/code/app/.worktrees/done", "async": true}
{"repo": "/Users/me/code/app", "prune": true}"async": true answers 202 {"status":"accepted","removal":{...}} at once and removes in the background; 409 when a removal of that path is already running; 400 for a relative path. Every removal, async or not, is listed in GET /worktrees under removals by path while it runs and for 30 minutes after: state (running, removed, failed), while running phase (waiting, checking, measuring, deleting, in that order), its step text (waiting for the current scan, checking it is still safe to remove, measuring, deleting) and step_at (when the phase began), error, and for a refusal row_state and reasons; branch, bytes and files (the measured tree, known from the deleting phase on), started_at, finished_at. git worktree remove runs under a 10-minute deadline (read-only git calls keep 10 seconds) and finishes even when the request is canceled. A removed worktree leaves the cached report at once, and a background rescan follows.
Remove measures the worktree (a fresh walk) and inspects it again at request time and runs git worktree remove only when that fresh verdict is remove; git still refuses a tree that turned dirty in between. A worktree with populated submodules is removed with --force (git refuses it otherwise): its verdict already requires the worktree and each submodule clean, and every submodule commit on a branch, HEAD or stash to be on a remote (submodules, and submodule_local naming work that only lives there, which keeps the worktree). The branch and its commits stay. Prune runs git worktree prune when the repository lists at least one unlocked worktree whose directory is gone. Both write an audit row and a cleanup ledger row (worktree-remove with the bytes measured before removal; worktree-prune with 0); a prune drops the cached scan.
{"path":"<absolute linked worktree>","head":"<listed HEAD>","reasons":["<listed reason>"]} is the console's Remove on a review or keep row. The hunter re-inspects the worktree under its scan lock and requires a keep or review verdict with the same HEAD and reasons. It refuses (409, the message names the reason) an inspection error, populated submodules, a live agent session (an agent session is live here), a lock, a detached HEAD whose commits no branch keeps (create a branch first), unresolved merge or rebase conflicts, files whose staged version differs from the working copy (git deletes the worktree's index with it; commit or unstage them first), a nested registered worktree, and any remove or prune row; 404 for an orphan folder or the main worktree, which are not linked worktrees. It moves the folder, uncommitted, untracked and ignored files included, to the same-volume Trash, then unregisters it from Git; the branch, commits, and stashes remain. If unregistering fails, it restores the folder, or reports its Trash location if restoration also fails. The result includes trash_path and bytes. 409 also means the listed facts changed; refresh and try again. Mutation, NoAgent.
{"path": "<absolute path>"} each. A folder whose .git file names a worktree record that no longer exists (its repository moved or was deleted) is listed as an orphan row under a group whose error is repository not found (moved or deleted); the errors line reads <repo>: repository not found (moved or deleted); N folders still point to it (listed first below). When a scanned repository's .git/worktrees/<name>/gitdir still points at the folder (the repository moved), the row carries reconnect: "<that repository>".
reveal: Finder selects a folder the current report lists.200 {"ok":true};404not listed;410gone;501off macOS.reconnect: runsgit worktree repair <path>in thereconnectrepository.200 {"status":"ok","repo"};404not an orphan;409no known repository records it.trash: moves an orphan folder to the Trash on its volume.200 {"status":"ok","result":{"path","bytes","trash_path"}};404not an orphan. The ledger bookstrash:orphan-worktree(counted intrashed_bytes); the row, and a missing-repository group it leaves empty with itserrorsline, leave the cached report.
All three write an audit row. Mutations, NoAgent.
{"path": "<keep or review worktree>"} resumes the newest currently active Claude Code or Codex session recorded in that worktree (hook or transcript identity; its id is the harness's own) and sends it a fixed request: open a pull request for work worth keeping (WORKTREE-VERDICT: pr <url>), or say the worktree can go (removable <reason>) or must stay (keep <reason>), and never delete the worktree itself. The console shows βAsk β only when that live session is available; βAsk advisorβ is a separate local advisory action. Claude Code runs as claude --resume <id> --fork-session -p <request> --output-format json --max-budget-usd 1.00; Codex as codex exec resume <id> <request> --skip-git-repo-check -o <file>. Both run in the worktree with the user's own agent settings and hooks, in their own process group, bounded at 15 minutes; one ask runs at a time. The request carries the checker's state and reasons; nothing else from the repository.
200 {"status":"ok","ask":{"id","ts","path","repo","harness","session_id","status":"running"}}; 400 bad path; 404 not a linked worktree, or no active resumable session in it; 409 the worktree is not keep or review, no agent is working there, or another ask is running; 503 asking is not wired or the CLI is not installed. The answer lands in the agent_asks table (status: answered, failed, timeout; verdict: pr, removable, keep, none; detail; cost_usd for Claude Code; output, the reply's last lines), an audit row worktree-ask and a ledger row ask:<verdict>. GET /worktrees/asks?limit=N lists asks newest first; GET /worktrees carries each worktree's newest ask in asks and eligible live harness in askable, keyed by path. The answer is displayed only; it never changes state or what POST /worktrees/remove accepts. Mutation, NoAgent. CLI: secure-agent worktrees ask <path>; the list view prints the answer under its row.
Everything besides worktrees that can be cleared, each with its size, last touched time and project:
kind |
What | Where it is looked for | Clear with |
|---|---|---|---|
tmp |
.tmp directories |
in every scanned repository and worktree (3 levels deep), and up to 3 directories above a repository | Trash |
quarantine |
.quarantine directories |
same | Trash |
repo-cache |
node_modules, .next, .nuxt, .turbo, .parcel-cache, dist, build, target, .venv, venv, __pycache__, .pytest_cache, .mypy_cache, .ruff_cache, .gradle, DerivedData, .build, .swiftpm |
inside repositories and worktrees, 3 levels deep | Trash |
tool-cache |
npm, yarn, pnpm, go build, go modules, pip, uv, Homebrew, cargo registry, Gradle, Playwright browsers, Xcode DerivedData; Hugging Face models (listed only) | the tool's default cache path | the tool's own command (npm cache clean --force, go clean -cache, brew cleanup --prune=all, ...) when the tool is installed, else Trash |
app-cache |
one directory per app under ~/Library/Caches and ~/.cache |
Trash (quit the app first) |
Each item: id, kind, name, path, project (the repository, for items inside one), worktree, size_bytes, files, size_partial, last_touched (newest modification time under it), idle_days, action (trash, clean, none), command and note. Items are biggest first; kinds and projects sum them. Sizes come from the same bounded background walk as worktree sizes (sizing while pending). An item inside a repository or worktree is offered only when git ignores it and no tracked file lives under it (a build/ or dist/ that is source reads action: none, note not ignored by git, or holds tracked files: not a cache); one where an agent session is live reads action: none too. reclaimed is the ledger totals. advice maps a project (its repository path, or machine for items outside any repository) to the local advisor's stored plan: rationale holds the summary, suggested_action the steps, one per line. A GET answers from the last inventory; one older than 10 minutes still answers while a background rebuild replaces it (refreshing: true), and a size older than an hour still answers while it is measured again. The last inventory and sizes are saved in the store and answer after a restart. ?refresh=1 rebuilds and waits. Read-level, NoAgent. CLI: secure-agent cleanup [--kind K] [--project P] [--refresh] [--json].
{"path": "<item path>"} moves one trash item to the Trash on its own volume (~/.Trash on the home volume, <volume>/.Trashes/<uid> elsewhere; a name already there gets a time suffix). {"name": "<tool>"} runs one clean item's command (10-minute bound) and books how much the cache shrank. Both rebuild the inventory at request time and act only on an item it still offers with that action. 200 {"status":"ok","result":{"item", "bytes", "bytes_partial", "trash_path", "output"}}; 400 bad payload; 404 not in the inventory with that action (including an item whose place now has a live agent session); 409 the directory changed; 500 the move or command failed. The ledger books trash:<kind> (counted as trashed_bytes: the space frees when the Trash is emptied) or clean:<tool>. Mutations, NoAgent. CLI: secure-agent cleanup trash <path>, secure-agent cleanup clean <tool>.
{"project": "<repository path | machine>"} queues one project for a cleanup plan from the local advisor (see ADVISOR_THREAT_MODEL.md). The request is built from the cached worktree report (that repository's non-main worktrees: path, branch, state, size, idle days, reasons) and the cached clutter inventory (the project's items: kind, path, size, idle days, action). 200 {"status":"ok","queued":true|false,"subject":"project:<project>"} (queued: false when the advisor queue is full); 400 bad payload; 404 the project has no worktrees or clutter; 503 the advisor or an inventory is not wired. The plan (a summary and at most 5 steps) is stored as advisor verdict kind project and shows in GET /cleanup advice; nothing reads it back into a verdict, a removal or a clear. Mutation, NoAgent. CLI: secure-agent cleanup advise <repository path | machine>.
The cleanup ledger, newest first: {"totals": {"bytes", "count", "bytes_30d", "count_30d", "trashed_bytes", "trashed_count"}, "entries": [{"id", "ts", "action", "path", "repo", "bytes", "detail"}]}. ?limit=N (default 100, max 1000). ?days=N (max 366) adds "daily": [{"day": "YYYY-MM-DD", "bytes", "count", "trashed_bytes", "trashed_count"}]: the N calendar days ending today in the daemon's local time, oldest first, zero-filled, counted like totals. Actions: worktree-remove, worktree-prune, trash:<kind> (moves to the Trash, summed in trashed_*), clean:<tool>, ask:<verdict> (an agent's answer: listed, not counted in count or daily). The ledger keeps the newest 20,000 rows. Read-level, NoAgent. CLI: secure-agent cleanup log [--limit N] [--json].
| Status | Body |
|---|---|
200 |
{"status":"ok","removed":"<path>","branch":"<branch>","reasons":[...],"bytes":<n>,"bytes_partial":false} or {"status":"ok","pruned":["<path>", ...]} |
400 |
missing or relative path / repo |
404 |
path is not a linked worktree git lists (the main worktree included), or repo is not inside a git repository |
409 |
remove: {"error":"not removable","state":"<state>","reasons":[...]} from the fresh verdict; prune: nothing to prune |
500 |
git failed |
Mutation (pinned UI or owner): while the menu bar app runs, the CLI gets 403 and changes go through the console. CLI: secure-agent worktrees remove <path>, secure-agent worktrees prune <repo>.
Queues one worktree for a note from the local advisor (see ADVISOR_THREAT_MODEL.md).
{"path": "/Users/me/code/app/.worktrees/ui"}200 {"status":"ok","queued":true,"subject":"worktree:<path>@<head>"}; queued is false when the advisor is off or its queue is full. 400 for a missing or relative path, 404 for a path that is not a linked worktree, 409 for a worktree whose directory is gone, 503 when advice is not wired. {"repo": "<absolute path>"} instead of path asks about every keep, review and remove row of that repository in the cached report (at most 40), one note at a time: the first row is handed to the advisor before the answer, each next one after the previous note is stored, so the advisor's queue (shared with flag triage and guard recommendations) never holds more than one; 202 {"status":"accepted","queued":1,"rows":<n>,"skipped":<m>,"paths":[...]}; 200 {"status":"ok","queued":0,...} when the advisor is off or full; the run stops when the advisor turns a row away, at daemon shutdown or after an hour; 404 repository not in the report; 409 no askable row, none could be inspected, or a group ask already running (one at a time). The model answers {"recommendation":"remove|review|keep","confidence":0-1,"rationale":"..."}; anything else is dropped. GET /worktrees returns stored notes in advice, keyed by worktree path, for notes taken at the row's current HEAD: {"<path>": {"assessment": "review", "confidence": 0.6, "rationale": "...", "model": "...", "created_at": "..."}}. A note never changes state or what POST /worktrees/remove accepts. Mutation (pinned UI or owner). CLI: secure-agent worktrees advise <path>; the list view prints the note under its row. Console: the Worktrees tab lists the report with a Remove button on remove rows and Prune on prune rows.
The console's Agent tab (see SYSTEM_AGENT.md). Every route is console-admitted and NoAgent; POST is a mutation (pinned UI or owner).
| Route | Method | Body / query | Answer |
|---|---|---|---|
/agent/status |
GET |
β | {"enabled","endpoint","reachable","ollama_version","reason","model","harness_model","models":[...],"harnesses":[{"id","label","bin","min_ollama","path","installed","ready","reason"}],"skills":[{"id","title","summary"}],"chatting","running_run","terminal","home"}. Probes Ollama (/api/tags, /api/version, 1.5 s). |
/agent/skills |
GET |
β | [{"id","title","summary","keywords","body"}] |
/agent/chat |
GET |
β | `{"messages":[{"id","ts","role":"user |
/agent/chat |
POST |
{"message","workdir"} |
202 {"message":{...}}; the local Ollama reply lands asynchronously β poll GET until chatting is false. A harness field is rejected (400), as are empty or oversized messages. 409 off or already answering; 422 an unmaskable secret. |
/agent/worktree |
POST |
{"path":"<absolute linked worktree>"} or {"repo":"<absolute main worktree path>"} |
202 {"message":{...}}; the daemon re-inspects the worktree, writes the question ("Can I delete this worktree?" with the checker's verdict and the repository facts inside <evidence>) and stores it as a user message with origin worktree and the worktree as workdir; the reply lands like a chat reply and later chat messages keep it as context. With repo the question covers every non-main worktree in the cached report, one <evidence> line each, and workdir is the repository. 400 relative path or both fields; 404 not a linked worktree, or a repository not in the report; 409 the agent is off or already answering, the directory is gone (prune it), or the repository has no linked worktree; 422 an unmaskable secret. |
/agent/actions |
POST |
{"message_id"} |
202 {"run":{...}} starts only the stored assistant message's exact local command, once. The caller cannot supply a command, folder or mode. 404 no command; 409 already claimed or another local run active. |
/agent/chat |
DELETE |
β | Clears the conversation; plans and runs stay. 409 while answering. |
/agent/plans |
GET |
β | Plans newest first, each with ready and reason computed now. |
/agent/plans |
POST |
{"message_id"} saves a legacy reply proposal; {"id", β¦} edits a plan; otherwise `{"title","harness","mode":"headless |
terminal","workdir","task","steps","skills","model"}` creates a separate handoff |
/agent/plans?id= |
DELETE |
β | 404 unknown, 409 running. |
/agent/dispatch |
POST |
{"plan_id","mode","workdir","model"} (the last three optional; kept on the plan) |
202 {"run":{...}} with status running (headless; poll /agent/runs), opened (a Terminal window opened) or manual (detail holds sh '<script>' to run). 400 missing folder, 404 unknown plan, 409 off, harness not ready (the plan keeps the reason in note) or a headless run already in flight. |
/agent/runs |
GET |
β | Runs newest first: `{"id","plan_id","ts","finished_at","title","harness","mode","model","workdir","status":"running |
Every connection is identified with macOS LOCAL_PEEREPID / LOCAL_PEERCRED (kernel-attested; not forgeable):
| Role | Who | Allowed |
|---|---|---|
| Owner | Same uid as the daemon (CLI, shells, ssh management) | All reads; POST /guard/resolve; DELETE /guard/rules; POST /firewall/*; POST /kill (agent pids only) |
| Agent | PIDs currently tagged as agent processes | Reads; POST /guard/decision |
| Foreign | Different uid | Nothing |
POST /kill additionally refuses any PID that is not currently a recognized agent process, so the control socket cannot be turned into an arbitrary-process killer.
NoAgent routes (/files/detail, /files/reveal, /files/open, /agent/*, among others marked NoAgent in apiroutes.Table) refuse every agent process. On the unix socket the Agent and Foreign roles get 403, and an Owner peer whose process belongs to an agent family (checked live, so a child spawned a moment ago counts) is refused too. On the console listener the console token is not enough: the daemon identifies the TCP client's process with netstat and serves it only when that process is outside every agent family; an unidentified client is refused. Off macOS the console listener refuses these routes.
GET /debug/pprof/ (Go runtime profiles: heap, goroutine, profile?seconds=N, trace, β¦) is served on the unix socket only, to the Owner role (and the pinned menubar app); agents and foreign peers get 403, and the proxy listener never serves it.
The embedded console is served at both:
http://127.0.0.1:<proxy_port>/dashboard/(when the proxy is enabled), and- over the unix socket at
/dashboard/(forcurl --unix-socketor an SSH tunnel).
Both routes send Content-Security-Policy, X-Frame-Options: DENY, X-Content-Type-Options: nosniff, and Referrer-Policy: no-referrer.
Downstream collectors consume events from many nodes three ways:
Fastest path: secure-agent fleet enroll <collector-url> on the node β it reads the node id from the daemon, generates the secret, merges the webhook into config.yaml (backup first), and prints the one line the collector's secrets file needs. The daemon hot-reloads fleet config; no restart. The manual equivalent:
fleet:
hostname: "builder-01" # display name collectors show (default: os.Hostname)
labels: { env: prod, role: build-runner } # grouping dimensions for multi-fleet views
heartbeat_interval_sec: 60 # status-envelope cadence (default 60)
webhooks:
- url: https://collector.internal/hooks/secure-agent
secret: "<shared-secret>"
events: [flag, incident, guard] # empty = all; add session, trace for the sessions viewEvery flag, incident, and guard decision is POSTed as:
{"node_id": "β¦", "kind": "flag", "ts": "β¦", "version": "β¦", "boot": "β¦", "seq": 42, "payload": {β¦}}session envelopes carry a session upsert/lifecycle change (one per change, not per event) and trace envelopes carry one agent-semantic trace event (tool call, model call, turn) β both are opt-in per sink because a busy harness emits traces at a rate that would crowd the security events. Trace delivery is deliberately lossy: the publisher's in-flight cap drops trace overflow before it can starve flag/incident delivery, and the collector's gap detector keeps the loss honest. GET /fleet/sessions folds them into the cross-node sessions view.
boot identifies one daemon run; seq is a per-boot monotonic counter stamped on every envelope (heartbeats included). Collectors use them for gap detection: a delivery lost to the backlog cap, collector downtime, or a restart surfaces as a sequence gap with a 90s grace period for retries/reordering β loss is honest, never silent. A new boot resets the expectation (a restart is not a gap). Fleet config (webhooks, hostname, labels, heartbeat_interval_sec) is hot-reloadable: the daemon's config watcher swaps sinks and cadence within one poll cycle.
In addition, every node pushes a status heartbeat β once at boot, then
every heartbeat_interval_sec, and immediately whenever the posture state
changes (all-clear β critical must not wait out the interval). The status
kind is not filterable by events: β liveness that can be unsubscribed
is indistinguishable from a dead node. Payload:
{"hostname": "builder-01", "os": "darwin", "arch": "arm64", "agents": 2,
"uptime": "4h12m", "posture_state": "critical",
"posture_summary": "Secret leaving in agent traffic β act now.",
"needs_you": 2, "labels": {"env": "prod"}}posture_state/posture_summary/needs_you are the node's own /posture
headline β collectors render the same answer the local UIs show instead of
re-deriving it from raw flags.
- Signature:
X-SecureAgent-Signature: sha256=<hex hmac-sha256(secret, body)>β verify before trustingpayload. - Retries: 3 attempts (500ms/2s/5s backoff) on network errors, 5xx, and 429 only. Non-retryable failures land in
~/.local/state/secure-agent/webhook-deliveries.jsonl(0600). - Delivery is best-effort and asynchronous; a dead collector never slows the daemon.
GET /fleet returns this node's status including stable node_id and build version; GET /flags|events|incidents|audit|guard/* are all available over SSH tunnels or Tailscale. Point the CLI at a tunneled socket with SECURE_AGENT_SOCK=/path/to/tunneled.sock secure-agent status.
Hook-stamped session_id (env CLAUDE_SESSION_ID, or a per-run uuid) flows through events, flags, and incident evidence, so one agent run can be followed end-to-end even after PIDs recycle.
directory_guard:
cwd_overrides:
- cwd_prefix: /Users/me/work/prod-api
rules: { env-files: deny, ssh-keys: prompt }Resolution per tool call: first entry whose cwd_prefix contains the agent's working directory wins for the rules it lists; unlisted rules fall back to the global guard-modes.json override, then to shipped defaults. This is how one repo gets pinned to deny while the machine stays monitor.
The headline answer β "do I need to look at this machine, and what first?":
{
"state": "attention", // all-clear | attention | critical
"needs_you": 2,
"summary": "2 item(s) need you β first: Agent read a secret, then connected out.",
"items": [
{"kind": "flag", "id": "β¦", "title": "Agent read a secret, then connected out",
"severity": 3, "detail": "β¦", "ts": "β¦"}
]
}The queue holds only decisions: a pending guard prompt, a pending resource intervention, an open high or critical incident, and a flag, pattern or routine group whose disposition is critical. Warning and benign-likely flags and patterns, recurring egress candidates and lower-risk incidents are not queued; they stay in /flags, /patterns, /incidents and /egress/episodes. Flag items take their severity and detail prefix from the flag's disposition; in groups, flag items carry disposition.
Item kinds: flag (recent β€24h, critical disposition, human-titled), pattern (the flags one /patterns row covers, as one item: id = pattern key, detail = its summary; in groups also count, rule, disposition; the covered flags have no flag items), guard_pending (unresolved prompts), collector_down (dead/abandoned monitors), collector_silent, harness_uncovered and guard_hook_unregistered (coverage gaps while agents run), uninspected_egress (connections that bypassed the firewall, one item per group that carries them), incident (unresolved critical/high), resource_pressure (a pending resource intervention). Derived live β never a second source of truth.
coverage_items (counted in coverage_count) hold monitoring gaps (collector_down, collector_silent, harness_uncovered, guard_hook_unregistered) and the uninspected_egress note. With needs_you 0, a gap makes state attention; the egress note alone leaves it all-clear.
Invariant: every item in items appears in exactly one of groups, and the group item counts sum to needs_you (= len(items)). Groups are agent sessions (session:<key>), agent buckets (agent:<name>; summary names the processes and sessions behind their findings, e.g. "3 processes (claude-code 2.1.281 via Claude.app) across 3 sessions, all exited"), and machine (agent: "", label: "This machine"), which holds the agent-less items: dead or silent collectors, missing hooks, and the machine-wide uninspected item when no agent group carries egress. Group item priorities: guard 5, resource 4, incident 3, flag, pattern and routine 2.
Live feed of every stored event as event: <kind> / data: <json>, with a 15s heartbeat comment. Replaces polling for UIs that can hold a connection β the menu bar app and the web console both consume this stream (guard prompts surface at push latency), falling back to polling when the endpoint is unavailable. One bus subscription per connection, released on disconnect.
The browser console at http://127.0.0.1:<proxy_port>/dashboard/ fetches telemetry same-origin, i.e. from the proxy listener. That listener serves the API endpoints listed in proxy.isConsoleAPIPath (status/posture/flags/events/incidents/audit/fleet/firewall sources + guard pending/rules/resolve/path-allow + kill + rollup + mute + allowlist(+suggestions) + /egress/uninspected + /notify/rules + advisor retriage + /sessions/{id}/timeline and /sessions/{id}/report + /flags/{id}/explain + this SSE stream) behind the console token. The whitelist is kept in lockstep with the console's fetches by TestConsoleAPIPathsCoverWebApp β a path the console fetches but the listener doesn't whitelist 407s and the panel dies silently, which is exactly the drift that test exists to catch:
- Header
X-SecureAgent-Console-Token: <token>(fetch/XHR) or?ct=<token>(EventSource can't set headers). - The token lives at
~/.config/secure-agent/console-token(0600), distinct from the proxy token on purpose: agents routed through the proxy carry the proxy token in their environment and must not be able to read telemetry or resolve guard prompts with it. /guard/decisionis not served on this listener at all β it stays on the peer-attested unix socket.- Admission is method-aware: GET/HEAD pass on every whitelisted route, but a mutating method is admitted only when
apiroutes.Tablelists it in that route'sMutatingMethodsorConsoleMethodsβ so the console token can drivePOST /guard/path-allowandDELETE /mutebut notDELETE /guard/rulesorDELETE /guard/path-allow, which stay owner-level on the unix socket.
Besides telemetry kinds (file-open, conn-open, proxy-hit, β¦), the stream carries the guard lifecycle:
event: guard-promptβ a directory-guard prompt was enqueued; refetch/guard/pendingimmediately.event: guard-resolvedβ a prompt was resolved; refetch pending +/guard/rules.
data for these carries only {kind, ts, detail} with detail = "<agent>/<rule_id>" β never paths.
GET /incidentsβ list items now carryworkflow: {status, acknowledged_at, resolved_at, resolution_note}.GET /incidents?id=β¦β returns{incident, workflow}.POST /incidents/statusβ{"id","status":"open|acknowledged|resolved","note":"β¦"}. Forward-only transitions;acknowledged_atstamps once; re-resolve replaces the note. Audited.
The drill-down behind the posture warning β the actual endpoints that bypassed inspection, so the count is explainable and actionable:
GET /egress/uninspected?hours=24&limit=200
[
{"agent": "cursor", "host": "registry.npmjs.org", "count": 14,
"first_seen": "2026-09-14T10:00:00Z", "last_seen": "2026-09-15T10:00:00Z",
"session_id": "sess-cursor-2",
"identity": {"kind": "hostname", "name": "registry.npmjs.org", "org": "npm registry", "class": "vendor"},
"assessment": "benign", "rationale": "npm registry is routine for JS projects"},
{"agent": "openclaw", "host": "2607:6bc0::10", "count": 94,
"first_seen": "2026-09-23T12:00:00Z", "last_seen": "2026-09-23T13:00:00Z",
"identity": {"kind": "ipv6", "org": "Anthropic", "ip": "2607:6bc0::10", "class": "vendor"}}
]identityβ owner of the host from the provider CIDR table, host suffix, or cached reverse DNS (org,name,kind,ip,class), never a network lookup.identity.classβvendor(Anthropic, OpenAI, GitHub, GitHub Container Registry, npm registry, PyPI, crates.io, RubyGems, Docker Hub, Docker, Google Container Registry, Debian, Ubuntu),telemetry(Statsig, Sentry, Segment, PostHog, Amplitude),cloud(any other named org).identity.classis omitted whenorgis empty.- The same
identityobject,classincluded, is onGET /egress/endpoint?host=and on each/snapshotsuggestionsrow. - Vendor-class hosts are never
/snapshotsuggestions. first_seenβ first sighting of the agent+host pair, omitted when unknown.session_idβ most recent session that reached the host, omitted when none.infraβ set only for CDN/cloud carriers (Cloudflare, Google, GitHub, PTR-classified).identity.orgcan be set withoutinfra.agent_kindβ"infra"whenagentis an infra family (cursor-ide,claude-desktop,ollama,lm-studio, or anykind: infraentry inagents:); omitted for agents. Infra rows are never/snapshotsuggestions.
hours (1β168, default 24) windows the list by last-seen; out-of-range
values fall back to 24. Sorted most-frequent first; assessment/rationale
carry the advisor's host verdict when one exists. Read-level. Approve a row
with POST /allowlist to close that blind spot.
Related: status.uninspected_egress is a rolling 24h distinct-endpoint
count ("what is bypassing inspection now"), not a lifetime figure β pairs
silent for 7+ days are swept from the tracker entirely. It counts agents'
endpoints only; CDN/cloud carriers and every infra family's endpoints count
in status.uninspected_infra.
Each agent's outbound connections grouped by activity scope and destination (host, protocol, port), from connection-open events. Metadata only: no URL, payload, command or credential.
{"episodes": [{"id": "<32 hex>", "expected": false, "candidate": true,
"observed": {"scope": {"agent": "codex", "exe_path": "β¦", "harness": "codex", "workspace": "β¦"},
"session_ids": ["β¦"], "host": "api.example.com", "protocol": "tcp", "port": 443,
"count": 12, "first_seen": "β¦", "last_seen": "β¦", "recurring": true, "scope_complete": true},
"advisor_inference": {"possible_purpose": "β¦", "confidence": 0.7, "created_at": "β¦"}}],
"non_candidate_limit": 100}recurringβ at least 5 connections, and the last 4 gaps are each 1 minute or more and within 2Γ of each other.candidateβ recurring, attributed to a known agent, and not covered by an expected-egress rule; candidates come first and are listed on the Egress tab, not in the Home queue.- At most 100 non-candidate episodes are listed; episodes idle for 7 days are dropped.
advisor_inferenceis present only when the advisor assessed the episode's current evidence; the console labels it as an inference.
POST /egress/episodes/{id}/assess queues an advisor assessment for a candidate: {"status": "queued"}, or {"status": "cached"} when the current evidence is already assessed. Non-candidates return 409; no advisor returns 503.
Both are console-allowed and NoAgent; the POST is a mutation (pinned UI or owner).
Operator decisions taken from an observed episode. The request names only the episode and the rule kind; destination and scope always come from the daemon's observation.
GET /expected-egressβ{"rules": [...]}(id,agent,kind,host/protocol/portorexe_path/harness/workspace,created_at,revoked_at).POST /expected-egress {"episode_id", "kind": "destination"}β expects that agent's exact host, protocol and port. Loopback destinations are refused.POST /expected-egress {"episode_id", "kind": "scope"}β expects every destination of that agent's executable, harness and workspace; only for an episode withscope_complete.DELETE /expected-egress?id=<32 hex>β revokes a rule.
Creates and revokes are audited (expected-egress-create, expected-egress-revoke). A rule only clears the episode's candidate flag on the Egress tab: the proxy, guard, correlator, incidents and flags never consult it. NoAgent; POST and DELETE are mutations (pinned UI or owner).
Per-rule notification overrides, layered over the default policy (severity β₯ 3 notifies; informational flags like routine keychain-db opens are silent). Both UIs (menu bar app and web console) read this store, so one choice silences both surfaces.
GET /notify/rules
{"default_min_severity": 3, "overrides": {"keychain-access": false}}POST /notify/rules {"rule": "keychain-access", "notify": false}
POST /notify/rules {"rule": "keychain-access", "notify": null} // clear β default
true = always notify for the rule (even below the severity bar), false =
never, null/absent = back to default. Rule ids must match
^[A-Za-z0-9_.-]+$. Persisted at ~/.config/secure-agent/notify-rules.json
(0600, atomic); sets and clears are audited (notify-rule-set /
notify-rule-clear).
Operator-only, NoAgent exceptions persisted in ~/.config/secure-agent/expected.json (0600, atomic). Guard protection is unchanged.
GET /expectedlists entries, including optionalscope: "file", runtime hit counts and last match time.POST /expected {"flag_id", "path"?, "host"?}derives an agent/reader/exact-file/exact-host pattern from stored evidence. Optional path/host selects a recorded pair; arbitrary injected pairs are rejected. It acknowledges an open finding only when every recorded read/destination pair is covered. Findings with remaining evidence offer the next uncovered pair.POST /expected {"flag_ids":["<id>",β¦]}(1..500 ids) adds every exact pair those flags record, with one save, then acknowledges the findings now fully covered β{"added", "acknowledged"}. Unknown ids and flags without a recorded reader add nothing;422when none adds a pair,400withflag_idtoo or over 500 ids.POST /expected {"flag_id", "scope":"file"}marks the recorded exact.envpath non-secret for that agent, across readers and destinations. This explicit exception remains in effect for future contents until revoked; use only for test fixtures without real credentials. Other agents and neighboring paths remain monitored. It does not delete or inspect file contents.DELETE /expected?key=<key>revokes an exception. Add/remove actions are audited; Policy exposes revocation.
Destination keys are canonical exact hosts, not cloud/CDN organizations. Legacy provider-wide entries do not match new exact-host observations; reapprove a specific host when justified.
POST /agent/analyze {"flag_ids":["id", ...]} reviews up to 30 selected stored flags through local Ollama. Missing IDs return 404 before any analysis is queued. Omitting IDs retains general recent-activity analysis. Successful responses are accepted asynchronously and lead to a persisted recommendation in /agent/recommendations; no command or harness starts. /agent/actions remains the separately confirmed execution route.
POST /mute with host: "*" is the rule-level disposition: the whole
flag class stops raising flags (silenced fires are counted in
status.muted_flags), and every open flag of the rule is acknowledged so the
old rows leave the critical list. This is the recourse for noisy host-less
rules (keychain-access, keychain-security-cli) β the console and menu bar
expose it as "Dismiss this flag class". Reversible with DELETE /mute.
GET /mute lists dispositions as [{"rule", "host", "agent", "title"}],
sorted by rule, host, then agent; title is the rule's human title (the rule
id when it has none). POST /mute takes {"rule", "host", "agent"}; it
ignores title.
POST /mute {"rule": "keychain-access", "host": "*", "agent": "codex"}
GET /mute β [{"rule": "keychain-access", "host": "*", "agent": "codex", "title": "Agent touched the keychain"}]
DELETE /mute?rule=keychain-access&host=*&agent=codex
agentis optional; absent or empty mutes the pair for every agent.- With
agent, only that agent's hits are counted instead of flagged; other agents still flag. - With
agent,POSTacknowledges only that agent's open flags of the rule. agentis a configured agent name or anuntagged:<exe>label:^[A-Za-z0-9][A-Za-z0-9_. :-]{0,127}$.DELETEremoves the entry with the sameagent(omit it for an every-agent mute).GET /muteand/snapshotmutesreturnagentonly for scoped mutes.
These routes share the peer and console authorization rules. Mutation admission does not bypass a route's agent restrictions.
| Endpoint | Method | Contract |
|---|---|---|
/resources/episodes |
GET |
Returns the 20 most recent stored resource episodes when storage is attached; otherwise returns the current resource snapshot's episodes. |
/decision-scopes |
GET |
Lists persisted decision scopes. Returns 503 when permissions cannot be read; agent peers are refused. |
/decision-scopes?id=<id> |
DELETE |
Revokes one persisted scope. Returns {"revoked":true,"id":"<id>"} only after saving; invalid IDs return 400, absent IDs 404, and failed persistence 503. Agent peers are refused. Revocation does not undo prior access or revoke other policies. |
/allowlist/suggestions |
GET |
Returns current allowlist suggestions without saving an exception. |
/advisor/retriage |
POST |
{"flag_id":"<id>"} queues a fresh advisory verdict. Returns `{"status":"ok","queued":true |
/advisor/assess-host |
POST |
{"agent":"claude","host":"example.com"} queues an advisory host assessment and can return the current cached verdict. queued reports whether a task was queued. It changes no enforcement. An unavailable advisor returns 503. |
/stats/rollup?hours=24 |
GET |
Returns stored rollups for the requested positive hour range; defaults to 24 and caps at 744 hours. Retention still limits the underlying records. |
/ui/open-fda |
POST |
Opens the macOS Full Disk Access settings pane; it does not grant the permission. |
/ui/open-config |
POST |
Opens the configured overlay in a text editor, creating a private empty overlay if absent. Rejects a symlink or non-regular file; agent peers are refused. Opening the editor is macOS-only (501 otherwise). |
Stdlib-only reference implementation of the consumer side. Run:
make collector
umask 077
printf '%s\n' '<node-id>=<secret>' > secrets.txt
./bin/secure-agent-collector -addr 127.0.0.1:9445 -store <dir> -config secrets.txt| Endpoint | Description |
|---|---|
POST /hooks/secure-agent |
Webhook receiver. Requires X-SecureAgent-Node (provisioned) and X-SecureAgent-Signature (HMAC over the raw body, constant-time compared). Envelope node_id must match the header. Accepted kinds: flag, incident, guard, status, session, trace. |
GET /fleet |
Merged multi-node rollup ordered by operator priority (critical β attention β stale β all-clear β legacy). Per node: hostname, labels, version (tracks the newest report), last_seen (liveness), last_event (security activity), lifetime counts, rolling 24h counts (flags_24h, critical_flags_24h, incidents_24h), guard allow/deny breakdown, gaps (sequence-gap loss count), boot_id, and the node's own posture (posture_state, posture_summary, needs_you, agents). |
GET /fleet/rules |
Cross-node rule aggregation: {total_nodes, rules: [{rule, nodes, node_ids, flags_24h, critical_24h}]} sorted by fleet spread β "is the same thing firing on N/M nodes?" |
GET /fleet/sessions |
Cross-node sessions: every node's latest record per session (harness, workspace, repo, branch, status, confidence, timestamps) with the node's hostname and labels. Live sessions first, ended ones below β the "who is working where" view. Fed by the opt-in session/trace envelope kinds. |
GET /nodes/<id>/events?kind=&limit= |
One node's stored envelopes, newest first. |
GET / |
HTML overview: a fleet headline ("2 critical Β· 1 stale Β· 12 all-clear"), the rules-across-fleet table, and per-node cards (hostname, posture chip, 24h counts, labels, delivery-gap warnings). Liveness: heartbeat nodes stale >3 min, gone >10 min; legacy event-only nodes >10 / >20 min. |
GET /healthz |
Liveness. |
Secrets come from a flat file (node_id=secret lines) or -secrets n1=a,n2=b. Store: append-only JSONL per node, 0600 in a 0700 directory, replayed into the rollup at startup behind a small envelopeLog interface β a SQLite backend can replace it without touching rollup semantics (the production-grade trajectory: retention, TLS, alerting).
Read endpoints (/fleet, /fleet/rules, /fleet/sessions, /nodes/* and /) require Authorization: Bearer <token> when -read-token or SECURE_AGENT_COLLECTOR_READ_TOKEN is configured. Without that setting they are unauthenticated; keep the default loopback bind unless read access is protected. /healthz remains unauthenticated. The collector serves HTTP, so remote deployments need a TLS frontend. Secrets are loaded at startup; restart after provisioning a node. See Fleet setup.
The e2e smoke test provisions a collector, configures a node webhook, triggers a real flag, and asserts verified flag and status-heartbeat envelopes land in the store β the fleet contract cannot regress silently.
Evidence .env inspection through GET /files/detail?path=<stored evidence path> may include env_variables (variable names only) or env_withheld (why names were withheld). Reads reject final symlinks, non-regular files, other users' files, files over 64 KiB, and unsupported multiline syntax. No environment values are returned by this inspection.