Removed CLAUDE.md file from the repo, as documentation will live in the wiki

This commit is contained in:
Tony West committed 2026-08-20 12:06:28 -04:00
1 parent 17b84d9c31
commit 1f475de278
2 files changed
+3 -575

No files matched your search

+3
View File
@@ -13,3 +13,6 @@ web/node_modules/
# Tests are not committed to the public repo
*test*.go
# Agent instructions are local-only
CLAUDE.md
-575
View File
@@ -1,575 +0,0 @@
# Joro - Claude Code Instructions
## Keeping This File Fresh
**Update this file whenever significant changes are made to the project** - new packages, changed commands, architectural decisions, or new conventions. Outdated instructions cause mistakes. If you add a dependency, change a build step, or restructure a package, update the relevant section here before finishing the task.
---
## Project Overview
Joro is an intercepting HTTP/HTTPS proxy and web shell toolkit for penetration testing. It is a single Go binary that starts a proxy server and serves a React web UI - there is no CLI mode.
Three modes:
- **Proxy mode** (default): intercepting proxy + web UI
- **Listener mode** (`--listener`): out-of-band callback server (DNS + HTTP + SMTP) for blind vuln detection
- **Team Server mode** (`--listener --teamserver`): listener + authenticated team collaboration (chat, notes, flagged requests, shared project configs + collaboration swap)
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, 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)
---
## Repository Structure
```
main.go Entrypoint (proxy or listener mode)
internal/
config/ Config struct + defaults
event/ Shared WSEvent struct (avoids proxy/callback import cycle)
callback/ SQLite (modernc.org/sqlite), token CRUD, DNS + HTTP listeners
cert/ ECDSA P-256 CA, leaf gen, sync.Map cache
proxy/
handler.go ServeHTTP: CONNECT vs plain HTTP
mitm.go TLS termination + HTTP/1.1 loop
intercept.go Per-request channel queue with timeout
noise.go Silently tunnels browser background traffic
scope.go Two-level scope (host + method/path)
store.go Thread-safe ring buffer
replace.go Match & Replace (raw-byte rules)
customdata.go Additive header/query/body injection
websocket.go ws_relay.go ws_store.go WS MITM (custom frames over net.Conn)
ws_manipulate.go User-driven outbound WS sessions
client.go helpers.go HTTP client + utilities
team/ Team chat + notes tables, bearer-token auth middleware
capability/ Automation authorization layer. Pure policy: imports no
other internal package (defines ScopeChecker rather than
importing proxy, Principal rather than importing automation)
capability.go Capability, Class, Handler, Typed[T] decode adapter
registry.go Register/List/Invoke — the single guarded choke point;
availableTo is shared by List and Invoke so an
always-denied tool is never advertised
guard.go Scope + host-whitelist evaluation, failing closed
limits.go audit.go Token bucket + concurrency; the Activity ring buffer +
RecordChange, the readable record of a mutation
reserved.go Reserved ID namespaces (token/grant/mcp/admin/...)
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
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
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
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)
detect/ Passive vulnerability detection over captured traffic
detect.go Enums, Rule, Finding, Occurrence, Config, Summary
rules_builtin.go ~130 built-in regex rules (function, not a package var)
analyzers.go ~37 Go analyzers (headers, cookies, relational checks)
postfilters.go Named validators: luhn, iban97, ssn, denylist, ...
entropy.go Shannon entropy + placeholder detection
parse.go Raw bytes -> Message (decompress, gate, size-cap)
engine.go store.go Immutable ruleSet snapshot + dedup findings store
scanner.go 250ms cursor loop + rescan worker fan-out
shell/ ASP/ASPX/PHP/etc. shell gen + executor + dictionary
sliver/ gRPC client for Sliver C2 (custom protowire encoding)
plugins/ Plugin lifecycle: load, categorize, init, shutdown
api/
server.go routes.go APIServer + route registration + SPA embedding
ws.go WebSocket hub (gorilla/websocket)
handlers_*.go Per-feature handlers (requests, intercept, manipulate,
generate, execute, fuzzer, detect, settings, certs,
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
sdk/sdk.go Plugin SDK: interfaces, types, constants (separate Go module)
web/
embed.go //go:embed dist
dist/ Built frontend (gitignored except placeholder)
src/
main.tsx App.tsx index.css vite-env.d.ts
themes/bishop-fox.css Default dark theme (BF brand palette)
lib/api.ts ws.ts Typed fetch wrapper + WS singleton (auto-reconnect)
lib/bytes.ts base64/latin-1/hex conversion, hex dump, binary sniff
lib/ohttp.ts RFC 9458 envelope + key-config decoding (no crypto)
lib/deaddrop.ts .jord export/import (gzip via CompressionStream, base64 raw bytes)
lib/dashboardWidgets.tsx Dashboard widget catalog (id, availability, data needs)
lib/dashboardPresets.tsx Layout presets + the team/local default layouts
lib/useDashboardPolling.ts The dashboard's single poll loop
stores/*.ts Zustand: automation (tokens/capabilities/MCP state),
request, fuzz, intercept, settings, callback,
ws, manipulateWS, team, teamFlagged, teamSharedConfig, deadDrop,
dashboardData (polled), dashboardLayout (persisted)
pages/ Dashboard (a frame over the widget catalog), History,
Intercept, Manipulate (HTTP+WS), Generator, Executor, Fuzz,
DeadDrop, Login, Settings, Callbacks, PluginTabPage
components/ AutomationSettings + TokenModal/GrantPicker/SecretModal/
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),
DynamicConfigForm (auto-gen plugin ExecProvider config),
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),
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
hello-tab/ hello-feature/ Top-level tab + sub-tab plugin examples
hello-dashboard/ Dashboard replacement example
interactsh/ InteractProvider: stdlib-only interactsh client
Makefile build, build-frontend, build-all, dev, clean
```
---
## Build Commands
```bash
go build ./... # Go-only (uses placeholder frontend, works without npm)
make build # Full (frontend + Go binary)
make build-all # Cross-platform → dist/
make dev # Backend with --dev flag (proxies UI to Vite)
cd web && npm run dev # Vite dev server (separate terminal, with `make dev`)
# Build a plugin from source (auto-detects .so vs .dylib)
./joro --build-plugin examples/plugins/hello-feature
./joro --build-plugin examples/plugins/hello-feature --install # also installs to ~/.joro/plugins/
# Or manually:
cd examples/plugins/hello-feature
go build -buildmode=plugin -o hello-feature.dylib . # macOS
go build -buildmode=plugin -o hello-feature.so . # Linux
```
**Tests are not committed to the public repo, and `.gitignore` enforces it.** There is no test suite, no `make test` target, and no CI workflow; `CONTRIBUTING.md` asks for a manual verification note in the PR description instead. The `*test*.go` ignore pattern means a test file you write **will never appear in `git status`** — that is deliberate, but it also silently swallows any source file whose name contains "test" (`tester.go`, `testbrowser.go`), so name new source files around it or use `git add -f` to override. Do not write comments claiming a test pins an invariant: state the invariant, and name the real enforcement (a `MustCompile` pattern, a startup panic) or say plainly that it is checked by hand in review.
### Releases
Tagged releases use [goreleaser](https://goreleaser.com) (config: `.goreleaser.yaml`). `make build` / `make build-all` are the local-dev workflow; goreleaser is only for cutting releases.
```bash
goreleaser release --snapshot --clean # Local snapshot — sanity check config
goreleaser check # Verify config syntax
git tag v1.0.1 && git push --tags && goreleaser release --clean # Cut release (needs GITHUB_TOKEN)
```
Goreleaser produces 6 binaries (linux/{amd64,arm64}, darwin/{amd64,arm64}, windows/{amd64,arm64}) in tar.gz/zip archives with LICENSE + README, plus `checksums.txt`. All targets are built with `CGO_ENABLED=1` via `zig cc` cross-compilers — required so Go's `plugin` package (dlopen-based) works in release binaries. `make build-all` mirrors the goreleaser config (also CGO=1 + zig cc) and produces all 6 targets. **Requires `zig` on PATH** (`brew install zig`); the goreleaser `before:` hook fails fast if it's missing. Linux glibc is pinned to 2.17 for wide compat. Releases are created as **drafts** so the operator publishes manually. `-X main.version={{.Tag}}` and `-X main.commit={{.ShortCommit}}` are injected at link time. Asset name template (`joro_<version>_<os>_<arch>.tar.gz|zip`) is duplicated in `internal/update/update.go` (`runBinaryUpdate`) — keep them in sync.
**`--build-plugin` flag forwarding.** `runBuildPlugin` in `main.go` reads `runtime/debug.BuildInfo` from the running binary and forwards ABI-relevant settings to the child `go build -buildmode=plugin`: `-trimpath`, `-tags` (e.g. `netgo,osusergo`), and `CGO_ENABLED` / `GOARM64` / `GOAMD64` env. The child build must inherit these so stdlib package hashes match the host: a release host (built with `-trimpath -tags netgo,osusergo`) and a plugin built bare hash stdlib packages differently, so dlopen rejects with `plugin was built with a different version of package internal/goarch`. The build banner prints the resolved `Flags:` and `Env:` so mismatches are visible. If a plugin fails to load with `different version of package …`, check that `go version` (run from the plugin's source dir) matches the host binary's `runtime.Version()`.
**Plugin go.mod must require the SDK at the host's pseudo-version.** Under `-trimpath`, the Go compiler bakes the SDK's module version string into the position info embedded in exported declarations, which is part of the package's export hash (`go:link.pkghashbytes.github.com/BishopFox/joro/sdk`). The host's `go.mod` requires `github.com/BishopFox/joro/sdk v0.0.0-00010101000000-000000000000` (Go's canonical zero pseudo-version, auto-generated by `go mod tidy` for a local-replace dep with no real tag), so a plugin must require the same full pseudo-version — a plain `v0.0.0` produces a different export hash even with identical source and replace target, and `plugin.Open()` rejects with `different version of package github.com/BishopFox/joro/sdk`. The 5 example plugin go.mod files all use the full pseudo-version — do the same in any new plugin. (Only `-trimpath` (goreleaser) builds require the match; without it, position info uses absolute paths that happen to align, so `make build` + `--build-plugin` tolerate the mismatch.)
### In-app updater install modes
`internal/update/update.go` detects how the running binary was installed:
- **Git mode** (`.git` dir alongside executable): `git fetch` + parse upstream `main.go` version literal; update via `git pull --ff-only` + `make build`.
- **Binary mode** (no `.git`): hits `GET /repos/BishopFox/joro/releases/latest`, downloads matching archive + `checksums.txt`, verifies SHA-256, atomically replaces the running binary.
Both paths fail silently on errors (no network, rate limit, missing `git`, bad checksum) — startup is never blocked. After successful update, `update.Restart()` re-execs.
---
## Frontend Development
Source in `web/`. TypeScript/React/Vite.
```bash
cd web
npm install # install dependencies
npm run dev # Vite dev server on :5173 (use with `make dev`)
npm run build # output to web/dist/ (embedded into Go binary)
```
**npm registry:** machine may be on a private registry. If `npm install` fails, check `npm config get registry` and `npm config delete registry`.
**Icons: `lucide-react`.** All UI icons come from `lucide-react` via direct named imports (`import { Filter, X } from 'lucide-react'`) — no wrapper component, no shared icons file. Size/weight per call site with `size={N}` / `strokeWidth={N}`; `color` defaults to `currentColor`, so semantic text-color classes tint icons for free (never hardcode icon colors). **Do not reintroduce emoji or Unicode/HTML-entity glyphs (🚩 ⚙ ↩ ✕ ▶ …) as icons** — use a Lucide component. Two intentional exceptions stay hand-drawn (no Lucide equivalent): the Jorōgumo spider mascot (`App.tsx`, Dead Drop nav) and the Windows/Tux/Apple OS brand marks in `NetworkGraph.tsx`'s `osIcon`. Icons rendered inside the graph's SVG coordinate space (`NetworkGraph.tsx` `osIcon`/`serverIcon`) use a nested `<svg x= y= width= height=>` viewport rather than a bare Lucide element. Typographic separators (`&bull;`, `&middot;`) are text, not icons — leave them.
---
## API Reference
All under `/api/v1/`. Request/response shapes are JSON unless noted. WebSocket events stream from `/ws`.
**History & intercept**
- `GET/DELETE /requests`, `GET /requests/:id` — paginated history with filters; raw bytes base64. Filters: `host`, `method`, `status`, `search` (URL substring), `exclude`+`extMode` (file extensions), `contentType`, `scope_only`, and `content`+`contentMode` (`include`/`exclude`) +`contentRegex` (`true`) — matches a string (case-insensitive) or regex against the **raw request + response bytes**.
- `method` — comma-separated, case-insensitive, OR'd: `method=GET,POST`.
- `status` — comma-separated expression OR'ing classes (`4xx`), exact codes (`403`), inclusive ranges (`500-599`), and `none` (no response captured, `StatusCode == 0`): `status=4xx,5xx,403,500-599`. A bare `status=200` still works. Unparsable tokens are skipped, and an expression with no parsable token means no filter (so a half-typed value degrades rather than blanking the table). `status=0` means `none`, **not** "any". Parser: `parseStatusFilter` in `internal/proxy/statusfilter.go`.
- Live-streamed rows honor `method`, `status`, and `exclude`+`extMode` client-side (`requestStore.addItem`/`addItems` → `matchesLiveFilter`). `host`, `search`, `contentType`, `content*`, and `scope_only` are server-side only (raw bytes and scope rules aren't in the WS summary), so those bypass live rows until reload.
- `GET /intercept` — `{enabled, responsesEnabled, items[]}`; each item is `{id, kind: request|response, method, url, host, protocol, status?, pausedAt, reqRaw, respRaw?}` (raw bytes base64; `reqRaw` is set for **both** kinds, `respRaw` only for responses)
- `PUT /intercept/enabled` — `{enabled}` (requests); `PUT /intercept/responses` — `{enabled}` (responses, independent phase)
- `POST /intercept/:id/{forward,drop}` — forward accepts modified `reqRaw` **and/or** `respRaw` base64; both routes serve either kind (`Resolve` is phase-agnostic). A 404 means the pause already timed out or was released.
- `POST /intercept/release` — `{kind?: request|response}` (omit for both) → `{released}`; forwards every pending pause unmodified. Forward-only by design: a bulk drop is destructive with no undo.
**Manipulate**
- `POST /manipulate/send` — raw HTTP `{raw b64, scheme, host}`
- `POST /manipulate/ws/connect` — `{raw b64, scheme: ws|wss, host}` → `{sessionId, status, rawResp, error}` (always 200; sessionId empty on failure)
- `POST /manipulate/ws/{id}/send` — `{opcode: text|binary|ping|pong|close, payload b64}`
- `POST /manipulate/ws/{id}/disconnect`
**Shells**
- `POST /generate` — `{format: php|asp|aspx|ashx|jsp|cfm}`
- `POST /execute` — `{target, webshell, authKey, command}`
**Fuzzer**
- `POST /fuzzer/start` — `{raw, scheme, host, wordlist[], wordlists?, attackMode?, threads, rateLimit, followRedirects, updateContentLength?, matchers, filters}`
- `POST /fuzzer/{id}/stop`, `GET /fuzzer/campaigns`, `GET /fuzzer/campaigns/{id}` (paginated results), `DELETE /fuzzer/campaigns/{id}`
- `POST /fuzzer/wordlist` — multipart upload → `{lines[], count}`
**Detect (passive vulnerability detection)**
- `GET /detect` — `{enabled, config, summary{total,bySeverity,byCategory,falsePositives,skippedEncoded,skippedBinary,scanned}, scan{…}, cursor, ruleCount, activeRules}`
- `PUT /detect/enabled` — `{enabled}`; `GET/PUT /detect/config` (PUT takes a **partial** patch; pointer fields, so any subset)
- `GET /detect/findings` — `?severity=&minSeverity=&category=&ruleId=&host=&search=&confidence=&fp=true|false|all&includeDisabled=&sort=severity|lastSeen|firstSeen|count|host|rule&dir=&offset=&limit=` → `{items,total,offset,limit}` (`limit=0` = all).
**`severity` overrides `minSeverity`** rather than combining with it, and the endpoint applies no default of its own. `minSeverity` is a supported param but the UI never sends it: the findings view carries an explicit `severity` CSV (the set of visible bands, omitted entirely to mean "all"), so the two can't interact.
- `GET /detect/findings/{id}` — `{finding, notes, occurrences[{…, requestPresent}], rule}`; `PUT /detect/findings/{id}` — `{falsePositive?, notes?, severity?}`
- `DELETE /detect/findings/{id}`, `DELETE /detect/findings?fp=true` (only false-positive-marked)
- `GET /detect/rules` — `?category=&severity=&builtin=&enabled=&search=` → `{rules[] (each with findingCount), builtinCount, userCount, activeCount, categories, postFilters}`
- `POST /detect/rules` (201; `kind` must be `regex`), **`PUT /detect/rules/{id}`**, `DELETE /detect/rules/{id}` — both 403 on a built-in
- `PUT /detect/rules/{id}/enabled` — `{enabled}`; `PUT /detect/rules/{id}/severity` — `{severity}`; `POST /detect/rules/{id}/reset` — both allowed on built-ins (they change the operator's view, not the rule)
- `POST /detect/rules/test` — `{pattern, sample|sampleB64, captureGroup?, minEntropy?, minLength?}` → `{valid, error?, groups, matches[{match,redacted,offset,length,entropy,passes}], truncated}`
- `POST /detect/scan` — `{scope:"all"|"host", host?, purge?}` → 202 status (409 if already running); `GET /detect/scan`; `POST /detect/scan/cancel`
**Filters & rules** (each: `GET`, `PUT /enabled`, `POST` add, `DELETE /{id}`)
- `/noise` — `{pattern}`
- `/scope/rules` — `{pattern, methods, path, include}`
- `POST /scope/rules/import` — `{config: {scopeEnabled?, scopeRules[]}, mode: replace|merge}` → `{enabled, rules, imported, skipped}`. Bulk import of a scope file. **All-or-nothing**: every rule is validated before anything is applied, so one bad rule 400s the whole request naming its index and the live rule set is untouched. `include` is required per rule (`*bool` server-side) — a plain bool can't tell an omitted field from `false`, which is "exclude". `mode` must be exactly `replace` or `merge` (no defaulting); merge seeds from live rules and dedupes via `containsScopeRule`. `scopeEnabled` is optional and leaves the flag alone when absent. Caps: 1 MB body (`MaxBytesReader`), 1000 rules, `DisallowUnknownFields`. JSON only — no multipart and no gzip path, so nothing from the request ever reaches the filesystem.
- `/replace/rules` — `{target, matchType, match, replace}` (target ∈ request_header, request_body, response_header, response_body, ws_message)
- `/customdata/items` — `{type, name, value}`
**WebSocket capture**
- `GET /ws/messages?host=&offset=&limit=`, `DELETE /ws/messages`
**Settings & system**
- `GET/PUT /settings`, `GET /certs/ca.crt`, `GET /mode` (returns `{mode: proxy|listener}`)
- `POST /system/restart` — graceful re-exec
- `GET /system/healthcheck` — `{proxyPort, uiPort, bindAddr, caPresent, browserAvailable, browserName, requestCount, activeProject}` (first-run wizard poll)
- `GET /browser/status` — `{available, browser}`; `POST /browser/launch` — `{url?}` → `{status, browser, profile}`; `POST /browser/clear-cookies` → `{status, profile}` (clears only the active project's testing-browser profile cookies) (managed testing browser)
**Callbacks (listener mode)**
- `GET/PUT /callbacks/config` — `{domain, responseIp}`
- `GET/POST /callbacks/tokens`, `DELETE /callbacks/tokens/{id}` (cascade)
- `GET/DELETE /callbacks/interactions?token_id=`
**Sliver C2**
- `GET /sliver/status`, `POST /sliver/{connect,disconnect}`, `GET /sliver/sessions`
- `POST /sliver/execute` — `{sessionId, command, args}`
- `POST /sliver/command` — `{input}` → `{output, error, downloadId?, filename?}` (text command dispatcher)
- `GET /sliver/download/{id}` (60s TTL cache), `POST /sliver/upload` (multipart)
**Mythic C2**
- `GET /mythic/status`, `POST /mythic/{connect,disconnect}` — connect `{url, username?, password?, apiToken?}` (JWT via `POST /auth`, or `apitoken` header)
- `GET /mythic/callbacks` — active callbacks (agent instances)
- `POST /mythic/command` — `{input}` → `{output, error, downloadId?, filename?, callbackChanged?, callbackId?, callbackName?, disconnected?}` (REPL: `callbacks`/`use <id>`/`background`/`tasks`/`download`/`help`, else issues the line as a task to the active callback)
- `GET /mythic/download/{id}` (60s TTL cache), `POST /mythic/upload` (multipart)
**Team server** (auth: `Authorization: Bearer <token>` + `X-Joro-Nickname`)
- `GET/POST /team/chat`, `GET /team/users` (returns `[{nickname, status, project}]`), `POST /team/nickname` (`{oldNickname, newNickname}`), `POST /team/presence` (`{status, project}` — sets the caller's presence metadata; `project` is the active project config name)
- `GET /team/notes/hosts`, `GET/POST /team/notes`, `PUT /team/notes/{id}` (edit content), `DELETE /team/notes/{id}` — **PUT/DELETE are author-only** (soft ownership: 403 if `X-Joro-Nickname` ≠ note author). Local notes mirror this with `PUT /notes/{id}` (no ownership check — single operator). An **empty `host`** on create/list is the host-less "General" bucket (both team + local notes); the UI pins a **General** entry atop the Hosts list.
- `GET/POST /team/flagged`, `GET /team/flagged/{id}`, `DELETE /team/flagged/{id}` — shared flagged requests; POST body `{host, method, url, status, reqRaw b64, respRaw b64, note}` stores the artifact **and** posts a referencing chat message; list returns summaries (no raw bytes); get-one returns raw `reqRaw`/`respRaw` base64 + `truncated`
- `GET/POST /team/configs`, `GET /team/configs/{id}`, `DELETE /team/configs/{id}` — published (shared) project configs; POST `{name, project, config}` where `config` is base64(gzipped `projectConfigFile`) built by the proxy's `GET /configs/export` and `project` is the active project config name; list omits the blob; get-one returns it. The teamserver treats `config` as opaque. (Wire/JSON key is `project`; the SQLite column stays `project_id` — no migration.)
- `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; 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
- `POST /api/v1/configs/project/switch` `{name, action?: save|discard, saveScratchAs?}` — saves the outgoing project (respecting its `autoSave` pref, or `action`), then loads `name`; returns `applyProjectConfig`'s map + optional `{autoSaved}`
- `POST /api/v1/configs/project/prefs` `{name, autoSave?, saveHistory?}` — updates the sidecar only (no `.joro` decompress)
**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). 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
**Plugins**
- `GET /plugins`, `POST /plugins/upload` (multipart, 32MB max), `DELETE /plugins/{filename}` (restart required)
- `GET /plugins/{exec-providers,interact-providers,graph}`
- Per-plugin exec: `GET /plugin/{name}/status`, `POST /plugin/{name}/{connect,disconnect,command}`
- Per-plugin interact: `GET/POST /plugin/{name}/interact/instances`, `DELETE /plugin/{name}/interact/instances/{id}`, `PUT .../enabled`, `GET/DELETE /plugin/{name}/interact/interactions?instance_id=`
### WebSocket Events (over `/ws`)
```
request.captured { ...RequestSummary }
intercept.queued { id, kind: request|response, method, url, host, protocol, status, reqRaw, respRaw } (both phases; reqRaw always set)
intercept.resolved { id, action: forward|drop } (shared by both phases)
callback.interaction { ...Interaction }
ws.message { id, connectionId, timestamp, direction, opcode, payloadLength, payload, host, url, isText }
team.chat { id, author, text, refId?, createdAt }
team.note { id, host, content, author, createdAt, updatedAt } (fires on create + edit)
team.note.deleted { id }
team.flagged { id, host, method, url, status, truncated, note, author, createdAt }
team.flagged.deleted { id }
team.config { id, name, projectId, author, createdAt }
team.config.deleted { id }
team.collab.request { id, requestor, projectId, note, status, createdAt }
team.collab.accepted { id, acceptedBy }
team.presence { users: [{ nickname, status, projectId }] } (status online|away|dnd; appear-offline omitted; projectId "" unless shared)
team.nickname_changed { oldNickname, newNickname }
team.relay { state: connecting|connected|disconnected|idle, error, httpStatus } (proxy→teamserver relay health; deduped by state, pushed to each client on connect)
fuzzer.started { campaignId, total }
fuzzer.result { campaignId, result: { index, payload, payloads?, statusCode, size, words, lines, durationMs, url } }
fuzzer.complete { campaignId, status, completed, errors }
detect.finding { finding: FindingSummary, isNew } (sent NON-BLOCKING, so droppable; suppressed during a rescan)
detect.summary { total, bySeverity, byCategory, falsePositives, skippedEncoded, skippedBinary, scanned }
detect.scan.started { jobId, kind, total }
detect.scan.progress { jobId, scanned, total, findingsNew } (throttled ~2/s)
detect.scan.complete { jobId, status, scanned, findingsNew, purged, durationMs }
detect.rules.changed { builtinCount, userCount, activeCount }
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 } (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? }
```
---
## Go Dependencies
| Module | Purpose |
|--------|---------|
| `github.com/hashicorp/go-uuid` | UUIDs for shell auth keys |
| `github.com/gorilla/websocket` | WebSocket server |
| `github.com/miekg/dns` | DNS server (callback listener) |
| `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`, ... |
Tracked via `go.mod` / `go.sum` only — repo does **not** vendor (see "no vendor/" decision below). Add deps with `go get <module>` then `go mod tidy`. Commit `go.mod` + `go.sum` together. Do not hand-edit them.
---
## Key Design Decisions
- **No CLI mode.** All features through web UI. Don't add CLI flags for shell gen/exec.
- **No global variables.** Functions take parameters; globals removed in v0.5.0.
- **No `os.Exit` in packages.** Only `main.go` exits. Internal packages return errors.
- **Intercept uses per-request channels.** `InterceptQueue.Pause()` blocks the proxy goroutine until `Resolve()` or timeout (default 60s). Don't change to polling. The **response phase** (`PauseResponse`) reuses the same channel mechanism, the same map, and the same key — the request id — because the two phases of one transaction are strictly sequential in the same goroutine: `Pause` returns and its deferred delete runs before `RoundTrip`, which precedes the response pause. So the key is never occupied twice and `Resolve(id, …)` needs no phase argument; each pause site reads only the decision field it cares about (`ReqData` / `RespData`). `PendingIntercept.Kind` is the discriminator. The `enabled`/`respEnabled` flags are deliberately **`atomic.Bool` outside `q.mu`**: they're read on every proxied request, and disabling a phase must both store the flag *and* drain that phase's pending pauses — with a mutex-guarded flag that pairing is a deadlock waiting to be written, since `sync.RWMutex` isn't reentrant. Disabling **either** toggle calls `DrainAll(kind)`, which auto-forwards pending pauses: a paused item holds an open client connection, so without the drain those goroutines block to timeout with no queue row left to resolve. `pause` also re-checks the flag *after* registering, closing the window where a toggle flips between the caller's check and registration.
- **Response interception buffers, and is off by default.** `IsResponseEnabled()` gates a pause placed **after** `applyResponseReplace` and **before** the write in all three paths (`mitm.go`, `handler.go`, `h2_mitm.go`) — Match & Replace is an automated transform, intercept is the human's last look, so the operator sees and can override the exact bytes that will hit the browser. It forces the buffered path (the streaming fast paths are bypassed), which is why `readAndCaptureResponse` now returns **nil** for bodies over `maxCaptureBody` with `resp.Body` restored intact via `multiReadCloser` (buffered prefix + unread remainder) — the caller then streams through without pausing. That also **fixes a pre-existing desync bug**: the old code truncated the body but restored `resp.Body` from the truncated bytes while leaving `Content-Length` at the full value, so `resp.Write` advertised N bytes and sent 10 MB, desyncing the keep-alive connection so the *next* request on it was misparsed. It needed response M&R rules *and* a >10 MB body to fire; response interception would have made the buffered path universal. Note that response **M&R rules still apply** to oversized bodies — `applyResponseReplace` does an unbounded `io.ReadAll` *before* the size check, so the rules see the whole body and the (possibly rewritten) bytes now reach the client complete. Only the intercept *pause* is skipped over the cap. That unbounded read means the "10 MB per in-flight response" memory bound holds for the intercept-only path, not when response M&R rules are active (pre-existing). `responseIsPausable` additionally skips `text/event-stream`, `multipart/x-mixed-replace`, `application/grpc*`, any `1xx`, and `204`/`304` (indefinite streams would never finish buffering; bodiless statuses have nothing to edit). `maxPendingResponses` (32) is a runaway backstop for automated traffic, not a workflow limit. Operator edits go through `adoptEditedResponse`: `UpdateContentLength` (shared with Manipulate and the fuzzer — it parses no start line, so it serves responses too) corrects the header **before** `http.ReadResponse`, then the body is read eagerly so any framing the edit implies but doesn't satisfy fails **pre-write** and falls back to the original, rather than dying mid-write and desyncing the keep-alive connection. A **response drop happens after the upstream round trip** — the server already processed the request; it replies `403 response dropped by intercept`. In `h2_mitm.go` both M&R branches only *compute* status/headers/body and there is exactly **one** write site at the bottom of `h2Stream`: a write above the drop branch would forward the real response while `http.Error` logged a superfluous-WriteHeader warning — a drop that silently doesn't drop. Both toggles are **in-memory only** (mirrored into `Settings` for `GET /settings`), so they reset to off on restart and are not in the project file. Plugin `RunResponseHook` still runs *after* the write, so with response intercept on the forwarded bytes and the History copy can differ if a plugin rewrites `rawResp`.
- **CA cert reused across restarts.** `cert.LoadOrCreate()` only regenerates when missing.
- **Managed testing browser (`internal/browser`, proxy mode only).** `POST /browser/launch` starts an installed Chromium-family browser (`browser.Find()` precedence Chrome→Chromium→Edge→Brave; macOS app-bundle paths, Linux `exec.LookPath`+absolute fallbacks, Windows `%ProgramFiles%`/`%LocalAppData%`; no registry lookup, no new deps) with `--proxy-server=<bind>:<proxyPort>`, `--proxy-bypass-list=<-loopback>`, `--user-data-dir=~/.joro/browser-profiles/<activeProjectConfig|default>/`, and `--ignore-certificate-errors-spki-list=<fp>` where `fp = base64(sha256(ca.Cert.RawSubjectPublicKeyInfo))` (`browser.SPKIFingerprint`). The SPKI flag is only honored when `--user-data-dir` is also set. Profile dir is keyed on the active project (`sanitizeProfileKey`) so cookies never cross projects. Named-project launches are `Start()`+`Process.Release()` (detached, persistent); with **no project loaded** the profile is `default` and **ephemeral** — `LaunchOptions.WipeOnExit` makes `Launch` keep the `*Cmd`, `cmd.Wait()` for the browser to exit, then `os.RemoveAll` the profile, guarded by `profileInUse` (skips the wipe if a Chromium singleton lock — `SingletonLock`/`lockfile` — is still present, so a second concurrent launch can't delete a live session). `--test-type` suppresses Chrome's unsupported-command-line-flag infobar (the SPKI switch is on Chrome's bad-flags list); it does not alter cert validation. The presented TLS chain includes the CA cert (`cert.Certificate = append(..., ca.Cert.Raw)` in `leaf.go`) so the CA's SPKI is actually in the chain the browser sees — without it the pin matches nothing and every site errors. The optional landing URL is the last arg (Chrome reads the first non-switch arg as a URL) and persists in localStorage (`joro-testbrowser-url`, `web/src/lib/browserPrefs.ts`), shared by the Settings **Testing Browser** card and the health-check wizard. `POST /browser/clear-cookies` → `browser.ClearCookies` deletes the cookie DBs (`Default/Network/Cookies*`, legacy `Default/Cookies*`) from the active project's profile only — other site data is kept; the browser must be closed for it to take effect (Chrome holds the DB open and rewrites on exit). `browserProfile()` (`handlers_browser.go`) is the shared key/dir helper for launch + clear-cookies. (No CLI way to force Chrome's UI theme dark on macOS, so no color-scheme control.) No OS trust-store install — traffic routes through the proxy so M&R/Custom Data/scope/intercept/SOCKS-upstream apply unchanged; **do not add a browser-level SOCKS/direct-connect flag** (would bypass the proxy). `browser.Find()` returns false on hosts with none installed → `/browser/launch` 400s cleanly. Windows main binary supports this (unlike plugins). `GET /system/healthcheck` (proxy `Store.Count()` + `browser.Find()`) backs the first-run wizard (`web/src/components/HealthCheck.tsx`, embedded in `Setup.tsx`'s local flow and a Settings CA-card modal).
- **`web/dist/` embedded** via `//go:embed dist`. Populated by `npm run build` before Go compiles — `make build` runs the frontend first, and the goreleaser `before:` hook does the same. Bare `go build ./...` requires `npm run build` to have run.
- **Noise filter is separate from scope.** Silently tunnels common browser background traffic (captive portal, telemetry, OCSP, safe browsing) without capture. Enabled by default. Checked **before** scope — noisy hosts never MITM'd regardless of scope rules. Patterns are `filepath.Match` globs against the **whole** host (`*` spans dots, so `*.mozilla.org` matches `a.b.mozilla.org` but not the bare apex); an invalid glob silently never matches. Because a noise match leaves **no trace anywhere** — no History row, no WS event, no store write, so not even a rescan can recover it — default patterns are kept narrow enough that they cannot swallow a target. `proxy.OHTTPNoisePattern` (`mozilla-ohttp*.fastly-edge.com`) covers Firefox's Oblivious HTTP relays: Mozilla hosts them **on Fastly**, so the `*.mozilla.*` / `*.mozgcp.net` entries miss them, and their bodies are HPKE-encrypted to the *gateway* — the relay cannot read them and neither can a MITM, since there is no key to intercept.
- **Non-text response bodies render as a hex dump, and OHTTP additionally decodes its cleartext envelope.** `createResponseBlobUrl` (`web/src/lib/responseRenderer.ts`) is the single dispatch point for the Render tab, shared by History, Manipulate (HTTP), and Fuzz — fix it once, all three follow. Its image/pdf/audio/video/html allowlist previously fell through to an HTML-escaped `<pre>`, so every binary body rendered as latin-1 mojibake. A branch before that fallback converts the body with `latin1ToBytes` and, when the media type is OHTTP **or** `looksBinary` fires, emits an offset/ASCII hex dump (`web/src/lib/bytes.ts`, capped at 64 KB, non-wrapping `<pre>` so columns align) under an optional summary table. Text bodies keep their exact previous path. `web/src/lib/ohttp.ts` parses the RFC 9458 framing only — **no crypto, no new dependency**: the request header (key ID + KEM/KDF/AEAD, named from the RFC 9180 registries) and, for `application/ohttp-keys`, the whole cleartext key config. An OHTTP *response* has no cleartext header (nonce length depends on the request's AEAD), so it reports sizes and names the possibilities rather than inventing a split. Every parser returns `null` on a length or registry mismatch and falls back to the bare hex dump — **never show a decoded field that might be wrong**. Deliberately **no OHTTP edit affordance**: the ciphertext is AEAD-authenticated to a key Joro does not hold, so any edit fails at the gateway; byte-level editing already exists via the Transform scratchpad and the Manipulate→WS hex path. The History **Raw** tab stays byte-exact. (`message/bhttp`, the cleartext inner format, is not decoded — a reasonable follow-up if a gateway-side engagement ever needs it.)
- **Two-level scope filtering.** L1 (CONNECT): host pattern only — out-of-scope hosts tunneled raw without MITM. L2 (request): host + method + path after TLS termination — out-of-scope requests forwarded without capture/intercept. Disabled by default; enabled with no rules blocks everything (safe default). Exclude rules override include rules.
- **`proxy.ValidateScopeRule` is the one validator, applied at the add and import entry points** (`handleAddScopeRule`, `handleImportScopeRules`) and used to skip-and-continue in `handleApplySharedConfig` — the detect-rule precedent in the same handler. It normalizes (trim, uppercase methods, drop blanks) and rejects what `matchRule` would accept and then never match: `filepath.Match` reports `ErrBadPattern` as "no match" (`scope.go` `matchRule`), so a malformed glob used to be stored and silently match nothing, and a **host pattern containing `/`** — a pasted `https://host/path` — is a *valid* glob that can never match, since a hostname holds no `/` and `*` doesn't cross one. Also caps pattern/path length and method count/charset. Deliberately **not** applied in `applyProjectConfig`: a project file is locally produced, and dropping rules on load would silently mutate saved operator state.
- `liveStateSignature` folds scope in as a **content hash** (`scopeSignature`, FNV-64a over `pattern|path|include|methods`) rather than a count, since a replace-mode import that swaps N rules for N different ones would otherwise read as clean and never autosave. `rp`/`cd`/`no` are still counts and retain that latent gap.
- **History/Site Map method + status filters are multi-value, and shared between the two pages.** `web/src/lib/requestFilters.ts` is the single source for `HTTP_METHOD_OPTIONS`, `STATUS_CLASS_OPTIONS`, `buildStatusExpr` (chips + codes box → the `status` query param), `parseStatusExpr`/`matchesStatus` (a **hand-maintained mirror of `parseStatusFilter` in `internal/proxy/statusfilter.go` — keep in sync**, including its lenient skip-bad-tokens semantics: a strict mirror would blank the table mid-typing while the server still returns rows), and `matchesLiveFilter`, the client-side predicate `requestStore.addItem`/`addItems` apply to live `request.captured` rows. `matchesLiveFilter` covers **only** method/status/extension — the WS summary has no raw bytes or scope context, and mixing enforcement levels would make the row count disagree with the server's `total`. UI: `MultiSelectDropdown.tsx` (method popover) + `StatusFilter.tsx` (class chips + debounced codes box) are used by both History's filter bar and `SitemapFilterModal`; the store's `RequestFilter` and `SitemapFilter` both name the fields `methods`/`statusClasses`/`statusCodes` so the controls' patch objects drop straight into either setter. Method/status are deliberately **not** persisted to `localStorage` (unlike `exclude`/`content*`) — they're per-investigation, and History has no "filters active" badge, so a silently restored `4xx` would read as "the proxy stopped capturing". `MultiSelectDropdown`'s panel is `position: fixed` (to escape the filter bar and the site-map modal card) but **not** portaled, so click-outside is one `contains` check and a panel click inside the modal can't reach the backdrop's dismiss handler; it also `stopPropagation()`s ArrowUp/ArrowDown, since History installs a document-level row-navigation listener that only bails on `tagName === 'INPUT'`.
- **Listener mode is mutually exclusive with proxy mode.** `--listener` starts DNS + HTTP callback servers + reduced API/UI. No CA, proxy, or intercept. Data in `~/.joro/callbacks.db`.
- **Token entropy:** 12 hex chars = 48 bits. Correlated by leftmost subdomain label.
- **Callback listeners are capture-only and pure-stdlib.** DNS uses `miekg/dns`; HTTP/SMTP/FTP/LDAP use only `net`/`bufio`/`crypto/tls` (no third-party protocol libs — supply-chain risk). `internal/callback/{ftp,ldap}.go` clone the `SMTPServer` shape (struct + `Start(ctx)` + `acceptLoop` + per-conn goroutine + optional implicit-TLS via shared `*tls.Config`). **FTP** is a fake server that captures USER/PASS + path args and refuses the data channel (`PASV`/`PORT` → `502`); it never opens a second socket or completes a transfer. **LDAP** hand-rolls a minimal BER TLV reader (`readTLV`/`readRawMessage` in `ldap.go` — *not* `encoding/asn1`, which is too DER-strict) to pull the bind DN / search baseObject (where JNDI/Log4Shell payloads land), then replies with canned success `BindResponse`/`SearchResultDone` echoing the messageID. Both `handleConnection`s open with `defer recover()` (untrusted network input) and cap message/line sizes before allocating. New protocols reuse existing `Interaction` columns (`Type`/`SourceIP`/`RawRequest`/`Headers`) — **no schema change**; the frontend `Callbacks.tsx` already renders `ftp`/`ldap` badges + a generic detail view, so **no frontend change**.
- **Correlation helpers (`internal/callback/token.go`).** `Correlate` (DNS subdomain label), `CorrelateSMTP` (email local-part then subdomain), and `CorrelateAny(store, candidates...)` — scans arbitrary captured strings for `[0-9a-fA-F]{16,}` runs and looks up the first 16 chars via `FindTokenByHex`. FTP/LDAP feed their captured fields (+ transcript/hex fallback) into `CorrelateAny`. **Limitation (interactsh parity):** a token present only in the *hostname* (resolved by DNS) and not in the LDAP/FTP payload can't be correlated from the connection itself — but the DNS lookup already records it under the `dns` type.
- **Privileged ports need root/capabilities on Linux.** DNS `:53`, SMTP `:25`, HTTP `:80`, HTTPS `:443`, SMTPS `:465`, FTP `:21`, LDAP `:389`, FTPS `:990`, LDAPS `:636` are all <1024. `setcap cap_net_bind_service=+ep ./joro` or iptables redirect; or use the `--{dns,http,https,smtp,smtps,ftp,ftps,ldap,ldaps}-port` flags to pick unprivileged ports.
- **`internal/event` package** holds shared `WSEvent` to avoid proxy↔callback import cycle.
- **Upstream TLS is maximally permissive (`internal/proxy/tlsconfig.go`).** All connections to target servers use `newUpstreamTLSConfig()`: `InsecureSkipVerify` (we MITM, never validate), `MinVersion: TLS 1.0`, and an explicit `CipherSuites` list of every suite Go implements — `tls.CipherSuites()` **plus** `tls.InsecureCipherSuites()`. The explicit list is required because Go 1.22+ omits the static-RSA key-exchange suites (`TLS_RSA_WITH_AES_*`) from the default ClientHello: without them, legacy servers that only accept those suites fail the handshake with `remote error: tls: handshake failure` (at handshake, *before* cert verification, so `InsecureSkipVerify` doesn't help). Listing every suite matches curl/OpenSSL reach. **Caveat:** Go's `crypto/tls` implements no finite-field DHE (`TLS_DHE_*`) suites, so a DHE-only server stays unreachable. Used by `transport.go`, `client.go`, `sender.go` (H1 + H2), `ws_relay.go`, `ws_manipulate.go` — add new upstream dials through this helper too.
- **Match & Replace operates on raw bytes.** Splits raw dump at `\r\n\r\n`, applies header/body rules independently, then reparses. Cumulative in order. Supports `string` and `regex`. Targets: `request_header`, `request_body`, `response_header`, `response_body`, `ws_message`. **HTTP/1.1 and HTTP/2 have separate apply functions — keep them in sync.** The H1 path uses `applyRequestReplace`/`applyResponseReplace` (`internal/proxy/replace.go`); the H2 MITM path uses `applyRequestReplaceRaw`/`applyResponseReplaceRaw` (`internal/proxy/h2_mitm.go`) because h2 has no textual wire format and works on synthesized raw bytes. The two paths mirror each other and must stay in sync — e.g. `stripBlankHeaderLines` (collapses blank lines from an empty replacement and drops colon-less orphan lines left by a name-only match) wraps header-rule output in *both* `applyRequestReplace` and `applyRequestReplaceRaw`. The H2 *response* path reparses headers via `parseHeaderBlock` (a header map), so blank/orphan lines vanish there without the helper.
- **WebSocket MITM uses custom frame reader/writer** on raw `net.Conn` (not gorilla). Detected via `Upgrade: websocket`. After 101, two goroutines relay bidirectionally. Control frames forwarded immediately; data frames accumulated until FIN, match/replace applied on complete messages, forwarded as single frame. 16MB payload limit.
- **WebSocket Manipulate is a client path, not proxy interception.** `internal/proxy/ws_manipulate.go` dials per-session (TCP or TLS w/ `InsecureSkipVerify`, honoring `TransportConfig.SOCKSDialContext()`), writes raw upgrade verbatim (injects `Sec-WebSocket-Key` only if missing), parses 101 with `http.ReadResponse`, reassembles continuation frames, calls `onFrame` per complete message. `Send` writes a single FIN masked frame. Sessions in-memory only, dropped on restart/error/close. Transcript streamed via `manipulate.ws.frame`/`manipulate.ws.closed` — sent frames also broadcast so multiple UI tabs stay in sync. Match & Replace intentionally NOT applied — what you type is what goes on the wire.
- **Custom Data is purely additive.** Unlike Match & Replace (needs match pattern), Custom Data appends headers, query params, or body data to in-scope requests. Applied after Match & Replace. UI in "Customize Requests" tab.
- **Fuzzer:** producer-consumer goroutine pool, 1-100 threads, rate limiting. Reuses `proxy.TransportConfig.Transport()` (SOCKS, HTTP/2, keep-alive). Results streamed via `fuzzer.result` with RAF batching client-side. Response bodies NOT stored — only metrics (status, size, words, lines, duration). Campaigns in memory (max 50, oldest completed evicted). Single (`FUZZ`) or multi-position (`FUZZ1`, `FUZZ2`, ...). Multi-position attack modes: **Spray** (same payload all positions), **Split** (parallel iteration), **Yolo** (cartesian product, max 10M). Detection regex `FUZZ(\d+)` with fallback to `FUZZ`. Replaced longest-label-first (e.g. `FUZZ10` before `FUZZ1`). Matchers (whitelist) / filters (blacklist) on status, size, words, lines, regex. Content-Length auto-update toggled by `updateContentLength`.
- **Passive detection pulls from the capture store; it is never called from the request path.** `internal/detect` runs the rule library over `proxy.CapturedRequest` records. A 250 ms cursor loop (`Scanner.Run`, started by `StartDetectLoop` from `runProxyMode`, alongside `StartAutoSaveLoop`) advances a `Store.LastSeq()` watermark via `Store.SinceSeq(seq, limit)`. **Not a hook at the six `store.Add` sites** — that would run detection on the goroutine serving the browser. Pulling also makes rescan the same code path with a different starting cursor. A `wake chan struct{}` (cap 1, non-blocking send) is the coalescing doorbell for an immediate pass.
- **`Finding.ID` IS the dedupe identity**: `hex(sha256(ruleID ‖ host ‖ groupDim))[:32]`, containing no request ID, timestamp, or seq. Rescanning reproduces byte-identical IDs, so rescan is idempotent, the frontend live-merge is a plain map upsert, and findings reload with no ID remapping. `groupDim` is **per-rule** via `Rule.GroupBy`: `host` (headers/cookies/fingerprints), `url` (panels, listings, interesting files), `evidence` (secrets/PII — one finding per distinct value, hashing the *raw* match so two secrets sharing a prefix never collide and the key never carries the secret).
- **Every finding funnels through `Store.Upsert`**, which increments `Count` only for an unseen `requestID:part:offset` (`occSeen`, capped at 512) — without that check a rescan doubles every count. Triage (`FalsePositive`, `Notes`, severity override) is preserved across upsert, and **rescan never clears first**.
- **Three check kinds, one output path.** Declarative regex rules (the only kind operators may create), named Go **post-filters** (`Rule.PostFilters` → `postfilters.go`: `luhn`, `iban97`, `ssn`, `denylist`, `notVersionString`, `notHTML`, …), and built-in **analyzers** (`Kind: "analyzer"`) for absence/relational logic a regex cannot express. Analyzers are rules whose behavior is a func looked up by ID, so they inherit toggling, severity override, and grouping. All converge on `Engine.newFinding`, so redaction and truncation cannot be bypassed.
- **Rule copy follows one convention, documented in the `rules_builtin.go` header, unenforced.** Names are **sentence case** (not Title Case) with no trailing period, shaped `<subject> <state>` — `exposed`, `disclosed`, `missing`, `present`, `enabled`, `referenced`; the generic descriptor in a console name (`manager`, `console`, `admin`) stays lowercase so the product name is the only capitalized part. A rule about one specific header is named for that header (`Strict-Transport-Security missing`); a check spanning several mechanisms is named for the concept (`Clickjacking protection missing`). Where a product name is canonically lowercase, **reword so a capitalized word leads** (`Apache htpasswd file exposed`) rather than misspelling it. **Every rule carries a distinct non-empty `Description`** — the field is `omitempty` with no server-side validation and the frontend renders it conditionally with no fallback, so an empty one silently yields a name with no subtitle. Front-load it: the rules table truncates to a single line. When adding a rule family via a helper closure, **give the helper a `desc` parameter** — `fingerprint`/`stack`/`urlFile` originally had none, which is how 88 rules shipped description-less.
- **Patterns are RE2: no lookahead, no lookbehind, no backreferences.** Negative conditions belong in a post-filter. RE2 also caps explicit repeat counts at **1000**, and Go's `(?m)$` matches only immediately before `\n` — so a class excluding `\r` can never reach `$` on a CRLF-framed header line (header patterns end at the character class, with no anchor). `Engine.applyRegexRule` trims the captured value, since a stray `\r` would land in both the evidence and the grouping hash.
- **`Rule.Literal` prescreen**: a case-folded `bytes.Contains` before the regex, which is what makes ~167 rules per response affordable. A wrong `Literal` **silently disables the rule**. `builtinRules()` is a **function**, not a package var, so nothing can alias the shipped table; **every shipped rule is enabled** (no present-but-disabled tier), so operator changes are a `disabled` set plus a `severityOverride` map, and `PUT`/`DELETE` on a built-in returns 403.
- **Info findings are hidden by default, not disabled, and severity visibility is one field.** 71 of the 167 rules are Info, so `emptyDetectFilter` (`web/src/stores/detectStore.ts`) defaults `severities` to the four non-Info bands; the rules still run and their findings are still deduplicated, counted in `summary.bySeverity`, and persisted. `DetectFilter.severities` is the **visible set**, and an **empty array means no severity filter at all** — the same meaning `MultiSelectDropdown`'s "Any"/Clear and an absent `severity` param carry. There is **no separate "show info" control** and the frontend does not use `minSeverity`. The Severity dropdown and the count-bar chips write that one field; a chip **reverses one band** rather than isolating it, so `toggleSeverityChip` materializes `SEVERITY_ORDER` before subtracting. Turning off the last visible chip lands on empty and reveals everything. The default is not persisted. While a band is hidden the chips sum higher than the table's total.
- **Evidence offsets are absolute within the rendered document, and redaction is a display control.** `Finding.EvidenceOffset` is an offset into the raw request/response the UI renders — named by `EvidencePart` — not into the buffer the rule matched against, with `-1` meaning no faithful mapping exists (a decompressed body, or a URL match). `Message.absoluteOffset` is the single translation point. `EvidenceLength` is the length of the *matched region*, which for an analyzer is not `len(Evidence)` — that field holds a synthesized description. The round trip that must hold is `respRaw[off:off+len] == match`. **A byte offset is not a CodeMirror document position**: `EditorState.create` splits the doc on `/\r\n?|\n/` and rejoins with `\n`, so every CRLF collapses to one character and an HTTP body offset is short by one per header line. `byteOffsetToPos` (`web/src/pages/Detect.tsx`) subtracts the preceding CRLFs; a lone `\r` costs nothing, since it is also a split point but becomes one `\n`. The plugin then **verifies** the translated range against the finding's own evidence and falls back to a text search when it does not match; verification is skipped for analyzers, whose evidence is a description rather than a slice. As on the server in `Store.Upsert`, `evidenceOffset`/`evidenceLength`/`evidencePart` are **one value** and `detectStore.upsertFindings` must move them together, or a host-grouped merge points a stale length at a fresh offset. `Finding.RawEvidence` stores the unmasked value for the 48 rules that redact and is persisted in the project file: **redaction guards against shoulder-surfing and screen sharing, not data at rest**. `Finding.Evidence` keeps the mask, which is what the findings table renders; the detail pane starts masked on every selection and the eye toggle reveals per finding.
- **Severity follows a fixed rubric.** Info = not exploitable, and gives away nothing beyond a surface or an identity (exposed panels, missing headers, software/version fingerprints, analytics keys, identifiers that are not credentials); Low = a real if minor weakness, or disclosure of more than an identity (an exposed config file, a verbose error or debug page, a directory listing, a disclosed filesystem path, a CORS or redirect misconfiguration); Medium = the catch-all; High = account credentials, sensitive API keys, DB connection strings, low-level PII; Critical = high-grade PII (national ID or payment card) or severe compromise alone (RCE, auth bypass, a served DB dump). **Every credential is High and none is Critical**, and a rule that merely detects a *surface* is Info. The Info/Low line runs through what the match itself gives away, which matters most for **error pages**: one leaking source paths, stack frames, or settings is Low (`java-stack-trace`, `php-error-with-path`, `laravel-whoops`), while one that only names the framework or an exception class stays Info (`java-exception-name`, `dotnet-stack-trace`, `spring-whitelabel-error`). Critical is a deliberately short list (~15 rules); the library's shape is `Info >= Critical * 3`, with fewer than half of all rules at High or above. Severity is orthogonal to confidence, and both are operator-overridable per rule and per finding.
- **Bodies are not reliably plaintext.** `transport.go` sets `DisableCompression`, so Go neither requests gzip nor decodes it, `stripHopHeaders` leaves `Content-Encoding`, and the h2 path passes `Decompress: false` — so `parse.go` decompresses via the exported `proxy.TryDecompress`. Brotli/zstd are undecodable without a new dep, so those bodies are marked unscannable and **counted** (`skippedEncoded`) rather than silently skipped. `.js`/`.css` are **not** in `SkipExtensions` — the inverse of History's default exclude list.
- **`skippedBinary`/`skippedEncoded` mean "blind spot", so anything opaque *by design* must be excluded by content type instead.** Only those two of `prepareBody`'s six skip reasons are counted by `Store.NoteSkipped`; the frontend sums them into the Detect tab's amber "N unreadable" badge. `detect.DefaultOHTTPContentTypes` (`message/ohttp-`, `application/ohttp-keys`) is therefore in `SkipContentTypes`: OHTTP ciphertext is encrypted to a gateway key the proxy never holds, so it is not a scanner gap and must not read as one. Without the entry it classifies as content-type keyword `other`, passes the gate, and dies at the `isBinary` sniff — inflating the badge on every Firefox request. The gate is a `strings.Contains` test, so the `message/ohttp-` prefix covers both `-req` and `-res`.
- **Content-type gating is a blacklist for data rules, a whitelist for format rules.** `Rule.ExcludeContentTypes` is checked before `ContentTypes` and wins outright. The seven ungated PII rules exclude `ctCSS` rather than whitelisting the formats that may carry PII, so they stay live everywhere else. **JavaScript is never excluded, and there is no `ctJS` constant**. `contentTypeKeyword` already returned `"css"`/`"js"`, so this added no classifier logic.
- **Cursor resets are load-bearing.** `Store.Clear()` zeroes `nextSeq` and `LoadItems()` rewrites it, so a stale high cursor means `SinceSeq` returns nothing and **detection silently stops for the session with no error**. Reset at all four sites: `handleClearRequests`, `resetLiveProjectState` (→ 0, via `resetDetectLiveState`), and `applyProjectConfig` (→ `store.LastSeq()`, **not** 0, so restored history isn't re-reported). `SetMaxSize` needs nothing.
- **Findings persist in the project file** (added in schema **v5**), gated on `Config.PersistFindings` (default on), capped at 2,000 highest-severity-first with all triaged findings always kept: false-positive marks and notes cannot be recomputed, and "recompute from `RequestHistory`" fails whenever `saveHistory` is off. `liveStateSignature` includes the findings store's `atomic.Uint64` **revision counter**, since an FP toggle or note edit changes no count. Custom detect **rules** join `sharedConfigPayload`; **findings do not** (per-operator triage, and evidence can reference live secrets).
- **Proxy-mode API enforces same-origin requests** (`internal/api/originguard.go`). `originGuard` rejects state-changing requests (and the `/ws` upgrade) unless `Sec-Fetch-Site` is `same-origin`/`none` and any `Origin` matches the host, plus a loopback/`--bind` `Host` allowlist. No `Access-Control-Allow-Origin` header is set (the SPA and plugin iframes are same-origin; the proxy→teamserver path is a non-browser Go client). Non-browser local tooling (no `Sec-Fetch-Site`/`Origin`) is allowed. The Host whitelist can be extended with `--allowed-host` (comma-separated or repeatable, hostname-only comparison) for setups that reach the loopback UI under a non-loopback Host, such as an SSH tunnel entry address. This only relaxes the Host check; the same-origin CSRF check is untouched. Listener/team-server mode uses `team.AuthMiddleware`'s bearer token instead (no origin guard).
- **Sliver C2 uses custom protowire encoding** to avoid the massive Sliver dep tree. `internal/sliver/`: `wire.go` (hand-encoded proto), `client.go` (gRPC), `commands.go` (text command dispatcher). Binary downloads/screenshots cached server-side, 60s TTL. `POST /sliver/command` is the main interface — `{input}` → `{output, error, downloadId?, filename?}`. Active session tracked in `Client.activeSessionID`.
- **Mythic C2 is a built-in exec provider mirroring Sliver, over plain HTTP + GraphQL (no new deps).** Third sub-tab on the Execute page alongside Web Shell + Sliver. `internal/mythic/`: `config.go` (`Config{URL, Username, Password, APIToken}`), `client.go` (`*http.Client` + GraphQL helper; auth = `POST /auth` → JWT **or** `apitoken` header, both supported; `InsecureSkipVerify` for Mythic's self-signed nginx cert — same stance as the proxy's upstream dials), `subscription.go` (Hasura graphql-ws live-callback events over `gorilla/websocket` as a *client* — reused, not a new dep — broadcast as `mythic.event`), `commands.go` (`Dispatch`/`CommandResult` REPL). Operates at the **callback** level only: lists callbacks, `use <display_id>` selects the active one, and typed lines are issued **agent-agnostically** as Mythic tasks (`createTask` mutation) with output retrieved by **polling** `WaitForTaskOutput`. `help` queries the callback's real `loadedcommands` (agent-specific), not static text. Installing agents / generating payloads are **out of scope** (server-side `mythic-cli` operations). Stateless like Sliver — connection details live only in `Client` memory, wiped on disconnect; proxy-mode only (nil in listener/teamserver). **GraphQL field names + file-transfer URLs target Mythic 3.3+ and may need reconciliation against other versions** (the connect UI shows a `Mythic 3.3+` support note; the `fileDownloadPath` const and query strings are the places to adjust). No Mythic scripting library is vendored (same "avoid the C2's dep tree" rationale as Sliver's protowire).
- **Team Server mode (`--listener --teamserver`)** extends listener mode with auth + collaboration. 32-char hex token generated at startup, printed to console. All teamserver requests (except `GET /api/v1/mode`) require `Authorization: Bearer <token>`. Nicknames via `X-Joro-Nickname`. Teamserver is API-only (no frontend). Proxy connects via `listenerUrl` and forwards team requests with `proxyToListener()`. Team data stored in `callbacks.db`. Active users tracked via WS hub client map (conn → nickname). Proxy maintains a WS relay (`ws_relay.go`) that forwards `team.*` events to the local hub. Nickname rename via `POST /api/v1/team/nickname` atomically renames in hub map and broadcasts `team.nickname_changed`, avoiding the disconnect/reconnect a full relay restart would cause; relay's cached nickname updated via `ListenerRelay.SetNickname()`. On 409 (collision), proxy rolls back the local `teamNickname` setting and surfaces the error. **Team Chat is a persisted session log.** On join the client fetches history (`GET /team/chat`, `listChatMessages({limit:200})` reversed to chronological) and `addMessage` dedupes by id so the live WS echo doesn't double up. Connect/disconnect/rename are persisted **server-side** as `author:"*"` system messages in `team_chat` (not synthesized client-side): the hub's `SetOnConnect`/`SetOnDisconnect` callbacks + `handleTeamRename` call `APIServer.postSystemChat`, which stores the message and broadcasts `team.chat`. `team.presence` drives only the active-users sidebar.
- **Flagged requests are self-contained artifacts, not history pointers.** Request history is local to each proxy instance, so a teammate on another machine can't dereference an ID into someone else's history. A flagged request therefore carries its own raw request/response bytes into the `team_flagged_requests` table on the team server. A **single** `POST /api/v1/team/flagged` both stores the artifact and creates a referencing chat message (via `CreateMessage`'s optional `refID` → new nullable `team_chat.ref_id` column), broadcasting **both** `team.flagged` (summary) and `team.chat` (chip). This keeps every UI entry point — History context menu, Manipulate "🚩 Flag" button, and the `/flag <seq>` chat slash command — to one API call. Responses are capped at **256KB** (`maxFlaggedRespBytes` in `handlers_team.go`) with a `truncated` flag surfaced in the viewer. List returns summaries without blobs; `GET /team/flagged/{id}` returns base64 `reqRaw`/`respRaw`. The `team_chat.ref_id` column is added by an idempotent `ALTER TABLE` in `MigrateDB` (swallows "duplicate column") since `CREATE TABLE IF NOT EXISTS` can't alter a pre-existing table. Frontend: `teamFlaggedStore` (fed by `team.flagged`/`team.flagged.deleted` WS events + a `listFlagged` poll in `useDashboardPolling`), a clickable chat chip and the **Flagged Requests dashboard widget** (available only in team mode, in whichever slot the operator's layout puts it), both opening `FlaggedRequestModal` (read-only CodeMirror + `ResponseRender`, with a "Send to Manipulate" button reusing `navigate('/manipulate', {state:{rawReq}})`). All flag entry points are gated on team mode (`listenerUrl` + `teamToken` + `teamNickname`).
- **Dead Drop shares requests via a portable file, no team server.** Where Flagged Requests need a live team server, the **Dead Drop** tab lets an operator stage captured requests (History context menu → "Stage for Dead Drop", on both the row menu — fetches bytes via `api.getRequest(id)` — and the detail menu — uses `selectedDetail`), reorder them by **drag-and-drop**, annotate, and export a self-contained **`.jord`** file that any Joro instance can import and view. **Entirely frontend, no backend/API changes.** `deadDropStore` (Zustand, **in-memory** — staged list is lost on reload; the exported file is the durable artifact) holds full records (base64 `reqRaw`/`respRaw` from `RequestDetail`). `lib/deaddrop.ts` serializes a `{type:"joro-deaddrop", version, exportedAt, author, title, note, items[]}` bundle: gzip via `CompressionStream` (plain-JSON fallback when absent) on export; on import it sniffs the gzip magic bytes `0x1f 0x8b` → `DecompressionStream`, else parses plain JSON (mirrors the backend `gunzipIfNeeded`). The **author** field is operator-entered on the staging screen (prefilled from `teamNickname` if set — no nickname exists in local mode). The viewer reuses `FlaggedRequestModal`, generalized with optional `title`/`byline` props (defaulted to the flag strings so `Dashboard` is unchanged). The access point is intentionally obscure — a low-profile icon in the header (with a staged-count badge), separate from the standard tabs rather than a named tab in `nav.ts`; the `/deaddrop` route still exists.
- **Operator presence carries opt-in status + active project name.** `team.presence` is `[{nickname, status, project}]` (not bare nicknames): the hub keeps a `presenceMeta` map (nickname → `{status, project}`) and `ActiveUsersDetailed()` joins it with `clients` (default `online`), **omits appear-offline** users, and feeds both `broadcastPresence()` and `GET /team/users`; operators set **status** (online/away/dnd/appear-offline) + a **Share project name** toggle (default off) from the Active Users dashboard widget (`components/dashboard/ActiveUsersWidget.tsx`; persisted in `Settings.TeamStatus`/`ShareProjectName`), which propagate via a forwarded `POST /team/presence` → `hub.SetPresenceMeta` (rebroadcasts, **never a relay reconnect**, so the session log isn't disturbed); the proxy pushes on join + on setting change, the server keeps meta across disconnects so a relay blip doesn't wipe a shared project, and `Rename` migrates the meta. The shared value is the active project config name (from the frontend `projectStore`), gated by the toggle.
- **Presence is gated on the Team Chat widget.** An operator whose **team** dashboard layout has no `team-chat` widget announces `status: "offline"`, and `ActiveUsersDetailed()` already omits appear-offline users — so they don't appear on the roster at all. No backend change: the frontend simply sends a different status. "Displayed" means **in the active layout**, not currently mounted — the push lives in `App.tsx` and reads `layoutIncludes(layout, 'team', 'team-chat')`, so navigating to another tab doesn't flip anyone offline. The Active Users widget disables its status select and says why when this is in effect, and the Settings layout editor warns before you remove chat from the team layout.
- **Team relay connection state is surfaced to the UI, and team polls time out independently of the app.** The proxy→teamserver relay (`ListenerRelay`, `ws_relay.go`) reports transitions to the hub via `Hub.SetRelayState(state, err, httpStatus)` (states: `connecting`/`connected`/`disconnected`/`idle`), which broadcasts a **`team.relay`** WS event. Dedup lives in the hub (by `state` string) so the 1s→30s backoff loop can call freely without spamming; `run()` guards each call with a non-blocking `<-stop` check so a stale reconnect goroutine can't clobber the current one, and `Update()` sets `connecting` synchronously / calls `ClearRelayState()` (→`idle`) when stopped. On every `/ws` client connect, `ServeWS` re-broadcasts the last state (via the channel, **unconditional** — the local browser has no nickname) so a page reload mid-outage shows the truth. Frontend: `teamConnectionStore` (default `connecting`) fed by the `team.relay` case in `ws.ts` (toasts **only** on connected→disconnected); drives the App header dot color, the `NetworkGraph` widget's `connected` prop (gated on `settings.listenerUrl` so solo mode stays "connected"), and a status row in the Settings Team Server card. `req()` (`lib/api.ts`) takes an optional `timeoutMs` (`AbortController`); the listener-proxied polling GETs (chat/notes/flagged/users/callbacks/xss lists) use `TEAM_POLL_TIMEOUT` (4s) so a dead team server can't hang them for the full server-side `proxyToListener` timeout (10s; client abort cancels it via `r.Context()`) and starve the browser's ~6-connection HTTP/1.1 pool (which would delay unrelated local calls like `getSettings`). `useDashboardPolling` + Notes `fetchHosts`/`fetchNotes` also **skip** those proxied polls when state is `disconnected`. Do **not** add a global `req()` timeout — `/manipulate/send`, fuzzer, and uploads can be legitimately slow.
- **Team notes have soft ownership + in-place edit.** `PUT /api/v1/team/notes/{id}` edits content (bumps `updated_at`); both PUT and DELETE fetch the note first and 403 unless `team.NicknameFromContext` matches the note's `author` ("soft" because nickname is the only identity). The frontend (`Notes.tsx`) hides the ✎/✕ affordances on notes the current `teamNickname` doesn't own, and shows an "(edited)" marker when `updatedAt != createdAt`. Edit/delete broadcast `team.note` / `team.note.deleted`. Local notes (`internal/notes`) also expose `PUT /notes/{id}` but with **no** ownership check (single operator); their UI affordances always show.
- **The project config name is the engagement identity (no separate Project ID).** Every feature that labels an engagement (team presence, published configs, collab chips) uses the **active project config name** (`s.activeProjectConfig`). Wire/JSON key is `project`; the teamserver's `team_shared_configs`/`team_collab_requests` SQLite columns stay `project_id` (Go field `ProjectID`, JSON tag `project`) — no DB migration. Chat chips distinguish artifact kinds via `team_chat.ref_type` (`flagged`/`collab`/`config`); `CreateMessage` takes `(id, author, text, refID, refType)`. `handleLoadProjectConfig`/`handleSaveProjectConfig`/`handleSwitchProject` share `buildProjectConfig`/`applyProjectConfig`/`saveProject`/`gzipJSON`/`gunzipIfNeeded` — keep all paths on them.
- **Project browser + switcher + auto-save (Settings → Project, header dropdown).** A header `ProjectSwitcher` dropdown and the `ProjectBrowser` component (`web/src/components/ProjectBrowser.tsx`, embedded in the Settings page under the **Project** sidebar category) list/create/import/load projects, both driven by `projectStore` (Zustand). The dropdown's **Manage…** navigates to `/settings` with router state `{category:'project'}`; `Settings.tsx` reads `location.state.category` to open that sub-menu, then clears the state. `projectConfigFile` is **schema v6** (v4 added `autoSave` + `saveHistory`, both default true; v5 added the `detect*` fields, with `detectEnabled` defaulting true; v6 backfills the Mozilla-OHTTP noise pattern + detect skip content types; `normalizeProjectConfig` is the single place those version-gated defaults live). **A saved project replaces the live noise list and detect config wholesale** (`applyProjectConfig` → `SetPatterns`), and autosave then re-persists it every 30s, so a newly shipped default never reaches an existing `.joro` without a version-gated backfill — `backfillOHTTPDefaults` appends only the specific missing entries, since unioning the full default list would resurrect entries the operator deliberately deleted. Each project has a `<name>.meta.json` **sidecar** (counts + prefs) — authoritative for prefs locally, so `handleSetProjectPrefs` toggles them without decompressing the `.joro`. `saveProject(name)` (shared by manual save / switch / auto-save) snapshots via `buildProjectConfig`, omitting request history when `saveHistory` is off. `StartAutoSaveLoop` (`internal/api/autosave.go`, from `runProxyMode`) ticks every 30s and saves the active project when `autoSave` is on and `liveStateSignature()` changed. Switching (`handleSwitchProject`) auto-saves the outgoing project when its `autoSave` is on, else the UI confirms Save/Discard/Cancel (also for an unnamed scratch session). New project (`NewProjectModal` → `handleNewProject`) prompts snapshot-current vs. start-empty. `activeProjectConfig` is **not** persisted across restarts. Frontend rehydration goes through `web/src/lib/applyProject.ts` (`applyProjectResp`), which fires a `joro:project-changed` window event.
- **The Dashboard is a frame over a widget catalog, with a layout per mode.** `web/src/pages/Dashboard.tsx` renders no panels of its own: it resolves the operator's stored layout into widgets and draws the chosen preset's rows above a shared, resizable bottom bar. Pieces: `lib/dashboardWidgets.tsx` (the catalog — `WidgetId`, `WidgetDef`, `WIDGETS`, availability), `lib/dashboardPresets.tsx` (`classic`/`triptych`/`grid`/`stack` + the two default layouts), `stores/dashboardLayoutStore.ts` (persistence), `lib/useDashboardPolling.ts` (the one poll loop), `stores/dashboardDataStore.ts` (what it polls), `components/DashboardPanel.tsx` (shared chrome), and `components/dashboard/*Widget.tsx`. Adding a widget = a component + one `WIDGETS` entry; nothing in Go, the layout store, or the Settings editor changes. Changing a **default layout** is a different job and not additive: a stored layout always beats a default, so the change reaches only fresh installs unless it also ships a `LAYOUT_VERSION` bump and a migration (see Persistence).
- **Two layouts, swapped automatically.** `team` defaults to the pre-widget layout byte-for-byte (`classic`: network graph | recent interactions / flagged requests, over chat + active users); `local` defaults to `grid`: detected findings / recent interactions over **automation activity** / proxy health, with both bar slots empty so the bar and its drag handle are hidden. The network graph moved out of the local default to make room and stays one dropdown away in the layout editor. That swap reaches an operator who has already used the dashboard only through the v1→v2 migration in `dashboardLayoutStore` — the first cut of it shipped without one and applied to fresh installs alone, which made “default” mean “default for nobody who already had Joro”. Customization is **presets + a dropdown per slot** (Settings → Appearance → Dashboard layout) — deliberately **not** drag-and-drop and **no new npm dependency**.
- **The bottom bar belongs to the frame, not to a preset.** Every preset shares `barMain` + `barAside`, so the resizer is written once and survives swapping chat out of it. Height is committed to the store **once on mouseup**, not per mousemove.
- **Sanitize and resolve are separate, and only resolve drops widget ids.** `sanitize()` (the single gate for both `localStorage` and User Config data) validates the version, preset, and slot keys but **preserves unknown widget ids verbatim** — stripping them would let a downgrade silently destroy a layout built on a newer build, since the next `setSlot` persists the stripped copy. Unknown *and* unavailable ids are dropped at render instead, where the slot simply collapses and flex siblings take the space (which is how the old dashboard behaved when it hid the graph in listener mode). **The reconciled result is never written back** — same rule `hiddenTabs` follows, so a team widget survives a trip through local mode.
- **Availability is two declarative flags, not a predicate.** `requiresProxyMode` (network graph, detect findings, proxy health, automation activity — their endpoints don't exist in listener mode) and `requiresTeam` (flagged requests). Chat and active users are **not** `requiresTeam`: they degrade to a local scratchpad and a list of one. Flags rather than a closure so the Settings editor can say *why* a widget is unavailable.
- **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): **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 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, 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.
- **The scope guard fails closed, because `Scope.InScope` is a filter and not a control.** It returns **true when scope is disabled** and false when enabled with zero rules, so calling it alone would let an agent reach any host in the state a fresh install is in. The rule is `(!requireScope OR (enabled AND ruleCount>0 AND inScope)) AND (hostAllow empty OR matches)`. The host whitelist **ANDs**; a whitelist that could stand in for a disabled scope would reintroduce the same hole through a different field. `requireScope` defaults on and every opt-out call is recorded. The guard tests **both the port-stripped host and the raw `host:port`**, reconciling a real inconsistency in the proxy itself — `mitm.go` checks scope against a bare hostname while `handler.go:125` passes `r.Host`, which carries the port; on 80/443 they agree, on a non-default port an operator's rule matches one and not the other. Redirect following is **not exposed** on send capabilities: a 302 to another host is a one-line scope bypass, so the agent follows it with a second, separately guarded call.
- **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.
- **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 at startup rather than a deferred check**: `validateProfiles` runs from `Build` before any listener binds, so a profile that would mint an impossible token stops the process instead of shipping. `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.
- **`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.
- **Theming uses CSS custom properties + `data-theme` attribute.** See Theme Architecture below.
---
## Theme Architecture
UI ships **Bishop Fox** theme (BF brand palette, id `bishop-fox`) as default, alongside named alternates. Colors are CSS custom properties on `[data-theme="..."]` selectors, mapped to semantic Tailwind classes via `tailwind.config.js`.
Brand palette uses 16 colors — White, Black, Red `#FA4844`, Magenta `#BF1363`, Crimson `#E40505`, Coral `#EF5B5B`, Orange `#FF7F11`, Amber `#FFBA49`, Lime `#D7E300`, Teal `#00A49E`, plus 6 grays/browns from light gray to near-black.
### CSS Variable → Tailwind Class Mapping
| CSS Variable | Tailwind Class | Usage |
|---|---|---|
| `--color-surface-body` | `bg-surface-body` | Page background |
| `--color-surface-card` | `bg-surface-card` | Cards, panels, header |
| `--color-surface-input` | `bg-surface-input` | Inputs, elevated surfaces |
| `--color-surface-hover` | `bg-surface-hover` | Hover backgrounds |
| `--color-surface-terminal` | `bg-surface-terminal` | Terminal background |
| `--color-border`, `--color-border-subtle` | `border-border`, `border-border-subtle` | Borders, row separators |
| `--color-content-{primary,secondary,muted}` | `text-content-{primary,secondary,muted}` | Text |
| `--color-accent` (+`-hover`) | `text-accent`, `bg-accent` | Primary — red (title, selected tabs, toggles) |
| `--color-accent-secondary` (+`-hover`) | `text-accent-secondary`, `bg-accent-secondary` | Secondary — teal (action buttons, links) |
| `--color-accent-tertiary` (+`-hover`) | `text-accent-tertiary`, `bg-accent-tertiary` | Tertiary — lime (forward/generate actions) |
| `--color-semantic-success` | `text-semantic-success` | Lime |
| `--color-semantic-error` (+`-bg`, `-hover`) | `text-semantic-error`, `bg-semantic-error-bg`, `bg-semantic-error-hover` | Red text / crimson bg / coral hover |
| `--color-semantic-info` | `text-semantic-info` | Teal |
| `--color-semantic-warning` | `text-semantic-warning` | Amber |
| `--color-semantic-special` | `text-semantic-special` | Magenta |
### How It Works
1. `web/index.html` has `data-theme="bishop-fox"` on `<html>`
2. Each `web/src/themes/<name>.css` defines variables under `[data-theme="<name>"]`
3. `web/tailwind.config.js` maps semantic classes to `var(--color-*)`
4. Components use semantic classes only — never raw Tailwind colors
To add a new theme: create `web/src/themes/<name>.css` with all `--color-*` variables under `[data-theme="<name>"]`, import in `web/src/index.css`, set `data-theme="<name>"` on `<html>` to activate.
### Important
- **No raw Tailwind colors in components.** No `bg-gray-*`, `text-red-*`, etc. in TSX. Always use semantic classes.
- **Three accent colors:** Red (`accent`) for brand/emphasis/selected, Teal (`accent-secondary`) for actions/links, Lime (`accent-tertiary`) for positive/forward.
- **`bg-accent-tertiary` and `bg-accent-secondary` buttons need `text-black`** for legibility.
- **Tailwind opacity syntax (`bg-color/80`) won't work** with CSS variable colors. Use a dedicated variable or a different palette color.
- **CodeMirror uses `oneDark`** — not yet integrated with theming.
- **Theme selector** on Settings page. Stored in `localStorage` under `joro-theme`, applied on load in `main.tsx`.