Skip to content

Latest commit

Β 

History

History
1479 lines (1141 loc) Β· 119 KB

File metadata and controls

1479 lines (1141 loc) Β· 119 KB

secure-agent Unix Socket API Specification

Documentation Β· Project home

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)

Endpoint quick reference

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.

Endpoints

1. GET /status

Returns daemon operational status, system uptime, and active tagged agent process count.

Request

GET /status HTTP/1.1
Host: unix

Response

{
  "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.

Installed-hook check: POST /coverage/probe

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.

Resource telemetry: GET /resources

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: 600

Workspace 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.


2. GET /flags

Retrieves recent security correlation flags.

Query Parameters

  • limit (optional, integer): Maximum number of flags to return (default: 50).

Request

GET /flags?limit=10 HTTP/1.1
Host: unix

Response

[
  {
    "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"
  }
]

Raising process and daemon acknowledgements

  • process β€” the raising process as it was when the flag was raised, kept after the process exits: {exe, name, args0, ppid, launcher}. args0 is argv[0] with secret-shaped values scrubbed; no further argv, no environment. launcher is 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 on GET /flags/{id}/explain.
  • evidence[].chain β€” recorded ancestor process IDs on file reads and connections; used to distinguish ancestor correlation from descendant activity.
  • evidence[].owners β€” on read items, the orgs credential_owners names for the file; sub is agent tool read when an agent tool (hook-reported) read it, else sensitive read. New connect items also record exe when the event or process tagger identifies the connecting executable.
  • evidence[].pid, evidence[].exe β€” on read items, the process that opened the file; on connect items (pid only), the process that connected. The reader may differ from the flag's pid. Absent on flags raised before the fields existed.
  • repeats, last_seen β€” later occurrences of the same sensitive-read-then-connect pattern (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_seen is the newest. Each fold re-publishes the flag delta. Patterns count a flag as 1 + repeats occurrences.
  • 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-connect flag: an open that is not a read of a regular file (a directory, or a write-only open); a read by a program credential_owners lists in programs for 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. A credential_owners path 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 open sensitive-read-then-connect flags none of whose reads counts as a secret read, with reason reclassified at start: … and one flag-reclassify audit entry. Not a secret read: a glob that no longer counts (guard rules with read_sensitive: false: shell-rc, harness-config), a .env template, a not_secret_paths directory, the macOS trust store (system-trust), a credential file read by a program its credential_owners entry lists in programs (the read item's exe), and a keychain file opened by anything but a byte-copy tool (cat, cp, ditto, tar, curl, base64, …). Empty when the operator acknowledged.

Explanation stamping

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.

Durable reviews

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.

2a. GET /flags/{id}/explain

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).

2c. GET /files/detail?path= Β· POST /files/reveal Β· POST /files/open

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.


2d. GET /advisor/plan?subject= Β· POST /advisor/plan

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.

2e. POST /labels

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.

2b. GET /patterns

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.

2c. POST /flags/acknowledge

{"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.

3. GET /events

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.

Query Parameters

  • 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 <= 0 are ignored.
  • since (optional, string): Only events with ts at or after this timestamp.
  • session_id (optional, string): Only events attributed to this session. Without page, the response remains the event array below.

Request

GET /events?limit=20 HTTP/1.1
Host: unix

Response

[
  {
    "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.

Paged session events

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.


4. POST /kill

Terminates an active agent process tree by PID using SIGKILL.

Request

POST /kill HTTP/1.1
Host: unix
Content-Type: application/json

{
  "pid": 58210
}

Response

{
  "status": "ok",
  "pid": 58210
}

Error Response (400 / 500)

HTTP/1.1 500 Internal Server Error
Content-Type: text/plain; charset=utf-8

Kill failed: process not found

Accessing via curl

To 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/kill

5. GET /incidents

Returns 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).

6. GET /audit

Returns the policy audit trail (rule promotions, fingerprint ingest, guard-rule changes). ?limit=N, default 100.

7. GET /fleet

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.

Protection rule configuration

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.

8. POST /firewall/mode

Promotes or demotes a firewall rule at runtime and persists the override. Payload: {"rule":"<id>","mode":"monitor|block"}. Owner-role only.

9. POST /firewall/fingerprints/reload

Re-applies persisted secret fingerprints to the running engine.

10. POST /firewall/fingerprints/ingest

Scans configured ingest sources, registers HMAC fingerprints (never plaintext), applies them live, returns registered labels.

11. GET|POST /firewall/sources

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.

12. POST /guard/decision

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_.-]+$.

13. GET /guard/pending

Returns the queued guard prompts oldest-first. Each item carries a scope_text disclosing what "Allow Always" would approve.

14. POST /guard/resolve

Resolves a pending prompt: {"id","verdict":"allow|deny","scope":"once|always"}.

15. GET|DELETE /guard/rules

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.
  • POST is a mutation: the pinned UI, or the owner uid when no UI is pinned.
  • DELETE stays owner-level.

16. GET /costs

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 operator pricing table 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.

GET /costs/unpriced

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}
  ]
}

GET /costs/plans

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

17. GET /doctor

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.

