gori has three entry points over one project and one engine: gori (the TUI, for you), gori run (the headless CLI, for scripts), and gori mcp (for AI agents). This page is the scripting path.
gori run is not a thin wrapper around the TUI. It is the same Store, Repeater, and sweep engines with a terminal-free front end. Anything you capture by hand is queryable from a script, and anything a script captures shows up when you open the TUI.
gori run <subcommand> [verb] [options]
Run gori run -h for the full subcommand list, or CLI Reference for every flag.
Choosing a Project
Each project is its own SQLite database. Read subcommands resolve one in this order:
| Selector | Meaning |
|---|---|
--db=PATH |
A specific database file |
--project=NAME |
Match by short id, directory slug, display name, or unique id prefix (case-insensitive). A name that is one project's slug and another's display name, or two projects' shared display name, is refused with each candidate's slug and short id |
GORI_PROJECT=NAME |
Set in the environment, the project a script's every command reads (same matching as --project) |
gori run project switch NAME |
A standing pin, until project switch --clear |
| (none of these) | The most-recently-active project |
The last row is the one to avoid in a script: a single write to any other project (notes create --project demo) makes that project the most recently active, and every later --project-less command follows it. Set GORI_PROJECT at the top of the script instead. A GORI_PROJECT or pin that names no project is refused, never skipped, and the stderr notice says which rule chose the project (gori run: using project demo (from GORI_PROJECT) — name another with --project NAME or --db PATH).
Either selector can sit before the subcommand or anywhere after it: gori run --project demo history, gori run project --project demo scope list and gori run history --project demo read the same project.
The two selectors are alternatives, not a precedence: passing both is a usage error, not a
silent win for --db. The same pair reaches destructive verbs (history delete, history clear, project delete), and there an invisible winner decides which project gets emptied.
gori run capture differs on one point: it creates or reopens its target, where reads require a project that already exists.
Read subcommands open the store read-only and never take the capture lock, so they are safe to run against a project a live TUI is capturing into; SQLite WAL keeps both readers and the writer happy. A body: query is the exception: answering it drains the search index, which is a write.
Write subcommands use the same WAL database as the TUI and MCP and serialize through the Store
writer. They may run while the TUI is open, but a capture commit can briefly own SQLite's writer
slot. A short-lived subcommand gives its SQLite open/writer waits a one-second budget; when the
slot stays busy, the required write exits non-zero and says the project is locked by another gori,
with the workaround (retry, or read it with a read-only subcommand). A subcommand that keeps the
project open for a whole run
(discover, fuzz, import, mine with a --macro, probe, retest run, oast listen/resume, intercept, sitemap js --scan) keeps
the standard five-second wait, the same one the TUI's capture writer uses. A
repeater send that already reached the network keeps its completed-send result even when its
response or History write cannot be persisted: it prints a warning to STDERR, and --format json
carries response_saved / history_saved with the reason, so a script can tell without asking a
generic shell retry to send the request again solely because of that write failure.
gori run history --project my-engagement -q 'status:5xx'
gori run issues --db /path/to/project.db --format json
The Scripting Contract
The JSON that gori run emits is a stable, documented shape meant to be parsed, not eyeballed. Four rules make it pipe cleanly:
STDOUT is data, STDERR is diagnostics. Warnings, counts, notes, and export confirmations go to STDERR, so gori run … | jq never has to filter chatter out of its input.
--format picks the shape. Most subcommands take text (default) or json; some add jsonl, raw, har, paths, or markdown. --json is the same as --format=json everywhere json is offered. json is always one JSON document and jsonl always one object per line:
| Subcommand | --format json |
--format jsonl |
|---|---|---|
history |
One array, streamed | One object per line |
capture |
One array, closed when the capture stops (--for, --max, Ctrl-C) |
One object per flow, as it completes |
fuzz, mine, discover, authorize, cache-deception |
One JSON array (fuzz's in index order) |
One object per line, as each result lands |
sequence |
The single report | Each sample as it lands, then the report |
Reach for jsonl when you want to consume a long sweep while it runs, and json when you want one document at the end (… --format json | jq length counts the rows).
Exit codes are meaningful.
| Code | Meaning |
|---|---|
0 |
Success |
1 |
Error: a failed send, an unreadable project, a mutation that could not be applied, or a sweep (fuzz, mine, discover, sequence, authorize, cache-deception) in which no request got an answer |
3 |
A verdict gate: gori run fuzz --fail-if-no-matches completed cleanly but nothing matched (a --stop-on / --stop-after-matches that fired exits 0), or gori run probe --fail-on=LEVEL reported an issue at or above LEVEL |
130 |
Interrupted by SIGINT/SIGTERM. capture (which exits 0 when --for or --max ends it), fuzz, mine, discover, sequence, authorize, cache-deception and repeater minimize flush what they collected first, so && next-step does not treat a truncated run as a finished one |
A fuzz run where nothing matched and every send errored (target down, TLS failure, scope-blocked) exits 1, so a script can tell "no findings" apart from "never reached the target" even without --fail-if-no-matches (with the flag, 3 wins).
A closed pipe is not an error. gori run history | head -5 exits 0 and stays quiet, the way any Unix filter should.
# Every 5xx in the project, as JSON Lines, into jq
gori run history -q 'status:5xx' --limit 500 --format jsonl | jq -r '.url'
# Capture for five minutes into a named project, streaming to a file
gori run capture --project ci-run --for 5m --format jsonl > flows.jsonl
# Fail a CI job when the fuzzer finds a reflected marker
gori run fuzz 42 --wordlist payloads.txt --mr 'gori-canary' --fail-if-no-matches
# A create command's --format json is the new row, id included: no scraping prose
id=$(gori run repeater create -t https://api.example.com -f req.http --format json | jq .id)
rule=$(gori run project scope add --pattern=api.example.com --format json | jq .id)
# One request per path, no session per path, status and headers only
for p in /api/v1/items/{1..38}; do gori run send "https://api.example.com$p" --headers-only; done
# curl's flags mean what they mean to curl: -d is the body, -b a cookie
gori run send https://api.example.com/login -d 'user=a&pass=b' -b 'lang=en' --format json | jq '{status, error_kind, retryable}'
# Fail a CI job on any finding of medium or worse (exit 3)
gori run probe --fail-on medium
Staying In Scope
Every active subcommand (anything that opens a socket) routes through the same outbound gate the TUI and MCP use. A project with scope rules refuses targets outside them; --allow-unscoped is the deliberate waiver. The sandbox applies regardless; explicit excludes still hold for sweeps (fuzz, mine, discover, retest), while a hand-authored send passes an exclude the way the proxy does.
When you fuzz a raw request with --request or STDIN and pass no --project / --db, there is no scope to consult, so gori prints an explicit unscoped warning to STDERR rather than pretending it checked.
Authenticated Sweeps
Session bindings ($BIND.SESSION and friends) live in the memory of the gori process that observed them. They are never persisted, because a restored token is stale by construction. That is fine in the TUI, where one process holds both the send and the sweep that follows, but gori run is one-shot per process.
--bind-from FLOW-ID closes the gap: it replays one captured flow first, so the response fills the bindings your fuzz, mine, sequence, or discover template reads in the same process.
gori run fuzz 42 --bind-from 41 --wordlist ids.txt
See Session bindings for how extract rules define them.
Process Hooks
gori has no plugin SDK and is not getting one. When a transform has to be computed (re-sign a JWT, recompress a body, decrypt a proprietary envelope, run a real detector) you hand the bytes to a program you already own. Bytes in on stdin, replacement bytes out on stdout. That is the whole extension surface, and it is the same primitive at four seams.
| Seam | Where | What it does |
|---|---|---|
Rewriter pipe op |
Rewriter tab, gori run rewriter add --op=pipe, MCP create_rule |
The matched region goes to the command; its stdout replaces it, live in the proxy |
Decoder exec: step |
Decoder tab chain, gori run decoder, §value¦chain§ markers |
One chain step is a command instead of a converter |
Probe exec rule |
Probe rules, gori run probe rules add --exec |
The region goes to the command; exit 0 raises a finding, stdout is the evidence |
Miner --hook |
gori run mine --hook, MCP mine_start hook |
The whole assembled request goes to the command; its stdout is the request that ships; one hook per probe |
# Re-sign every JWT leaving the browser, with your own signer.
gori run rewriter add --op=pipe --match=regex --part=body \
--find='eyJ[A-Za-z0-9._-]+' --value='./resign.sh --key dev.pem'
# Decode a base64 body and run it through your own parser.
gori run decoder 'base64-decode > exec:./parse-envelope --json' "$BLOB"
# Let a real detector decide, instead of a regex.
gori run probe rules add --title 'envelope leak' --exec --pattern './detect-leak --stdin'
# Mine hidden parameters on a signed API: sign every probe before it ships.
gori run mine 42 --locations=query --hook './sign.sh'
The command is exec'd directly. There is no shell. argv is tokenized with quote and
backslash rules ('a b', "a b", a\ b) and handed to execvp as data: $FOO, *, `,
;, &&, | and > are ordinary characters in an argument, never operators. Captured bytes
that flow through a hook can therefore never be shell-interpreted. In a Decoder chain the three
step separators (>, |, ,) are consumed before the step is read, so they cannot appear
inside an exec: step's arguments.
A hook never stalls the proxy. Every run has a hard wall-clock timeout (hooks.timeout_secs
in settings.json, 5s by default, 60s ceiling) and a 32 MiB stdout cap. If the command times out,
exits non-zero, cannot be spawned, or floods stdout, the original bytes pass through
unchanged and the failure is written to the project event feed as a notice (a Probe exec
rule's non-zero exit is its "no finding" answer, not a failure; one killed by a signal still
is). A wedged hook can
never cost you a flow. In the Rewriter the timeout is a budget shared by every pipe rule and
every match in one rewrite, so a pattern matching four hundred times (or four pipe rules on one
head) still costs that rewrite one timeout. A message is rewritten twice (its head and its
body), so that is the bound it sees.
The Miner's hook runs once per probe, which is what a signed API needs. An app that requires every
parameter to carry an HMAC, a signed envelope or a per-request nonce rejects a raw candidate
before it can react to it, so without a hook there is nothing to mine; every probe looks the
same. --hook hands each assembled request (candidate injected, session bindings already
resolved) to your command, and its stdout is what ships; a hook that cannot run skips that
probe with a reason rather than sending an unsigned request that the app would reject and the
miner would then read as a clean negative. The timeout is the same hooks.timeout_secs budget,
per outbound request, and a mine's request count is bounded by --max-requests and its own
bucket/bisection/confirm tree, so the total hook cost is bounded with it. The miner is
latency-bound (it counts round-trips), so a hook adds one fork-and-wait to each of them. A hook
is for a value your command can compute; a nonce or CSRF token the server hands out is fetched
instead by a request-time macro
(--macro), which replays a saved Repeater session before each probe.
Two things hooks are deliberately not wired into. The MCP decode tool refuses an exec:
step (saved chains included); it is exposed read-only and unbound, and stays pure compute; an
agent that needs a hook configures a pipe rewriter rule or an exec probe rule, both gated
writes the operator can see. And a Probe exec rule runs once per flow on the passive
analyzer. A slow detector becomes the whole rule set's bottleneck under live capture, so keep its
command fast.
Drawing something is not running it. Every surface that replays a chain in order to draw
withholds the exec: step and marks the row held: the Rewriter's OUTPUT pane, the ^Q chain
editor's preview, and a Fuzzer result row rebuilt from the template (which says the value shown
is the payload before that step). The Repeater's Content-Length reflection cannot say it in
place, so it stops rewriting the header instead; ^R computes the real length from the rendered
bytes, and ^L recomputes it once at the moment you take the header over. Restoring is not
running either: reopening a project rebuilds every saved Repeater tab and Decoder sub-tab
without firing their commands.
Two places do run it, and both are asking for the bytes of a send. ^R obviously. Less
obviously, anything that hands you those exact bytes (the Repeater's Copy as… menu and its
Comparer slot), because a curl line that omitted the hook would not reproduce the request it
claims to be.
The Decoder tab's chain is live, including while you type it. That tab is the workbench
exec: was built for, so every edit re-runs the pipeline, and each prefix of a command you are
typing is itself a complete command (exec:rm -rf /tmp/x runs rm -rf /tmp on the way). Compose
the argv somewhere else and paste it, or point the step at a wrapper script you edit on disk.
A hook runs as you. It is not sandboxed, jailed or confined: same trust level as a
--config file or any other Rewriter rule. gori never invents a hook: every one of them is
configuration a human wrote, and each is listed plainly in the rule list it belongs to. When
another session (an agent, a second TUI) adds a pipe rule to a project you have open, this one
tells you in as many words that it is now running a local command on your behalf.
A shared profile can carry one. rewriter and scan_rules are ordinary exportable sections,
and a decoder chain named in --sections is too, so a hook travels in a
profile like any other rule, which is the point: a team standardising
on one re-signing hook is why hooks exist. Two settings outside the hook seams run a command as
well, and travel the same way: statusline.command (through /bin/sh -c, or cmd.exe /d /s /c on Windows, on a timer) and
editor.command. Both ends say so. gori settings export counts what it wrote on stderr, and
gori settings import lists every command-carrying entry with its command and refuses to write
until you pass --allow-commands. Importing someone's profile is the same trust decision as
running their script, so read the commands first. The flag is the acknowledgement, and it is
answerable in a script because there is no prompt.
What to Reach For
| Task | Subcommand |
|---|---|
| Capture traffic in CI, headless | capture |
| Query or export History (incl. HAR) | history, show |
| Send one request, no session | send |
| Replay and diff a request | repeater, compare |
| Sweep payloads or hunt hidden params | fuzz, mine |
| Crawl and brute-force endpoints | discover, sitemap |
| Test access control across identities | authorize |
| Scan and triage | probe, issues, notes |
| Pure compute, no project needed | decoder, jwt, cookie |
| Manage projects, scope, env, network, rules | project, rewriter, colormarker |
Next Steps
- CLI Reference: every subcommand and flag
- Query Language: the filter syntax
-qaccepts - MCP Server: the same project, driven by an AI agent instead of a shell