Updated documentation

This commit is contained in:
Tony West committed 2026-08-11 17:42:49 -04:00
1 parent 04833cad99
commit 9157ce9725
1 file changed
+20 -6
+20 -6
View File
@@ -17,7 +17,7 @@ Three modes:
Ports & paths:
- Proxy `:8080` (`--proxy-port`), UI/API `:9090` (`--ui-port`)
- MCP `:9091` (proxy mode, **off by default**, toggled from Settings → Automation; loopback-only, no `--bind`; `--no-automation` removes the feature entirely — no routes, no token file, no second port)
- MCP `:9091` (proxy mode, **off by default**, toggled from Settings → Automation; loopback-only, no `--bind`; `--no-automation` removes the feature entirely — no routes, no token file, no second port; `--automation-privileged` additionally registers the execution and C2 capabilities)
- Data dir `~/.joro/` — CA cert/key + `callbacks.db` + `automation.json` (0600, automation tokens)
- Listener: DNS `:53` (`--dns-port`), HTTP `:80` (`--http-port`), HTTPS `:443` (`--https-port`, `0` to disable), SMTP `:25` (`--smtp-port`, `0` to disable), SMTPS `:465` (`--smtps-port`, `0` to disable), FTP `:21` (`--ftp-port`, `0` to disable), FTPS `:0` (`--ftps-port`, implicit TLS, disabled by default), LDAP `:389` (`--ldap-port`, `0` to disable), LDAPS `:0` (`--ldaps-port`, implicit TLS, disabled by default), domain via `--domain` or UI, optional external TLS cert via `--tls-cert` + `--tls-key` (both required; replaces the auto-generated self-signed leaf, shared by HTTPS/SMTPS/FTPS/LDAPS and STARTTLS)
@@ -59,15 +59,23 @@ internal/
capreg/ The ONLY place capabilities are declared. Imports neither
automation nor api, transitively — keep it that way
caps_history.go caps_context.go caps_http.go The read + send capabilities
caps_write.go notes.create, notes.hosts, findings.update
caps_config.go Match & Replace / Custom Data / noise + intercept status
caps_instance.go instance.get — the one-call orientation read
caps_websocket.go websocket.list (read-only)
caps_execcontext.go context.get / context.clear over the per-token cookie jar
caps_fuzzer.go fuzzer.start/status/results/stop, capped well below the UI
caps_write.go notes.create, notes.hosts, findings.create, findings.update
caps_config.go Match & Replace / Custom Data / noise + intercept status/queue
caps_detect.go Detection rules, config, rescan
caps_privileged.go exec.webshell + c2.{sliver,mythic}.{read,command}. Registered
ONLY under --automation-privileged, and no profile grants one
caps_scopewrite.go scope.addrule (include-only) + scope.enable, both
UnrestrictedOnly — see the scope-write decision below
profiles.go The five token profiles + startup validation (panics)
automation/ Bearer tokens + grants, ~/.joro/automation.json (0600)
httptools/ The token-efficient tool bodies (fingerprint, ranged read,
search, diff, structural edits, batch) + proxysend.go
redact.go Length-preserving masking of credential headers
exec_context.go Per-token cookie jar (stdlib cookiejar + publicsuffix)
mcp/ Hand-rolled JSON-RPC 2.0 + Streamable HTTP, on its own
loopback listener whose mux has no /api/v1 routes
fuzzer/ Goroutine-pool fuzzer + in-memory campaign store (max 50)
@@ -290,7 +298,8 @@ All under `/api/v1/`. Request/response shapes are JSON unless noted. WebSocket e
**Automation** (proxy mode; UI-only, behind `originGuard` like everything else. These are deliberately **not** capabilities — an automation client reaches Joro on the separate MCP port, whose mux has no `/api/v1` routes, so no bearer token has a path to them. All 12 return a JSON 404 when automation is off, rather than being left unregistered, because an unregistered route falls through to the SPA catch-all and returns 200 + HTML.)
- `GET/POST /automation/tokens`, `PUT/DELETE /automation/tokens/{id}`, `POST /automation/tokens/{id}/rotate`, `PUT /automation/tokens/{id}/enabled`, `POST /automation/tokens/{id}/reviewed` — POST-create and rotate are the **only** responses in the whole API that carry a plaintext secret
- `GET /automation/capabilities` → `{capabilities[], fingerprint, classes, profiles[]}` (backs the grant picker; `classes` is the group order, `profiles[]` the curated bundles, each expanded into a concrete grant list on create and never stored on the token)
- `GET /automation/capabilities` → `{capabilities[], fingerprint, classes, profiles[]}` (backs the grant picker; `classes` is the group order, `profiles[]` the curated bundles, each expanded into a concrete grant list on create and never stored on the token). Each capability carries `privileged`; each profile carries `allowsCredentials`
- Token create/update take `allowCredentials` (default **false**), which is a policy field like `requireScope`, not a grant
- `GET/DELETE /automation/audit` — `?tokenId=&capability=&result=&offset=&limit=`; the response embeds a `stats` block (`lastHour`, `deniedLastHour`, `errorsLastHour`, `tokens`, `tokensActive`) so the dashboard widget needs one request, not two. Entries for a mutating capability carry `change`, the handler's own description of what it altered
- `GET/PUT /automation/mcp` — `{enabled, running, port, endpoint, error, tokenCount}`; PUT 409s with the real OS error on a bind failure and does **not** persist `enabled`, so a restart never retries a port the operator never saw fail
@@ -426,7 +435,7 @@ Tracked via `go.mod` / `go.sum` only — repo does **not** vendor (see "no vendo
- **Team chat slash commands** (all handled in `TeamChatWidget.sendMessage`, team-mode only; in solo mode they show a "connect to a team server" hint instead of posting literal text). `/flag <seq> [note]` and `/collab <note>` (above); `/slap <user>` and `/me <text>` post **IRC-style action messages** — `sendChatMessage(text, 'action')` sets `refType:"action"` (the only client-settable refType; `handleCreateChatMessage` rejects forged `flagged`/`collab`/`config`), rendered italic as `* <author> <text>` with no name-colon prefix; `/nick <name>` calls `updateSettings({teamNickname})` (reuses the rename→`renameOnTeamServer` path, surfaces 409); `/help` appends a **local-only** `author:"*"` system message (the system-message span is `whitespace-pre-wrap` so the multi-line list renders).
- **Feature: collaboration request → diff-aware swap (via chat, rules-only).** The `/collab <note>` chat slash command posts a `team_collab_requests` row carrying a **3-field bundle** (scope/M&R/customdata JSON, built by `gatherCurrentRules()`) + a `refType:"collab"` chat chip naming the active project. Clicking the chip opens `CollabSwapModal`, which diffs the incoming bundle against the operator's current rules and offers four actions: **merge / save-and-load / load-without-saving / keep-current**. Merge/replace go through `POST /api/v1/configs/apply-shared {config, mode}` → bulk setters on **scope/replace/customdata only** — the swap **never touches history, notes, highlights, team settings, or the project-file schema**. `save-and-load` first calls the existing `saveProjectConfig`; `keep-current` applies nothing. Every choice records `POST /team/collab/{id}/accept`.
- **Automation is a capability registry, and the registry is the only way in.** `internal/capability` holds every operation exposed to an automation client, registered exactly once, and `Registry.Invoke` is the single choke point: authorization → reserved-prefix recheck → global concurrency → per-token rate + concurrency → scope guard → bounded, panic-recovered execution → output cap → audit. The audit entry is written from a `defer`, so denials, timeouts and recovered panics all land — the failures are the entries an operator most needs. An **unknown capability and an ungranted one return the same code and the same message**, so `tools/call` is never an oracle for what a token was not given. An oversize result is an **error, not truncated JSON**, which a client would mis-parse silently. Handlers are written against a real struct via `capability.Typed[T]`, whose decoder sets `DisallowUnknownFields` — the UI is a program that matches the server, but an automation client is a model that invents arguments, and silently ignoring an invented `limit` would return an unbounded result set instead of the error the agent needs to correct itself. Authorization lives **only** in `Invoke`: `mcp.callTool` deliberately does *not* pre-check `Get`+`Can`, because that duplicated the decision while returning before the audit `defer` — so an agent probing for tools it was not granted left no trace in Activity, the one place the audit log's own doc comment says such probing shows up.
- **There are 30 capabilities across eight classes, and 10 of them write.** The read/send-only era ended with `config.*` (the proxy's Match & Replace, Custom Data and noise rule stores, plus read-only intercept status), `detect.*` (rules, config, rescan), `notes.create`, `findings.update` and the two scope writes. `Classes` order is load-bearing — the grant picker renders groups in it, and sorting by ID instead would lead with the write-heavy `config` and `detect`. Three rules bind every write handler, each learned from a real hazard in the store it touches: **use the single-item methods** (`AddRule`/`AddItem`/`AddPattern`/`Remove*`), never `Rules()`+`SetRules()`, because those setters retain the caller's backing array and the accessors copy only the slice header, so a read-modify-write both aliases live state and races the operator's own REST calls; **cap the store**, since none of them enforces a size limit and `MatchReplace.Apply` and `Scope.inScope` run on the proxy hot path (200 M&R rules, 200 custom items, 300 noise patterns, 1000 scope rules, 300 user detect rules); and **pre-validate**, because `MatchReplace.AddRule` swallows its `regexp.Compile` error and `NoiseFilter` validates no glob at all — an invalid pattern becomes a silently inert rule, which is the worst failure mode for an agent, since it reads success and concludes the rule fired.
- **There are 40 capabilities across twelve classes (45 across fourteen with `--automation-privileged`), and 15 of them write.** The read/send-only era ended with `config.*` (the proxy's Match & Replace, Custom Data and noise rule stores, plus read-only intercept status), `detect.*` (rules, config, rescan), `notes.create`, `findings.update` and the two scope writes. `Classes` order is load-bearing — the grant picker renders groups in it, and sorting by ID instead would lead with the write-heavy `config` and `detect`. Three rules bind every write handler, each learned from a real hazard in the store it touches: **use the single-item methods** (`AddRule`/`AddItem`/`AddPattern`/`Remove*`), never `Rules()`+`SetRules()`, because those setters retain the caller's backing array and the accessors copy only the slice header, so a read-modify-write both aliases live state and races the operator's own REST calls; **cap the store**, since none of them enforces a size limit and `MatchReplace.Apply` and `Scope.inScope` run on the proxy hot path (200 M&R rules, 200 custom items, 300 noise patterns, 1000 scope rules, 300 user detect rules); and **pre-validate**, because `MatchReplace.AddRule` swallows its `regexp.Compile` error and `NoiseFilter` validates no glob at all — an invalid pattern becomes a silently inert rule, which is the worst failure mode for an agent, since it reads success and concludes the rule fired.
- **`AuditEntry.Change` is the one place arguments are recorded readably, and the exception is deliberate.** Arguments stay a `sha256[:8]` digest because a send carries cookies, credentials and payloads — but a scope rule or a Match & Replace pattern is configuration, not a secret, and without this an operator reviewing Activity could see that an agent edited their proxy and not what it did. A mutating handler calls `capability.RecordChange(ctx, …)`; the sink rides on the context because `Typed` passes only `(ctx, Principal, args)` and drops `Input`, and it is collected in a `defer` so a handler that fails partway still reports what it managed to change. `Target*` is **not** reused for this: `AutomationActivity.tsx` renders those fields as `METHOD host path`, so a config edit would display as a request the agent never sent.
- **Only the `detect.*` families broadcast a WS event.** `detect.rules.changed` and `detect.summary` already exist and have listeners, so an agent's rule or triage edit lands in the operator's Detect tab live. There is no `scope.changed`, M&R, Custom Data or noise event type — and the operator's own REST handlers for those don't broadcast either — so those panels update on reload; emitting an event nothing listens to would read as working. Sends go through `capreg.broadcast`, which is **non-blocking**: some REST call sites send blocking, but a capability handler runs under a 30s timeout that a full hub channel would consume, and a dropped notification is a stale panel while a stalled handler is a failed tool call.
- **Grant administration is structurally impossible from a capability, in four independent layers.** (1) **Import direction**: `internal/capability` defines `Principal` itself and never imports `internal/automation`; because automation imports capability, the reverse is a cycle and *will not compile*. (2) **`capreg.Deps`**: capabilities are declared only in `internal/capreg`, which is handed a small struct (capture store, scope, findings, detect engine + scanner, notes, CA, proxy address, the three proxy rule stores — `MatchReplace`/`CustomData`/`NoiseFilter` — the intercept queue, a `func() context.Context` for the server-lifetime context, and the hub's bare `chan<- any`) and must import neither `automation` nor `api`, transitively. Every field is an `internal/proxy`, `internal/detect`, `internal/notes` or stdlib type; the context is a **getter** because `SetAutomation` runs before `StartDetectLoop` (main.go:395 vs :401), so capturing the value would pin a nil, and the broadcast channel is `chan<- any` rather than `*api.Hub` for the import rule. Unlike layer (1) the compiler does not enforce this, so it is the layer to check by hand in review: adding a field to `Deps` is the change that would break it. The absence of `*api.APIServer` is the point; with it, every capability would reach settings, the config store and the plugin manager forever. (3) **Reserved namespaces** (`token. grant. mcp. admin. …`) panic in `Register`, and `automation.Store` drops them again when loading a hand-edited file. (4) **The MCP listener is a separate `http.Server` with its own mux**, on which `/api/v1/*` is not registered at all.
@@ -436,7 +445,12 @@ Tracked via `go.mod` / `go.sum` only — repo does **not** vendor (see "no vendo
- **Automation sends go through Joro's own proxy, not `proxy.SendRawRequest`.** `httptools.SendViaProxy` dials `127.0.0.1:<proxyPort>` (CONNECT + TLS verified against Joro's **own CA** for https, absolute-form target for http), so an agent's traffic is captured into History, scanned by detect, entered in the site map, filtered by scope at both levels, and rewritten by Match & Replace and Custom Data — exactly like browser traffic. **Manipulate and the fuzzer are unchanged** and keep using `SendRawRequest`'s direct dial, SOCKS included; automation still honors SOCKS one hop later, via the proxy's own upstream dial. Consequences to expect: M&R may rewrite what the client asked to send; an enabled **request intercept pauses an agent send in the operator's queue** (the tool times out first and says so); and HTTP/1.1 only, since ALPN is pinned rather than driving the h2 MITM path through a CONNECT tunnel. Correlating the send back to its history row uses **no injected header** — that would be a tell on the wire — but a sequence watermark plus method/URL/status and a body-suffix match, with a claim set so concurrent batch workers cannot take the same row. Correlation returning `seq -` is a normal outcome (noise-filtered host, or outside the proxy's capture scope), not an error.
- **Token-efficient tools: nothing returns a full body by default.** A 500 KB response is roughly 140,000 tokens, which is both expensive and worse at the task than a summary. So: `http_fingerprint` returns an exact body hash plus a **structural hash** that normalizes hex runs, UUIDs, ISO and HTTP dates, base64 runs and digit runs (in that order — hex before digits, or a CSRF token is shredded into digit noise first) and folds in the status and the surviving header *names*; two renderings of one page collapse to one `shash` while `bhash` still separates them. `http_read` is a ranged read with a negative-offset tail mode. `http_search` is two-stage, using `RequestFilter.Content` as a free in-store prefilter. `http_diff` is a hand-rolled LCS over hashed lines with prefix/suffix trim and unique-line drop, degrading **loudly** to an unordered delta past the DP budget. Edits are **structural** (`setHeader`, `setPath`, `replaceInBody`, …) so a model never retypes raw bytes — a mangled `Cookie` turns a negative result into a false negative, the worst outcome in a security test. **The encoding rule: more than three uniform rows render as a fixed-width text table, a single object as compact JSON** — repeated JSON keys are over half the token cost of a row. Two caps apply to every tool: its own, which truncates at a row/hunk/match boundary *and names how to get the rest*, and the registry's `MaxOutputBytes`, a blunt backstop. `hexdump.go` mirrors `web/src/lib/bytes.ts` byte for byte so operator and agent see identical output; **keep the two in sync**.
- **Automation tokens live in `~/.joro/automation.json` (0600, temp-file + rename), never in a project or user config.** A project config is **published to teammates**, so a grant set and an auto-starting listener riding along is a real privilege transfer; a user config round-trips through save/load/export and version-gated backfill, and security state must not inherit "helpfully add the new default" semantics. `~/.joro/configs/` is the namespace the UI browses, so a future export-all would sweep credentials up, and `joro.db` deletes its contents on open. **Grants are always fully expanded capability IDs — wildcards do not exist** anywhere, because `http.*` written today would silently grant a send-capable capability shipped later. New capabilities are surfaced per token (`ungrantedCapabilities`, plus a registry fingerprint stamped at last review) and never granted implicitly. The store keeps only a SHA-256 of the secret with no KDF, which is correct rather than lazy: the input is 256 bits of `crypto/rand`, so there is no guessable preimage to slow anyone down against. `Touch` is debounced through a 30s flush loop; mutations write synchronously.
- **Token profiles are curated grant bundles that expand at create time, never a stored reference.** `internal/capreg/profiles.go` declares five — `recon`, `tester`, `triage`, `setup`, `operator` — each carrying grants plus the token shape it expects (`RequireScope`, `AllowsSends`, rate limit, concurrency), served on `GET /automation/capabilities` as `profiles[]`. Selecting one in the grant picker copies the IDs into the token's grant list and the profile ID is **not** persisted: this is the no-wildcards rule applied one level up, since a profile that gains a send-capable capability in a later release must not retroactively widen a token issued today — `ungrantedCapabilities` surfaces the addition instead. `validateProfiles` runs in `Build` before `Seal` and **panics** on an unregistered or reserved grant, on a profile holding a `SendsTraffic` capability without `AllowsSends`, or on one that sets `RequireScope` while granting an `UnrestrictedOnly` capability (which would mint a token whose grant is denied on every call). A panic rather than a test because **the repo has no test files at all** — several comments in `internal/capability` and `internal/capreg` claim a test asserts these invariants, and none exists; startup validation is the posture that actually holds, and it runs before any listener binds. `setup` is the profile that ships the scope writes, so it sets `RequireScope: false` of necessity.
- **Credential header values are masked unless the token says otherwise, and the masking is length-preserving.** `httptools.MaskHeaders` (`redact.go`) overwrites the *values* of `authorization`, `proxy-authorization`, `cookie`, `set-cookie`, `x-api-key`, `x-auth-token`, `x-csrf-token` and `x-xsrf-token` with `*`, leaving names, framing and every byte offset intact — `http.read` reports offsets and `totalLength` against the same coordinates the History Raw tab uses, so a shorter placeholder would shift them and desynchronize `http.read` from `http.search`. It is applied at exactly two sites: the `http.read` handler and the two `partPicker` closures in `search.go`. Masking *before* the search is what stops `http.search` being an extraction oracle, for free. `http.diff` already reported these as `<present, redacted>` and now shares the one list. **The gate is `Token.AllowCredentials` → `Principal.AllowCredentials`, a policy field beside `RequireScope`/`HostAllow`, not a capability**: a capability is something you invoke, this modifies what other capabilities return, and registering a no-op tool would cost the model context on every turn. It defaults **false**, so a token that predates it is narrowed — the safe direction. Masked output always carries a `redacted:` line naming what was hidden, because a silent mask reads as an absent header and an agent would report an authenticated endpoint as unauthenticated. `AuditEntry.Credentials` records every call that could return them. **Known residual:** `history.list`'s `content` filter and `http.search`'s corpus prefilter grep the store's unmasked bytes, so a token without visibility can still *confirm* a guessed value by row count — a confirmation oracle against a rate-limited, audited token, not an extraction path. Left in place; the `history.list` description says so.
- **Sends carry a per-token cookie jar, and it never overrides the caller.** `httptools.Contexts` (`exec_context.go`) keys a stdlib `net/http/cookiejar` (with `publicsuffix.List` — `golang.org/x/net` is already a direct dep, so no new module) by token ID, so a login through `http.resend` keeps later sends authenticated without the agent ever handling the cookie — which it could not do anyway with masking on. Capture is unconditional; **application skips any cookie the request already carries, and is disabled entirely if the caller's edits touch the `Cookie` header**, because re-adding a session cookie to a request an agent deliberately stripped manufactures a false negative — the failure class `edit.go` names as the worst outcome in a security test. `useContext:false` opts out of both. The result line names any cookie the jar supplied. `http.batch` reads the jar but never writes it: variants run concurrently, so capturing per-worker would make which cookie wins depend on scheduling, and a batch must start every row from the same session state. Jars are dropped on token rotate/disable/revoke and on `applyProjectConfig`/`resetLiveProjectState` — a session belongs to the engagement it authenticated against.
- **The fuzzer is exposed at a fraction of the UI's limits, and its traffic is not captured.** `fuzzer.start` takes a captured `ref` plus structural edits that must introduce a `FUZZ` marker (reusing `ApplyEdits`, so the agent needs no new vocabulary), with an inline wordlist capped at 500 entries / 64 KB and threads at 10. One wordlist across however many markers the edits introduce, i.e. `AttackSpray` — no `wordlists` map from a model, and the wordlist bound is therefore also the request bound, so `AttackYolo`'s ten-million-item cartesian product is unreachable. It runs under `BgCtx()` because a campaign outlives the 30s invocation. Unlike `http.resend`, `fuzzer.Run` dials through `TransportConfig` directly rather than Joro's own proxy — the same path the Fuzz tab uses — so results are **not** captured into history and have no seq; the description says so, and `fuzzer.results` says to resend a payload with `http.resend` to inspect a body.
- **`intercept` stays read-only and `websocket` has no send.** `config.intercept.list` reports the queue without raw bytes; there is no forward or drop, because interception is the operator's last look before bytes reach their browser and a release has no undo. `websocket.list` reads captured frames; a WS send would need its own target extraction through `ws_manipulate.go` and is not exposed. `findings.create` mints a finding under the reserved rule ID `agent-reported`, which keeps it in an identity space `Finding.ID`'s `sha256(ruleID ‖ host ‖ groupDim)` means the scanner never produces — a rescan cannot collide with one.
- **Execution and C2 exist as capabilities only under a launch flag, and no profile grants one.** `--automation-privileged` is what makes `capreg.Build` register `exec.webshell` and `c2.{sliver,mythic}.{read,command}` at all; without it they cannot be granted, listed or invoked, and a grant stored from a previous run is inert. A launch flag rather than a Settings toggle so enabling cannot happen without a restart. `Capability.Privileged` is not the gate — it exists so `validateProfiles` **panics** if a profile names one, so the grant picker can mark it, and so `AuditEntry.Privileged` records it. **`validateProfiles` also had to change**: `operator` was `r.IDs()`, the whole registry, so the flag would have silently widened it; it is now every non-privileged capability. `exec.webshell` sets `SendsTraffic` with a target extractor over its URL, so the scope guard applies to it exactly as to `http.resend`; the `c2.*` capabilities do not, because they reach the operator's own team server rather than a web target and scope-guarding them would deny every call rather than bound anything — their descriptions say the grant is their only limit. `Deps` grows `Sliver`, `Mythic` and `Privileged`, which is the change that most tests `build.go`'s compiler-unenforced import rule. In the UI, ticking one of these in `GrantPicker` opens a second confirmation over the token modal (`ConfirmModal` at `z-[70]`, no autofocus and Enter does not confirm), once per editing session; every selection path routes through the picker's `commit`, so no preset or profile can add one without it. **No preset selects a privileged capability** — `Read-only` has to exclude them explicitly, since the `c2.*.read` capabilities are neither mutating nor send-capable and its plain predicate would otherwise pick them up.
- **Token profiles are curated grant bundles that expand at create time, never a stored reference.** `internal/capreg/profiles.go` declares five — `recon`, `tester`, `triage`, `setup`, `operator` — each carrying grants plus the token shape it expects (`RequireScope`, `AllowsSends`, `AllowsCredentials`, rate limit, concurrency), served on `GET /automation/capabilities` as `profiles[]`. Selecting one in the grant picker copies the IDs into the token's grant list and the profile ID is **not** persisted: this is the no-wildcards rule applied one level up, since a profile that gains a send-capable capability in a later release must not retroactively widen a token issued today — `ungrantedCapabilities` surfaces the addition instead. `validateProfiles` runs in `Build` before `Seal` and **panics** on an unregistered or reserved grant, on a profile holding a `SendsTraffic` capability without `AllowsSends`, on one naming a `Privileged` capability, or on one that sets `RequireScope` while granting an `UnrestrictedOnly` capability (which would mint a token whose grant is denied on every call). A panic rather than a test because **the repo has no test files at all** — several comments in `internal/capability` and `internal/capreg` claim a test asserts these invariants, and none exists; startup validation is the posture that actually holds, and it runs before any listener binds. `setup` is the profile that ships the scope writes, so it sets `RequireScope: false` of necessity — and because a scope-exempt token's reads are bounded by nothing (read capabilities carry no `Target`, so `HostAllow` never applies), it is the one profile **not** built on `reconGrants`: it is configuration-only and reads no captured traffic at all. The intercept reads live in `tester` rather than in `reconGrants` for the same reason, since they exist to explain a send that hung in the operator's queue and `recon`/`triage` cannot send.
- **The MCP server is off by default, loopback-only, and refuses to start with zero tokens** — an unauthenticated MCP server on loopback is a privilege-escalation gadget for every other local process. Its guard is stricter than `originGuard`: loopback `Host` with no `--allowed-host` escape, **any** `Origin` header is a 403 (a real MCP client is not a browser, so its presence is itself the signal), and it runs before auth. Transport is POST-only Streamable HTTP; `GET /mcp` is 405, which means `listChanged` is undeliverable and a grant edit reaches a client only on its next listing — surfaced in the UI rather than advertised as a capability. `tools/list` is filtered by grant. A tool failure is returned as a **successful** JSON-RPC response with `isError`, because that stays in the model's conversation where it can self-correct, whereas a JSON-RPC error may be swallowed by the client transport. Tool names map dots to underscores, which is total only because capability IDs may not contain an underscore. **Joro embeds no model and makes no outbound AI calls**; it speaks MCP as a server and holds no provider credentials.
- **Capability tokens are a leash on the agent, not a sandbox on the machine.** `originGuard` already admits any non-browser local client, so any process running as the operator can drive the full API directly and read `~/.joro/automation.json`. What tokens buy is scoping, an audit trail and a kill switch for the agent you chose to run. The Settings page says this in one line rather than leaving it implied.