CLI reference
Global flags
Section titled “Global flags”Every subcommand accepts these, before the subcommand name:
| flag | env var | default | |
|---|---|---|---|
--config, -c <path> | MARSHAL_CONFIG | $XDG_CONFIG_HOME/bot-marshal/config.yaml | usually ~/.config/bot-marshal/config.yaml; a system service should pass an explicit path (see Production) |
--log <level> | MARSHAL_LOG | info | error, warn, info, debug, trace — the base messages’ verbosity only; see Observability |
--log-detail <level> | MARSHAL_LOG_DETAIL | access | log, access, audit — how much per-request lines carry |
--log-sink <dest> | MARSHAL_LOG_SINK | auto | auto, stdout, journald, syslog |
--log-format <fmt> | MARSHAL_LOG_FORMAT | auto | auto, pretty, json — stdout only |
marshal --config /etc/bot-marshal/marshal.yaml --log debug serveEvery subcommand checks the config file exists before doing anything else, and says so clearly — naming the exact path it looked at — rather than surfacing a bare I/O error, since the first thing most people hit is the default path they never typed.
marshal config check
Section titled “marshal config check”Loads and validates the config, prints every diagnostic, exits non-zero on any error. That
includes building every profile’s request_transforms.secrets — whose schema the validator
cannot see, since the config model carries those entries untyped — so a misspelled field in a
secret source fails here rather than at the next start. Nothing is resolved or fetched:
building a source parses its configuration, it does not read the environment variable, open
the file, or call the token endpoint. The one thing it does read is tls.upstream_ca_certs,
so a config naming a CA file that is not there now fails the check rather than the next start.
The env file is read by every subcommand, including this
one, so a syntax error in it — or a named env_file: that is missing — fails the check too. On
success it prints how many variables the file contributed and how many the environment already
had, which is the usual reason an edited .env appears to do nothing.
Warnings do not fail the check but are worth reading; serve logs them at startup and refuses
to start on an error the same way.
marshal config checkmarshal secrets oauth <subcommand>
Section titled “marshal secrets oauth <subcommand>”Enrolment and inspection for { type: oauth2 } secret sources. Only the two interactive
grants need enrolment: client_credentials, refresh_token and jwt_bearer authenticate
from configuration alone.
marshal secrets oauth login <name> [--open] [--timeout <duration>]
Section titled “marshal secrets oauth login <name> [--open] [--timeout <duration>]”Authorises a credential once, so the proxy can use it unattended from then on. <name> is the
swap’s name.
Which flow runs is decided by the swap’s grant:
authorization_code— marshal generates a PKCE verifier, binds the loopback port named byredirect_uri, and prints the authorization URL. Open it (or pass--open), authorise in the browser, and the provider redirects back to marshal’s own listener with the code. The port is bound before the URL is printed, so a port already in use fails immediately rather than after the code has been issued and spent.device_code— marshal prints a URL and a short code to enter on any other device, then polls. Nothing is bound and no browser is needed on the host, which is what makes this the one that works over SSH.
Either way, what is kept is the refresh token, written under
state_dir at mode 0600. The access token is
short-lived and re-minted on demand; it is never written down.
--timeout (default 5m) bounds the wait. For device_code the provider’s own expiry also
applies, whichever is shorter.
marshal secrets oauth login GITHUB_APP --openTwo things that commonly go wrong the first time, and what they look like:
- The provider issues no refresh token. The flow completes and marshal refuses to record
it, because nothing would survive a restart. Most providers need
offline_accessinscope; Google wantsaccess_type: offlineinextra_params. redirect_uriis not loopback. Refused before anything is opened. Marshal binds that address itself; a redirect anywhere else hands the authorization code to something that is not marshal, which is the one thing the flow exists to prevent.
marshal secrets oauth login <name> --wait / --run -- <cmd>
Section titled “marshal secrets oauth login <name> --wait / --run -- <cmd>”Bootstrap a credential whose OAuth application you do not control — a vendor’s own CLI
subscription login, where the client_id and endpoints belong to them and are not published.
Instead of driving a flow it already knows, marshal starts an intercepting proxy and learns the
credential from the token exchange the tool itself performs. No OAuth source needs to be
declared beforehand; the base config, CA and state_dir are still required: here <name> is only the storage key the result is filed under, not a reference to
a swap. For what this proxy actually matches on, and why matching on shape alone is safe here
but would not be as a standing part of serve, see
OAuth2 credentials § Bootstrap capture.
With --run, capture and reporting are two separate moments. The moment the exchange is
captured, marshal prints one short line and nothing else, then waits up to 5 seconds for the
sandboxed command to exit before doing anything more — long enough for a tool that makes a
couple more requests right after success to not suddenly find the proxy gone out from under
it, but not an indefinite wait: under --mode steal the tool’s own login is deliberately made
to look like it failed, and plenty of tools answer that with a prompt that sits until a human
presses something, which nothing here can wait out. The full report — enrolled, granted scope,
discovered configuration — always follows within that window, whether or not the command has
exited. It is also logged (credential captured, enrolled, discovered configuration written), not only printed: a full-screen TUI can own the terminal via the alternate screen
buffer, and anything printed while it does can be silently overwritten by its next redraw
regardless of timing, which --log debug survives even when the console does not.
The discovered configuration is written to a file, not just printed. On success, a named
transform bundle is written to transforms_path (default transforms/) as <name>.yaml —
token_endpoint, client_id, redirect_uri, everything but the rules host, which bootstrap
has no way to know: it learns where the token endpoint is, not which API the credential is
for. Add <name> to whichever profile’s transforms: list needs it (transforms: [<name>] if
it has none yet), fill in that one field, allow the API host in policy, then reload or restart
the proxy. Follow the bootstrap walkthrough
for the complete sequence. An existing file at that path is never overwritten; the full block is printed instead,
exactly as before this existed.
marshal secrets oauth login CLAUDE_SUBSCRIPTION --waitprints a proxy address and a CA path; export them in the terminal where you run the tool’s own login, and log in as you normally would. The browser never has to be proxied — only the tool’s own network calls, which is where the exchange happens.
marshal secrets oauth login CLAUDE_SUBSCRIPTION --run -- some-vendor-cli logindoes the same but launches the command itself, confined so its egress cannot avoid the proxy.
Everything after -- reaches the command untouched, its own flags included.
--isolation takes the same values as marshal run
and has the same prerequisites — netns is the only one that actually prevents the command
routing around the proxy.
For a login that opens a browser and waits on a loopback callback, use --isolation cgroup
or --wait. A netns callback is inside the isolated network namespace, so the host browser
cannot reach it; a browser launched inside the namespace may also lack working proxy routing.
Marshal does not forward arbitrary callback ports between namespaces.
cgroup leaves host networking available; --wait launches no sandbox. Neither prevents
bypassing the proxy, so use them for a supervised login. See the
bootstrap walkthrough
for the command and its credential-handling consequences.
--bind <path>/--bind-group <name> work exactly as they do for marshal run, for whatever
the tool’s login needs beyond the workspace and standard system paths — a package manager
cache, a config directory it reads from outside the workspace. Both are meaningless without
--run (--wait sandboxes nothing to bind into) and, unlike marshal run, there is no
profile here to already name a sandbox.bind_groups/extra_binds default — bootstrap has no
profile, so whatever you pass here is the whole list.
--mode decides what happens to the exchange:
observe (default) | forward it untouched. The tool’s own login succeeds and it keeps a working credential too; marshal simply also has one. |
steal | redeem it out of band and answer the tool with a sentinel, so it never holds a working credential — at the cost of its login reporting failure, which from its point of view is what happened. |
--host narrows the match to one hostname, for the rare session with more than one flow in
flight — see the link above for why the match is host-agnostic by default. --timeout bounds
the wait (default 5m).
Two things worth knowing:
state_dirmust be set, and is checked before anything starts — discovering it missing after you have completed a vendor login is the worst possible moment.- A provider that issues no refresh token enrols nothing, and says so. An access token alone
does not survive a restart. Most providers need
offline_accessin the requested scope, which is the tool’s request to change, not marshal’s.
If nothing gets captured, this is what to watch. The global --log-detail/--log flags
work here exactly as they do for serve: --log-detail access shows every request the session
sees, and --log debug adds why a given POST wasn’t treated as a login exchange (the wrong
grant_type, no body, filtered out by --host) rather than leaving you to guess. --audit-log <path> is also available, appending the full structured JSON record for every request the
same way serve --audit-log does — like every audit record it never carries a body or a
captured secret’s value, since the redactor already knows any credential this session captures
before logging anything about that request (ADR-0029), and it’s meaningless without
--wait/--run since there’s no bootstrap session to log otherwise. Every record also carries
request_headers/response_headers
— content-type/content-encoding on both sides is usually the fastest way to see why a
response that reached the exchange still wasn’t captured (an unsupported or malformed
encoding, an unexpected content type), without ever showing a header this doesn’t recognise as
safe, authorization and cookie included.
Where per-request console output should go depends on which of --wait/--run you used,
because --run’s sandboxed command inherits marshal’s own stdout and stderr directly —
nothing separates the two streams. For --wait, marshal is the only thing on this terminal
(you drive the tool’s login in a different one), so pointing logs at it is fine:
marshal --log debug --log-detail access --log-sink stdout \ secrets oauth login CLAUDE_SUBSCRIPTION --waitFor --run, that same flag would interleave marshal’s own log lines with whatever the
sandboxed tool renders on the same terminal — corrupting anything that draws with absolute
cursor positioning or an alternate screen buffer, which is most TUIs, including an interactive
login prompt. --audit-log sidesteps this entirely — a file, not the shared terminal — and is
the more reliable choice for --run for exactly that reason, and unlike journald it’s a file
you named yourself and can delete once you’re done with it:
marshal secrets oauth login CLAUDE_SUBSCRIPTION \ --audit-log /tmp/bootstrap-debug.jsonl --run -- some-vendor-cli loginIf you’d rather watch live instead, journald works too, tailed from a second terminal or pane:
marshal --log debug --log-detail access --log-sink journald \ secrets oauth login CLAUDE_SUBSCRIPTION --run -- some-vendor-cli login# in another terminal:journalctl -t marshal -f(journalctl --user -t marshal -f if the plain form shows nothing or needs permissions you
don’t have.)
--log-sink auto (the default) already prefers journald when it’s reachable, which is most
interactive sessions — so in practice you may already be looking in the wrong place rather
than needing to change anything, and journalctl is where to look first regardless of which
sink you asked for.
This is a different mechanism from
capture: in_band, with a different threat
model — it trusts the session rather than excluding the client. See
ADR-0034.
marshal secrets oauth status [<name>]
Section titled “marshal secrets oauth status [<name>]”One line per OAuth2 swap: its name, the profile it belongs to, its grant, and whether it is enrolled and how long ago. Names are collapsed across profiles — two profiles declaring the same swap name share one stored grant, deliberately.
marshal secrets oauth refresh <name>
Section titled “marshal secrets oauth refresh <name>”Requests a new access token in this CLI process to check that a credential works without
waiting for an agent to need it. This does not invalidate the running proxy’s separate
in-memory cache; restart serve if that cache must be discarded. The token itself is not printed:
putting a live credential into a terminal, a scrollback buffer and a shell history undoes what
boundary injection is for.
marshal secrets oauth logout <name>
Section titled “marshal secrets oauth logout <name>”Removes the stored grant on disk. A fresh proxy process cannot use that grant until it is enrolled again, but a running proxy may still hold the grant and access token in memory. Stop/restart it to discard those copies. This does not revoke anything at the provider — do that there too if the credential may have leaked. See OAuth2 cache behavior.
marshal ca init [--common-name <name>] [--days <n>]
Section titled “marshal ca init [--common-name <name>] [--days <n>]”Generates a CA at the paths named by tls.ca_cert / tls.ca_key, and prints per-platform
trust instructions. Refuses to overwrite an existing one.
--days is the CA’s own validity period, defaulting to 825 (~2.3 years). It is unrelated to
tls.leaf_expiry_hours, which governs the much shorter-lived per-host leaves the CA signs
while it is valid.
marshal ca export [--pem-only]
Section titled “marshal ca export [--pem-only]”Prints the CA certificate and, unless --pem-only, the same trust instructions ca init
prints. Useful for piping into a container image build or a trust store update without
regenerating anything.
marshal serve [--profile <name>] [--listen <addr>] [--audit-log <path>]
Section titled “marshal serve [--profile <name>] [--listen <addr>] [--audit-log <path>]”Runs the proxy until Ctrl-C.
| flag | effect |
|---|---|
--profile <name> | overrides identities.unidentified.profile for the unattributed fallback |
--listen <addr> | replaces listeners.explicit.listen entirely with this one address |
--audit-log <path> | additionally write the full structured JSON record to a file |
--profile does not select a single profile to run. Every profile in the config gets
built and is reachable by whatever resolves an identity into it;
this flag only changes which one catches traffic nothing could attribute.
--audit-log writes the complete record — evidence trail, status code, more than the log’s
one-line summary — in append mode, created if missing. See
Observability.
marshal run --profile <name> [--isolation netns|cgroup|none] [--proxy <url>] [--bind <path>] [--bind-group <name>] [--dry-run] -- <command...>
Section titled “marshal run --profile <name> [--isolation netns|cgroup|none] [--proxy <url>] [--bind <path>] [--bind-group <name>] [--dry-run] -- <command...>”Launches an agent under a profile. See Identity › Launching an agent for what each isolation mode actually buys.
run is not standalone — a marshal serve for the same config must already be running
before you invoke it. run only prepares the agent’s environment (and, under --isolation netns, its sandbox); it does not start the proxy itself. Skip this and the agent gets pointed
at a proxy that isn’t there — for --isolation netns that’s an immediate hard failure, since
the socket serve creates doesn’t exist yet (see below); for --isolation cgroup/none it
surfaces later, as connection errors from the agent itself when it tries to talk to a proxy
nothing is listening on.
run also refuses to launch when identities.resolvers has no run entry. A cgroup name
is only meaningful if something reads it back — otherwise the agent launches, runs, and every
one of its requests lands unattributed on the fallback profile with no signal short of the
audit log. See Identity.
| flag | default | |
|---|---|---|
--profile <name> | none | a named profile, from profiles/; omit to run under the embedded profile: instead — the agent is still identified, it just names no profile |
--isolation | netns | netns enforces, cgroup identifies, none sets env vars only |
--proxy <url> | http://127.0.0.1:8080 | the address the agent is told to use |
--bind <path> | none, repeatable | extra path bound read-write inside --isolation netns; ignored by other modes |
--bind-group <name> | none, repeatable | a named bind group bound the same way as --bind; ignored by other modes |
--dry-run | off | print the command, environment and sandbox wiring; run nothing |
--bind/--bind-group on the command line add to, never replace, whatever the profile’s own
sandbox.bind_groups/sandbox.extra_binds already names — see Bind
groups for defining those once instead of retyping --bind on
every invocation that launches the same tool.
--proxy is not read from the config file, so it must match whatever serve is actually
listening on. The default matches serve’s own default.
--dry-run is useful for checking what a launch would actually do before trusting it with a
real agent — for --isolation netns specifically, it prints the exact bind list the sandbox
gets, which is the fastest way to tell whether a missing file will be the difference between
the agent working and failing.
--isolation netns gives the agent only the workspace, the standard system directories, the
CA certificate, and the marshal socket — not the whole filesystem (see
Identity). It also requires
listeners.explicit.unix_socket to be set in the config, since the Unix socket is the only
route out of the namespace; without it, marshal run fails fast with netns isolation reaches the proxy through a Unix socket, so listeners.explicit.unix_socket must be set in the config
rather than starting (see Identity › netns enforces rather than
identifies). Setting
unix_socket in the config is not enough on its own: the socket file is created by marshal serve, so marshal serve must already be running against a config with unix_socket set
before marshal run --isolation netns is invoked, or it fails fast with <path> does not exist. netns isolation reaches the proxy through this socket, so the proxy must be running with listeners.explicit.unix_socket configured. A tool that
needs something else, such as a package manager cache kept outside the workspace, needs
--bind for it explicitly:
--isolation netns does not work for a tool that opens a browser and waits on a loopback
OAuth callback — the browser it spawns inherits the same isolated namespace and has no route
out either, and the callback server’s loopback address is not the host’s; see the same caveat
under marshal secrets oauth login
for the detail. --isolation cgroup is the fix, at the cost of no longer enforcing that the
tool cannot route around the proxy.
marshal run --profile coding-agent --bind ~/.cache/uv -- uv syncmarshal run --profile llm-agent --isolation cgroup -- python agent.pymarshal run -- claude # no --profile: still identified as pid-<pid>, governed by the embedded profileThe agent binary itself is not exempt. If <command...> is installed anywhere outside the
workspace and READONLY_SYSTEM_DIRS (/usr /etc /bin /sbin /lib /lib64) — which covers most
user-local installs: ~/.local/bin, a Node/Python version manager, npm -g, cargo install,
etc. — --isolation netns cannot find it at all and fails with No such file or directory.
There is no $HOME bind by default. Two things commonly need binding, not just one: the
directory the command is invoked from (wherever it is on $PATH) and, if that’s a symlink,
wherever it actually resolves to — bwrap does not follow symlinks when deciding what to bind,
so binding only the symlink’s directory still leaves the real file unreachable:
which claude # /home/you/.local/bin/claudereadlink -f "$(which claude)" # /home/you/.local/share/claude/versions/2.1.220marshal run --profile coding-agent \ --bind ~/.local/bin --bind ~/.local/share/claude -- claude--dry-run now prints the resolved binds: line precisely so this is diagnosable — if a bare
marshal run --profile coding-agent -- claude fails to find the binary, add --dry-run and
check whether the command’s real path (after resolving symlinks) is in that list. Since the
same two paths are needed every time this agent launches, naming them as a bind
group on the profile is usually better than retyping --bind
twice on every invocation.
marshal sandbox
Section titled “marshal sandbox”Exists, is intentionally undocumented in --help, and should never be invoked directly — it
is the half of --isolation netns that marshal run re-execs itself as inside the network
namespace.