Updated documentation

This commit is contained in:
Tony West committed 2026-08-18 03:03:45 -04:00
1 parent 91e0bcdbb6
commit b8cc32062d
1 file changed
+77 -11
+77 -11
View File
@@ -17,8 +17,8 @@ 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; `--automation-privileged` additionally registers the execution and C2 capabilities)
- Data dir `~/.joro/` — CA cert/key + `callbacks.db` + `automation.json` (0600, automation tokens)
- 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, and `--automation-scripting` registers `script.run`)
- Data dir `~/.joro/` — CA cert/key + `callbacks.db` + `automation.json` (0600, automation tokens) + `automations/<id>/` (installed JS automations: `joro.json`, the entry `.js`, `joro.state.json`)
- 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,6 +59,7 @@ 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_highlight.go history.highlight, over Deps.SetHighlight
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
@@ -70,7 +71,29 @@ internal/
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
caps_script.go script.run + the ScriptRunner interface. Registered ONLY
under --automation-scripting, and no profile grants it
profiles.go The five token profiles + startup validation (panics)
jsruntime/ The JavaScript sandbox. A leaf package: imports NOTHING from
the rest of Joro, so nothing on any type here could reach a
store, a token file or an HTTP client
jsruntime.go Runtime + HostBridge interfaces, Request/Result/Limits
vm.go goja: curated globals, interrupt watchdogs, budgets
sdk.go The Binding table (JS name -> capability ID) + the
injected shim. One source for the SDK surface AND the
grant bundle, so the two cannot disagree
worker.go WorkerRuntime (parent: spawn, pipe, kill) + RunWorker
(child), sharing one JSON frame format
jsautomation/ Run lifecycle + installed packages over the sealed registry
manager.go Spawn/kill, run concurrency, the registryBridge, Invoke
principal.go The automation-v1 bundle -> Principal; policy narrowing
runs.go Bounded run ring, source retained verbatim
manifest.go Manifest (author) + State (operator) + effective limits
store.go ~/.joro/automations/<id>/, atomic writes, id re-validated
at the path join rather than trusted from upstream
storage.go joro.storage: bounded per-automation KV, project-scoped
triggers.go The Dispatcher: ticker + doorbell, batching, the
self-trigger cursor rule, the runaway breaker
automation/ Bearer tokens + grants, ~/.joro/automation.json (0600)
httptools/ The token-efficient tool bodies (fingerprint, ranged read,
search, diff, structural edits, batch) + proxysend.go
@@ -99,6 +122,8 @@ internal/
callbacks, replace, customdata, plugins, team, sliver, ws, ...)
detect_loop.go Starts the detect scanner; owns the 4 cursor-reset helpers
handlers_automation.go Token/grant/audit/MCP control plane (UI-only)
handlers_script.go Script run log + installed-automation CRUD + the SDK
reference (UI-only, for the same reason)
automation_wire.go Builds the registry; SetAutomation; MCP listener supervisor
detect_project.go Detect <-> projectConfigFile DTO conversion
ws_relay.go Relay to teamserver, forwards team.* events
@@ -122,9 +147,9 @@ web/
dashboardData (polled), dashboardLayout (persisted)
pages/ Dashboard (a frame over the widget catalog), History,
Intercept, Manipulate (HTTP+WS), Generator, Executor, Fuzz,
DeadDrop, Login, Settings, Callbacks, Plugins, PluginTabPage
DeadDrop, Login, Settings, Callbacks, PluginTabPage
components/ AutomationSettings + TokenModal/GrantPicker/SecretModal/
Activity (Settings -> Automation; the secret modal is the
Activity/ScriptRuns (Settings -> Automation; the secret modal is the
only place a plaintext token is ever shown; the picker
renders profile buttons, groups by the server's `classes`
order, and badges write + unrestricted-only grants),
@@ -132,7 +157,18 @@ web/
DetectRuleModal (per-rule config; opened from the
Detect -> Rules table, whose categories collapse),
DashboardPanel (shared widget chrome; slot-aware via context),
DashboardLayoutSettings (the layout editor)
DashboardLayoutSettings (the layout editor),
PluginSettings (Settings -> Plugins: the Manage table +
one sub-tab per loaded `feature` plugin),
LensOutput (one lens tab's body: runs the automation over
the bytes on screen), TabButton (the Raw/Render/lens tab,
shared by all six request viewers)
components/automation/ ScriptsPanel (the installed list), ScriptEditor (CodeMirror
+ manifest form + Run + output), LensPanel (Settings ->
Automation -> Lenses: label, part and order per lens),
RunOutput (the run report, shared with History's
run-on-a-request menu), SdkReference (generated from
GET /automation/sdk)
components/dashboard/ One file per dashboard widget
examples/plugins/
hello-provider/ ExecProvider + GraphProvider example
@@ -291,7 +327,7 @@ All under `/api/v1/`. Request/response shapes are JSON unless noted. WebSocket e
- `POST /team/collab`, `GET /team/collab/{id}`, `POST /team/collab/{id}/accept` — collaboration requests; POST `{project, note, config}` where `config` is a JSON 3-field bundle (scope/M&R/customdata); posts a `refType:"collab"` chat chip
- (proxy-local, not team) `GET /api/v1/configs/export` (`{config}`), `POST /api/v1/configs/import` `{name, config}` (writes a **new** local project + loads it, preserving the importer's nickname, 409 on name collision), `POST /api/v1/configs/apply-shared` `{config, mode: replace|merge}` (applies scope/M&R/customdata to live state only)
**Projects** (proxy mode; project = a saved `~/.joro/configs/project/<name>.joro` snapshot)
**Projects** (proxy mode; project = a saved `~/.joro/configs/project/<name>.joro` snapshot; schema **v7** added `automationStates`)
- `GET /api/v1/configs/project` → `{active, configs[], projects[]}` where each `ProjectMeta` is `{name, savedAt, sizeBytes, requestCount, noteCount, findingCount, autoSave, saveHistory, active}` (size/savedAt from `os.Stat`; counts/prefs from the `<name>.meta.json` sidecar, backfilled once from the `.joro` for legacy files)
- `POST /api/v1/configs/project` `{name}` (save/snapshot active), `PUT /api/v1/configs/project/{name}` (load), `DELETE /api/v1/configs/project/{name}` (also removes the sidecar)
- `POST /api/v1/configs/project/new` `{name, empty, action?: save|discard, saveScratchAs?}` — creates a **new** project (409 on collision): `empty:false` snapshots the current session under the name; `empty:true` saves the outgoing session (like a switch) then `resetLiveProjectState()` (fresh baseline: no rules, default noise, empty history/notes/highlights; team settings kept) before saving
@@ -302,6 +338,10 @@ All under `/api/v1/`. Request/response shapes are JSON unless noted. WebSocket e
- `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). 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 /automation/runs` — `?offset=&limit=` → `{runs[], total, offset, limit}`; sandboxed script runs, newest first, **source omitted**. `GET /automation/runs/{id}` returns one run **with its verbatim source**; `DELETE /automation/runs` clears the ring. `POST /automation/runs` — `{scriptId?|source?, input?, trigger?, timeoutMs?}` runs one now; this is the only path that accepts **inline source**, because it is the operator's own same-origin request and an authoring surface that cannot run a draft is not one. `trigger` labels the run (default `"ui"`, and any name in `jsautomation.Triggers` plus `"lens"`); an unknown one is a 400 rather than a silent relabel. `trigger:"lens"` also selects the send-free principal
- `GET/POST /automation/scripts`, `GET/PUT/DELETE /automation/scripts/{id}`, `PUT /automation/scripts/{id}/enabled`, `PUT /automation/scripts/{id}/prefs` — installed automations. POST takes `{manifest, source}` and 409s on an id collision; PUT takes `expectedHash` and **409s without it while the automation is enabled**. `prefs` takes `{limits?, triggersDisabled?, hostAllow?, lensLabel?, lensPart?, lensOrder?}` and writes only the sidecar; an empty `lensLabel`/`lensPart` clears the override back to the manifest's value. The list withholds source, and carries each automation's **effective** lens (author's declaration with the operator's overrides already applied)
- `GET /automation/sdk` — the authoring reference: `jsruntime.Bindings` joined with each capability's registered title, description and `inputSchema`, plus the `joro.storage` verbs and the trigger names
- Every route in this group 404s with **two distinguishable messages** — one naming `--no-automation`, one naming `--automation-scripting` — so an operator is never sent looking at the wrong switch
- `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
@@ -344,7 +384,8 @@ detect.findings.cleared { deleted }
manipulate.ws.frame { sessionId, direction: sent|received, opcode, payload (b64), isText, size, ts }
manipulate.ws.closed { sessionId, reason }
mythic.event { eventType: callback-new, callback: { id, display_id, user, host, os, architecture, ip, payload_type } }
automation.mcp.state { enabled, running, port, endpoint, error, tokenCount } (the ONLY automation event — one per invocation would be a firehose the agent controls, on a channel shared with proxy traffic; Activity polls instead)
automation.mcp.state { enabled, running, port, endpoint, error, tokenCount } (per-invocation events would be a firehose the agent controls, on a channel shared with proxy traffic; Activity and the run list poll instead)
automation.script.state { id, enabled, paused, pausedReason } (only on a state change the operator did NOT make — the runaway breaker pausing an armed automation)
system.update.restarting {}
plugin.{name}.{eventType} { ... }
plugin.{name}.interaction { id, instanceId, hex, protocol, sourceIp, timestamp, queryName?, queryType?, method?, path?, rawRequest? }
@@ -362,6 +403,7 @@ plugin.{name}.interaction { id, instanceId, hex, protocol, sourceIp, timestamp,
| `modernc.org/sqlite` | Pure-Go SQLite (no CGO, cross-compiles) |
| `google.golang.org/grpc` + `google.golang.org/protobuf` | Sliver C2 client (protowire hand-encoded) |
| `github.com/spf13/pflag` | POSIX-compliant CLI flags |
| `github.com/dop251/goja` | JavaScript engine for the automation sandbox (pure Go, no CGO — so it works on Windows and on CGO-less builds, where Go plugins cannot load at all) |
| `github.com/BishopFox/joro/sdk` | Plugin SDK (local module via `replace`) |
| stdlib for everything else | `crypto/x509`, `crypto/ecdsa`, `embed`, `net/http`, `io/fs`, ... |
@@ -432,14 +474,14 @@ Tracked via `go.mod` / `go.sum` only — repo does **not** vendor (see "no vendo
- **Polling is centralized and keyed on the resolved layout.** `needs` is the union of the `DataNeed`s of the widgets actually in the layout, computed **before** render — not from mount-time registration, which races the first tick. A widget that isn't on the dashboard costs no requests. The `teamDown` short-circuit (skip listener-proxied polls when the relay is down) lives here; do not scatter it into widgets.
- **Persistence: `localStorage['joro-dashboard-layout']` (schema v2), plus `userConfigFile` v4.** Follows the `joro-hidden-tabs` pattern (hand-rolled `load`/`persist`, no zustand middleware). The Go side treats it as an opaque `json.RawMessage`, like `PluginStates`; **no normalizer** is needed since `userConfigFile.Version` is never read and a v3 file just decodes to `nil`. Not in the Go `Settings` struct (nothing there is cosmetic) and not in `projectConfigFile` (that is shared with teammates; this is per-operator chrome). First load seeds `barHeight` from the legacy `joro-chat-height` key.
- **A version bump migrates; it does not reset.** `sanitize()` still falls back to `defaults()` for a version it does not recognize, but a *known older* version is upgraded first (`migrateV1`). v1→v2 swapped the local bottom-left slot from `network-graph` to `automation-activity`, and only where that slot still held the graph — the same reasoning as `backfillOHTTPDefaults`: patch the one entry rather than unioning the default set, so a deliberate choice is never overwritten by a shipped default. `load()` persists once when the stored version differed, because a migration that never lands on disk re-runs on every load and leaves the file claiming a schema it no longer has. That write is **not** an exception to the never-written-back rule two bullets up: that rule governs render-time reconciliation of unavailable widget ids, which must stay non-destructive so a downgrade cannot eat a layout. A migration is the opposite kind of operation and has to persist to be one.
- **Project-config settings live in Settings → Project, inline under the project table.** `web/src/components/ProjectSettings.tsx` owns the **Team Server** card, **Team Configs** panel (`TeamConfigsPanel`), and the tabbed **Filtering** card (Scope / Noise / Match&Replace / Custom Data) — all backed by `projectConfigFile`; it hydrates on mount + on `joro:project-changed`. It is rendered inline by `ProjectBrowser.tsx` below the project table (no longer a modal). `web/src/pages/Settings.tsx` is a sidebar-driven page (`CATEGORIES` array + `category` state): **Project** (the `ProjectBrowser`), **General** (user-config + machine settings: Server, Proxy, Connection, SOCKS, User Config), **Appearance**, and **Testing Browser**.
- **Project-config settings live in Settings → Project, inline under the project table.** `web/src/components/ProjectSettings.tsx` owns the **Team Server** card, **Team Configs** panel (`TeamConfigsPanel`), and the tabbed **Filtering** card (Scope / Noise / Match&Replace / Custom Data) — all backed by `projectConfigFile`; it hydrates on mount + on `joro:project-changed`. It is rendered inline by `ProjectBrowser.tsx` below the project table (no longer a modal). `web/src/pages/Settings.tsx` is a sidebar-driven page (`CATEGORIES` array + `category` state): **General** (user-config + machine settings: Server, Proxy, Connection, SOCKS, User Config), **Appearance**, **Project** (the `ProjectBrowser`), **Testing Browser**, **Plugins** (`PluginSettings`), and **Automation** (`AutomationSettings`). Every category renders into one shared padded, scrolling card — **except Plugins**, whose panel is full-bleed `flex flex-col overflow-hidden` because its feature-plugin sub-tabs are iframes and a block parent gives a `flex-1` child no height. Settings owns the single `api.listPlugins()` call and passes the list down, so a plugin upload/delete refreshes the Appearance → "Visible tabs" list from the same fetch.
- **Feature: publish / load a shared project config (async, whole-project).** `GET /api/v1/configs/export` serializes the current live project (via `buildProjectConfig` + `gzipJSON`, the same helpers the save handler uses) to base64(gzip); the frontend publishes it to the `team_shared_configs` table on the team server (`POST /team/configs {name, project, config}`, blob opaque to the server) and it appears in the Settings **Team Configs** panel. Loading calls `POST /api/v1/configs/import`, which writes a **new** local project file and runs the shared `applyProjectConfig` — **preserving the importer's own nickname** (adopts the shared listener URL + token) and returning **409 on a name collision** rather than clobbering. This shares the full project (scope/M&R/customdata + noise, notes, highlights, history, plugin states, team settings).
- **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 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.
- **There are 41 capabilities across twelve classes (46 across fourteen with `--automation-privileged`), and 16 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.
- **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, noise or highlight 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. The two halves are enforced differently: importing `api` here **is** a cycle (`api` imports `capreg` to build the registry), so the compiler rejects it, but importing `automation` is not a cycle and would compile — so that half rests on review alone, and 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.
- **A capability may edit scope only if the token is not itself restricted by scope.** `Register` still panics on a mutating `ClassScope` capability unless it sets `UnrestrictedOnly`, and `Invoke` then refuses that capability to any principal with `RequireScope` set or a non-empty `HostAllow` (`CodeTokenRestricted`, in `isDenial`). The asymmetry is the whole argument: a token with `RequireScope` off and an empty whitelist **already passes `checkTarget` for every host** — the scope clause is skipped and the whitelist clause is vacuous — so editing scope grants it no reach it did not have, and there is no containment check to get wrong; while a token whose authorization control *is* scope must never edit that control. `Capability.availableTo` is the single predicate, shared by `Invoke` and `Registry.List`, so a tool that would always be denied is never advertised and the two cannot disagree. **The verb set is `scope.addrule` (include-only) and `scope.enable`, with no exclude, remove or disable**, which makes scope writes strictly capture-*increasing*: excluding or removing is the only direction that reduces what Joro observes, and reducing observation is an evidence-suppression primitive on precisely the `requireScope:false` token this permits — an agent could otherwise exclude its own targets, keep sending successfully (`SendViaProxy` reads responses itself and history correlation is optional), and leave the operator with no record of the agent's traffic. `scope.enable` additionally **refuses unless an include rule already exists**, because enabled scope with zero rules puts nothing in scope and would silently stop all capture with no disable verb to undo it. What the operator accepts by granting this, and what the grant picker says in as many words: scope is the interception decision (`handler.go:91`, `mitm.go:67`), and the read capabilities carry no `Target` so `HostAllow` never bounds them — an agent can scope a host the operator left alone, wait for them to browse it, and read the plaintext.
@@ -456,7 +498,31 @@ Tracked via `go.mod` / `go.sum` only — repo does **not** vendor (see "no vendo
- **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.
- **Plugin system uses Go's `plugin` package** (.so on Linux, .dylib on macOS). **Linux and macOS only** — Go's plugin package does not support Windows or any GOOS outside Linux/Darwin/FreeBSD. `joro --build-plugin` errors immediately on Windows; release binaries on Windows still load fine but cannot use plugins. Plugin support requires the host binary to be built with `CGO_ENABLED=1`; the goreleaser config + Makefile do this via `zig cc`. Loaded at startup from `~/.joro/plugins/`. Each exports `var Plugin sdk.Plugin`. SDK in `sdk/sdk.go` as separate Go module (`replace` in go.mod). Six plugin types: `exec_provider`, `tab`, `feature`, `proxy_hook`, `dashboard` (only one active), `interact_provider`. Names match `^[a-z0-9][a-z0-9_-]*$`, can't be reserved (`api`, `ws`, `ext`, `system`). All method calls wrapped with panic recovery. Plugins get scoped data dir (`~/.joro/plugin-data/{name}/`) and scoped WS broadcast (events auto-prefixed `plugin.{name}.`). Tab/feature/dashboard plugins serve embedded UIs at `/plugin/{name}/` in iframes sandboxed with `allow-scripts allow-forms allow-same-origin`. `allow-same-origin` makes their `/api/v1/*` calls genuine same-origin requests (so they pass `originGuard` with no plugin code changes); it's safe because plugins are already trusted native code running in-process, so the iframe sandbox was never a real boundary against them. Upload/delete require restart — Plugins page has "Restart Now" button (`POST /api/v1/system/restart`, same `syscall.Exec` re-exec as updates). Proxy hooks run in load order; `OnRequest` returning nil drops a request. `ConfigField.Type` ∈ `text|password|textarea|file|checkbox`; checkboxes serialize as `"true"`/`"false"` to preserve `map[string]string` wire shape.
- **`script.run` runs submitted JavaScript against a fixed SDK bundle, and its grants are not intersected with the caller's.** This is the one capability whose authority is not its own: a token holding `instance.get` + `script.run` may run code that reads history, resends requests and writes findings, because the operator authorized *a standard automation surface* rather than a list of checkboxes. It exists because an agent sweeping fifty object IDs otherwise spends fifty round trips of model context doing one `http.resend` at a time, and because Go plugins cannot load on Windows or a CGO-less build, so a pure-Go scripting tier is the only cross-platform extension path Joro can have.
- **Grants are fixed; policy only narrows.** The run principal takes its `Grants` from the bundle, but `RequireScope` is pinned **true**, `AllowCredentials` pinned **false**, and `HostAllow` inherited verbatim from the launching token — the restrictive direction only. *Grants say what the code may do; policy says where it may reach.* Without that second half, an operator who leashed a token to one staging host and then granted `script.run` would have silently handed an agent every host in scope, against a guard written to fail closed. Pinning `RequireScope` also means `validateProfiles`' existing `UnrestrictedOnly && RequireScope` panic makes a bundle containing `scope.addrule`/`scope.enable` **fail to start** — the exclusion is enforced, not documented.
- **The run's synthetic principal *is* the execution session.** Every per-caller limit in the registry keys on `Principal.TokenID` — the rate bucket, the concurrency counter (`limits.go`), the cookie jar (`httptools/exec_context.go`) — so giving a run its own `TokenID` (`run_<hex>`) gives it its own budget, slot and session for free, and no new machinery in `internal/capability`. Reusing the caller's would charge a script's reads against the operator's own minute budget and return `busy` on **every** SDK call for a token at `MaxConcurrent: 1`, which is the minimum the UI offers. Teardown is mandatory and deferred: `Registry.Forget(runID)` (or the limiter map grows one entry per run, forever) and `Contexts.Reset(runID)`. `Principal.RunID` → `AuditEntry.RunID` groups the children; the parent row's readable link is its `RecordChange` summary, and the run log is the parent record.
- **The VM runs in a worker process, because goja has no memory ceiling of any kind.** `--script-worker` re-execs this binary; the host bridge is JSON frames over stdin/stdout. Termination is `Process.Kill()` rather than cooperation, and `new Array(1e9).fill(0)` costs the worker — which matters because the capture store is an in-memory ring buffer, so an OOM in the proxy loses the engagement's traffic. In-process the ceiling is `debug.SetMemoryLimit` plus a `ReadMemStats` watchdog calling `vm.Interrupt`, a mitigation rather than a wall. The JSON-only data boundary also stops being a rule someone has to keep: a Go pointer cannot be written to a pipe. The job travels on **stdin, never argv**, which is world-readable via `ps` and a script may legitimately carry a session token. Both sides hold the deadline — the child self-interrupts so the operator gets a real termination reason, the parent kills at deadline + grace. Concurrent runs are capped at 2, because each active run holds two of the registry's eight global concurrency slots.
- **Every SDK call is synchronous inside the VM, and `await` is cosmetic.** `await` on a non-thenable resolves immediately, and goja drains its own job queue, so an `async function run()` whose awaits all resolve from SDK calls is already settled when the call returns — verified, including `await` inside a loop. That deletes the Promise/event-loop integration, makes the pipe protocol strict request/response, and makes budget accounting exact because calls cannot interleave. `Promise.all` silently serializes, which is the wanted behavior anyway. A promise left genuinely pending is reported with an explanation, since the sandbox has no timers or I/O for anything else to resolve it.
- **`jsruntime.Bindings` is one source for the SDK surface and the grant bundle.** The injected shim and `CapabilityIDs()` both derive from it, so a method that exists is granted and a grant that exists is callable; widening the SDK and widening the bundle are the same edit. Excluded, each for its own reason: `config.*.edit` (a script adding a Match & Replace rule silently rewrites the operator's traffic — setup is a different job with its own profile), all `detect.*` writes, both scope writes, everything privileged, and `script.*` itself (a script that can start scripts launders its budget).
- **The runtime contract is a frozen `joro` global, not ES modules.** goja's module support is described by its own maintainers as predating async/await and being a hack; async/await and Promise are solid. So a program is a classic script defining `async function run(ctx)`. `@joro/sdk` survives as the name of the published type declaration, and a **bounded** rewrite erases `import … from "@joro/sdk"` and a leading `export`, replacing them with spaces so line and column numbers in a stack trace still point where the author looked. An import of anything else is an error naming it — never a silent rewrite, which would report a missing variable when the real problem is that dependencies must be bundled. Erasure applies only to module syntax in **code position**: `codeMask` marks the bytes that are not inside a string, template or comment, because a template holding `export const x = 1` that got rewritten would mean the source an operator reads back is not the source that ran — the one property the run log exists to provide. Entry resolution runs as a trailing statement *inside* the user's script, so `const run = async () => {}` is found; it never becomes a property of `globalThis`.
- **Reading a script-visible value is running script code, so it happens inside `vm.Try` with the watchdogs still armed.** `ToObject` on a thrown primitive allocates *through* the runtime, and `.name`/`.message`/`.stack` or a returned object's getters and `toJSON` are accessors the script defined — goja documents all of these as panicking with `*Exception`. Rendering an error and exporting a result therefore run under `Try`, and the watchdogs are stopped by a single `defer` at the end rather than before those reads, so a looping getter is still interrupted. `VM.Run` and `RunWorker` each end with a `recover()` as a last resort, because a panic escaping is the one failure this package cannot report — and `workerLostDetail` distinguishes a Go panic from an out-of-memory kill, since calling a crash "the containment working" would guarantee nobody investigated it.
- **Nothing crosses the host boundary but strings.** A Go error returned to goja is wrapped in a `GoError` whose value is a reflected Go object, so failures cross as a JSON envelope the shim turns into a JavaScript `throw` carrying `err.code` and `err.capability`; `GoError` itself is deleted from the global. goja's default global object holds ECMAScript built-ins and **nothing else** — no `process`, `require`, `fetch`, `setTimeout` or `console` — so there is no ambient authority to remove, only a bridge to add, and `console` is built in JavaScript so formatting never reflects over script data.
- **`Privileged: true` plus its own launch flag, reusing four existing mechanisms unchanged**: excluded from the `operator` profile's automatic expansion, a `validateProfiles` panic if any profile bundles it, the grant picker's second confirmation modal, and `AuditEntry.Privileged`. The flag is `--automation-scripting` rather than `--automation-privileged`, which means web shell and C2 specifically — an operator should be able to take one without the other.
- **Run records keep the source verbatim, not just a hash.** For an installed automation a hash answers "did this change?"; a one-shot script submitted over MCP has no artifact to compare against, so a hash would leave an operator holding a fingerprint of code nobody kept. On a tool that sends traffic to a client's systems, reading exactly what an agent ran is the point — which is why the run log endpoints ship with the capability rather than waiting for the authoring UI. They are UI-only like the rest of the control plane, and here that matters more than elsewhere: a script able to read the run log would be a lateral channel between tokens.
- **An installed automation is three files on disk, never a project-config entry.** `~/.joro/automations/<id>/` holds `joro.json` (author-owned), the entry `.js`, and `joro.state.json` (operator-owned). Not in the project config, because a project config is **published to teammates** and shipping executable code that runs against the full SDK bundle is the same privilege transfer that keeps `automation.json` out of one — which makes automations machine-global, and `enabled` with them. Three files rather than one because the source has to be a real `.js` an operator can open in their own editor, and because author intent and operator intent must not overwrite each other: effective limits are `min(manifest, operator)` then `Limits.Normalize()`, so an automation can only ever narrow itself. The sidecar's `triggersDisabled` is named for what a `true` means, because the polarity is the field's entire content and a reader who infers it from a call site will eventually infer it backwards; the handler rejects an unknown trigger name rather than storing one, since a typo would otherwise be accepted with a 200 and silently mean "armed". The one exception is `MinIntervalMs`, which lives outside `Limits` and combines with **max** — for an interval the conservative value is the larger one. `configstore` was not reused: it is flat `<name>.<ext>` files, which would force the JavaScript into a JSON string. Writes are temp-file + rename, which `configstore` does not do and installed code deserves.
- **`entrypoint` is one filename and multi-file packages are refused**, because there is no module loader — the runtime contract is one classic script, so bundling is the author's job. **Revisions are metadata only** (`{hash, at, bytes}`): "did the code change?" is answered there and "what exactly ran?" by the run log, which retains each run's source *and* its hash, so a third content store would answer nothing new. An update to an **enabled** automation requires `expectedHash`, making silent replacement a precondition failure rather than a warning.
- **`script.invoke` is the narrower grant, and the more likely posture.** An operator can let an agent run automations they wrote and reviewed without letting it submit code: `script.list` (metadata only, never source — source would make it a lateral channel between tokens) plus `script.invoke` is strictly less than `script.run`. An agent may invoke only an **enabled** automation; the operator can always run a disabled one from the UI, because reviewing something means running it before arming it. Both are `Privileged` under the same flag, and `ScriptRunner` widened rather than `Deps` gaining a field — `build.go:12` names that as the change which breaks trust layer 2.
- **`joro.storage` is the one SDK surface that is not a capability**, because there is nothing to authorize: the namespace is bound by the manager from the automation's own id and is never an argument, and storage reaches nothing — not a target, not the operator's configuration. It is memory, not authority. It rides in `projectConfigFile` (v7 `automationStates`) as an opaque blob with the *same* `encodePluginStates`/`mergePluginStates`/ghost-preservation machinery as plugin state, resets on project switch, and folds into `liveStateSignature` as a **revision counter** — an opaque blob's size can change while its count does not. `Apply` re-applies every bound `Op` enforces, dropping what does not fit: a project config is an imported artifact, so it is an untrusted write path into state a script trusts, and seeding an "already tested" set would suppress coverage silently. A one-shot `script.run` has no namespace and is told so, rather than handed a scratchpad that silently evaporates.
- **Every trigger delivers a batch, which is what makes the traffic trigger affordable.** The `Dispatcher` copies `detect.Scanner` — a 250ms ticker plus a capacity-1 doorbell — and for `request.captured` the doorbell is *only* a doorbell: the proxy emits that event with a non-blocking send, so it is already lossy, and a `Store.SinceSeq` cursor is what is authoritative. One run per interval handles up to 50 events, so there is no process spawn per request and nothing to overflow in the common case. Overflow of the discrete-event buffer is reported **to the script** as `ctx.trigger.dropped`, not merely logged. `callback.interaction` is deliberately **not** a trigger: every callback listener is built in listener mode and scripting is proxy-mode only, so it could never fire.
- **A trigger fires on engagement activity, not on an agent's.** `findings.create` broadcasts `detect.finding`, and it is non-privileged, bundled into the stock `tester`/`triage` profiles, and takes `host` as a free string with no target extractor — so treating its broadcast as a trigger would let any token holding it induce a run. That matters because a trigger-fired run has **no launching token**: `AutomationPrincipal` carries no `HostAllow`, so it is bounded by scope alone and could reach a host the inducing token is itself denied. It is a confused deputy around exactly the grant meant to gate this, `script.invoke`, which is privileged, flag-gated, refused to every profile and audited. So the dispatcher drops a finding whose rule is the reserved `agent-reported` id, and the operator can additionally set a per-automation `hostAllow` in the sidecar — applied only when the caller has none of its own, since a token's leash is the stricter authority and two whitelists in one field cannot be ANDed. Residual gap worth knowing: a trigger-fired run writes no audit entry of its own, only its nested SDK calls, so the run log is the parent record.
- **The self-trigger loop is closed by the counter the manager already has.** A traffic-triggered script that calls `http.resend` produces traffic that re-triggers it. On finish: if `Result.SendCalls > 0` the cursor jumps to `Store.LastSeq()` **as of then**, which is past its own sends and therefore skips exactly the window it caused; if it sent nothing — a watcher, the common case — the cursor advances only to the highest seq it was actually handed, so a long run swallows nothing. Reading the head *before* the run instead is the obvious bug and does not work. A runs-per-minute **breaker** covers the longer loops this misses (send → detect scans it → new finding → trigger): it sets `paused` in the sidecar so a restart does not re-arm a runaway, and leaves `enabled` alone so Resume restores the operator's intent.
- **`Hub.Subscribe` is the only new bus machinery, and its sends are non-blocking.** `Hub.Run` was the single consumer of one 512-deep channel; subscribers are fanned out inside the loop body it already holds `mu.RLock` for. Non-blocking is load-bearing rather than defensive: `fuzzer.result` sends **blocking** from every worker goroutine, as do the callback listeners and the relay, so a subscriber that could stall `Run` would stall every WebSocket client and back-pressure the proxy behind them. `detect.finding` has two producers with different concrete payload types — the scanner sends a summary, `findings.create` sends the whole finding — so the dispatcher re-encodes through a struct holding only the fields both share, rather than type-asserting one of them and silently ignoring the other.
- **An automation reaches the operator at three points beyond the run log, and each is a different kind of thing.** `request.selected` is a trigger the dispatcher never watches (excluded from `ArmedTriggers` like `manual`): it puts the automation in History's context menus, invoked with `{ref, method, url, host}` as `ctx.input`. A **lens** is a manifest `{label, part}` declaration that adds a tab beside Raw and Render, handed `ctx.input.raw` — the base64 of the bytes the viewer is already showing, capped at 512 KB with a `truncated` flag — and returning `{text, language?}`. `history.highlight` colours a row, reaching `APIServer.highlights` through a `Deps.SetHighlight` func field, the same shape `ActiveProject` uses.
- **A lens run drops its send grants, and the trigger is what selects that.** `RunRequest.NoSend` filters every `SendsTraffic` capability out of the principal's grant map, so `joro.http.resend` comes back `forbidden` rather than merely failing. It is a grant restriction and not a budget because `Limits.Normalize` runs each field through `clampInt(v, def, max)`, where a non-positive value means "take the default" — `MaxSendCalls: 0` is not representable. The handler derives it from `trigger == "lens"` rather than reading a boolean off the body, so the guarantee belongs to the surface rather than to what the client asked for. A lens tab runs on selection, unattended, over bytes the operator is already looking at; it has no business reaching a target.
- **Author and operator own different halves of a lens, as everywhere else in a package.** The manifest declares `label` and `part`; `State.LensLabel`/`LensPart`/`LensOrder` override them and live in the sidecar, so updating the source never reverts a retitled tab. `Automation.EffectiveLens()` merges the two and `Summary` carries the result, so no caller holds both halves. Visibility is the automation's own `Enabled` rather than a second switch: a lens declares no triggers, so enabling one arms nothing, and installed-disabled already means a fresh lens does not appear in every viewer unasked.
- **`atob` and `btoa` are defined in the shim, in JavaScript.** goja's global object holds ECMAScript built-ins and nothing else, so neither existed — which made `ctx.input.raw` undecodable and the lens contract unusable. They operate on strings only and reach no host function, so they add no authority. `GET /automation/sdk` reports them under `globals` beside `console`.
- **`internal/jsruntime` imports nothing from Joro**, which is what keeps the worker honest — the child links a JavaScript engine and a pipe and has no way to reach a store or a token file even by accident. `capreg` imports `internal/jsautomation` for the `ScriptRunner` interface; that is neither `internal/api` (a cycle the compiler rejects) nor `internal/automation` (the token store), and jsautomation holds only a capability `Invoker` and a cookie-jar handle, so it hands a capability body no reach it did not already have.
- **Plugin system uses Go's `plugin` package** (.so on Linux, .dylib on macOS). **Linux and macOS only** — Go's plugin package does not support Windows or any GOOS outside Linux/Darwin/FreeBSD. `joro --build-plugin` errors immediately on Windows; release binaries on Windows still load fine but cannot use plugins. Plugin support requires the host binary to be built with `CGO_ENABLED=1`; the goreleaser config + Makefile do this via `zig cc`. Loaded at startup from `~/.joro/plugins/`. Each exports `var Plugin sdk.Plugin`. SDK in `sdk/sdk.go` as separate Go module (`replace` in go.mod). Six plugin types: `exec_provider`, `tab`, `feature`, `proxy_hook`, `dashboard` (only one active), `interact_provider`. Names match `^[a-z0-9][a-z0-9_-]*$`, can't be reserved (`api`, `ws`, `ext`, `system`). All method calls wrapped with panic recovery. Plugins get scoped data dir (`~/.joro/plugin-data/{name}/`) and scoped WS broadcast (events auto-prefixed `plugin.{name}.`). Tab/feature/dashboard plugins serve embedded UIs at `/plugin/{name}/` in iframes sandboxed with `allow-scripts allow-forms allow-same-origin`. `allow-same-origin` makes their `/api/v1/*` calls genuine same-origin requests (so they pass `originGuard` with no plugin code changes); it's safe because plugins are already trusted native code running in-process, so the iframe sandbox was never a real boundary against them. Plugin management lives at **Settings → Plugins** (`web/src/components/PluginSettings.tsx`) — there is no top-level Plugins tab and no `/plugins` route; `feature`-type plugins are sub-tabs of that category, beside its **Manage** tab. Upload/delete require restart — the Manage tab has the "Restart Now" button (`POST /api/v1/system/restart`, same `syscall.Exec` re-exec as updates). Proxy hooks run in load order; `OnRequest` returning nil drops a request. `ConfigField.Type` ∈ `text|password|textarea|file|checkbox`; checkboxes serialize as `"true"`/`"false"` to preserve `map[string]string` wire shape.
- **Plugin state persistence is opt-in via two SDK interfaces.** `UserStatefulPlugin` (`ExportUserState`/`ImportUserState`) — operator-scoped state riding with User Configs (API keys, personal tokens). `ProjectStatefulPlugin` (`ExportProjectState`/`ImportProjectState`) — engagement-scoped state riding with Project Configs (active sessions, instance configs). May implement either, both, or neither. State bytes are opaque — plugins own schema and migration. No autosave on shutdown, no separate on-disk state files: serialized only when user saves a User/Project Config, applied only on load. `internal/plugins/manager.go` exposes `{Export,Apply}{User,Project}States`. Config handlers in `internal/api/handlers_configs.go` embed a `pluginStates` map (name → base64 blob) in `userConfigFile` (v4) and `projectConfigFile` (v6), and ghost-preserve blobs for plugins not installed locally via `APIServer.pendingUserPluginStates` / `pendingProjectPluginStates`, so a load→save round-trip never drops state for missing plugins. Load responses include `unknownPluginStates: []` shown in Settings.
- **Interactsh shipped as an example plugin** (`examples/plugins/interactsh/`), not native. Reimplements interactsh wire protocol with stdlib only — RSA-2048 keygen, RSA-OAEP-SHA256 for session AES-256 key, AES-CTR for per-interaction payloads, per-instance `http.Client` with opt-in `InsecureSkipVerify` for self-signed self-hosted servers. Main binary has zero `projectdiscovery/*` deps. Implements `ProjectStatefulPlugin` (`state.go`): saves RSA keypair (PKCS#1 PEM), correlation ID, nonce, secret key, auth token, enabled state per server. Loading reconstructs servers and resumes polling without re-registering, so in-flight interactions keep decrypting against the existing session. Correlation IDs only useful while the remote server retains them (~24h on oast.live).
- **No `vendor/` directory.** Tracked via `go.mod`/`go.sum` only. Plugins have own `go.mod` with `replace github.com/BishopFox/joro/sdk => ../../../sdk`, building in mod-mode. If the main binary built in vendor-mode, its module graph would hash differently than the plugin's and Go's plugin loader would reject the .so/.dylib with `plugin was built with a different version of package github.com/BishopFox/joro/sdk`. Do not run `go mod vendor` or commit a `vendor/` tree.