18. GET /sessions/{id}/report

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; events is how many were read.
  • tools are sorted by calls (errors counts tool_status: "error", duration_ms is summed); models by cost; files (file open/write/delete) and hosts (connections) by count, at most 50 each.
  • secret_hits are transcript hits: label is the rule id, status the detection layer.
  • flags are the session's findings; timeline is the first 500 events, labelled by tool, model, path, host or detail.
  • Every list is [] when empty, never null.

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.


18b. GET /sessions/{id}/memory

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, guard or resource.
  • limit β€” 1–500, default 200; a larger value is capped at 500.
  • before β€” the next_cursor of the previous page; the next page is older.
  • rows are oldest first within a page; next_cursor is set only when has_earlier is true.
  • An unknown session returns 404; a bad limit or cursor returns 400.

Read-level; console-allowed. The Sessions tab shows it for the selected session.

GET /sessions/{id}/overview

Current access requests, retained findings, observed coverage and machine impact for one durable session. Read-level; console-allowed.

  • session_id, observed_at identify the session and response time.
  • requests contains only pending guard requests carrying that exact session ID; existing approval endpoints and scopes apply.
  • findings contains the latest 20 session-attributed findings, including reviewed findings. Each carries id, title, at and the existing finding assessment; review does not erase risk. findings_truncated signals additional history.
  • coverage is the existing session coverage projection, or null without an unambiguous live process match.
  • resources carries key, observed_at, rss_bytes, cpu_percent, process_count, diagnoses and optional control. It requires the same root PID, start time and harness as the live session; otherwise it is null.
  • 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 returns 503. The console retains the previous response with a stale warning and disables its controls until refreshed.

18c. GET /routing/claude

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.

GET /advisor/discover

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.

19. GET /worktrees

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.

POST /worktrees/repos

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>.

POST /worktrees/remove

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.

POST /worktrees/review-trash

{"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.

POST /worktrees/reveal, POST /worktrees/reconnect, POST /worktrees/trash

{"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}; 404 not listed; 410 gone; 501 off macOS.
  • reconnect: runs git worktree repair <path> in the reconnect repository. 200 {"status":"ok","repo"}; 404 not an orphan; 409 no known repository records it.
  • trash: moves an orphan folder to the Trash on its volume. 200 {"status":"ok","result":{"path","bytes","trash_path"}}; 404 not an orphan. The ledger books trash:orphan-worktree (counted in trashed_bytes); the row, and a missing-repository group it leaves empty with its errors line, leave the cached report.

All three write an audit row. Mutations, NoAgent.

POST /worktrees/ask and GET /worktrees/asks

{"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.

GET /cleanup

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].

POST /cleanup/trash and POST /cleanup/clean

{"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>.

POST /cleanup/advise

{"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>.

GET /cleanup/ledger

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>.

POST /worktrees/advise

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.

20. System agent: /agent/*

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

Peer authentication & endpoint roles

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.

Web dashboard

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/ (for curl --unix-socket or an SSH tunnel).

Both routes send Content-Security-Policy, X-Frame-Options: DENY, X-Content-Type-Options: nosniff, and Referrer-Policy: no-referrer.


Fleet oversight

Downstream collectors consume events from many nodes three ways:

1. Webhook push (real-time)

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 view

Every 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 trusting payload.
  • 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.

2. Pull API

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.

3. Session identity

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.

Per-project guard policies

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.


Operator UX endpoints

GET /posture

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.

GET /events/stream (SSE)

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.

Console access on the proxy port

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/decision is 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.Table lists it in that route's MutatingMethods or ConsoleMethods β€” so the console token can drive POST /guard/path-allow and DELETE /mute but not DELETE /guard/rules or DELETE /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/pending immediately.
  • 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.

Incident workflow

  • GET /incidents β€” list items now carry workflow: {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_at stamps once; re-resolve replaces the note. Audited.

GET /egress/uninspected

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.class is omitted when org is empty.
  • The same identity object, class included, is on GET /egress/endpoint?host= and on each /snapshot suggestions row.
  • Vendor-class hosts are never /snapshot suggestions.
  • 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.org can be set without infra.
  • agent_kind β€” "infra" when agent is an infra family (cursor-ide, claude-desktop, ollama, lm-studio, or any kind: infra entry in agents:); omitted for agents. Infra rows are never /snapshot suggestions.

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.

GET /egress/episodes

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_inference is 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).

Expected egress (/expected-egress)

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/port or exe_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 with scope_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).

GET|POST /notify/rules

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).

Expected secret reads (/expected)

Operator-only, NoAgent exceptions persisted in ~/.config/secure-agent/expected.json (0600, atomic). Guard protection is unchanged.

  • GET /expected lists entries, including optional scope: "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; 422 when none adds a pair, 400 with flag_id too or over 500 ids.
  • POST /expected {"flag_id", "scope":"file"} marks the recorded exact .env path 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.

Muting flag classes (host: "*")

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.

Per-agent mutes (agent)

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
  • agent is 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, POST acknowledges only that agent's open flags of the rule.
  • agent is a configured agent name or an untagged:<exe> label: ^[A-Za-z0-9][A-Za-z0-9_. :-]{0,127}$.
  • DELETE removes the entry with the same agent (omit it for an every-agent mute).
  • GET /mute and /snapshot mutes return agent only for scoped mutes.

Additional operator routes

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).

Reference collector (cmd/secure-agent-collector)

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.