Skip to content

CLI reference

Every subcommand accepts these, before the subcommand name:

flagenv vardefault
--config, -c <path>MARSHAL_CONFIG$XDG_CONFIG_HOME/bot-marshal/config.yamlusually ~/.config/bot-marshal/config.yaml; a system service should pass an explicit path (see Production)
--log <level>MARSHAL_LOGinfoerror, warn, info, debug, trace — the base messages’ verbosity only; see Observability
--log-detail <level>MARSHAL_LOG_DETAILaccesslog, access, audit — how much per-request lines carry
--log-sink <dest>MARSHAL_LOG_SINKautoauto, stdout, journald, syslog
--log-format <fmt>MARSHAL_LOG_FORMATautoauto, pretty, json — stdout only
Terminal window
marshal --config /etc/bot-marshal/marshal.yaml --log debug serve

Every 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.

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.

Terminal window
marshal config check

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 by redirect_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.

Terminal window
marshal secrets oauth login GITHUB_APP --open

Two 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_access in scope; Google wants access_type: offline in extra_params.
  • redirect_uri is 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.

Terminal window
marshal secrets oauth login CLAUDE_SUBSCRIPTION --wait

prints 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.

Terminal window
marshal secrets oauth login CLAUDE_SUBSCRIPTION --run -- some-vendor-cli login

does 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.
stealredeem 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_dir must 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_access in 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:

Terminal window
marshal --log debug --log-detail access --log-sink stdout \
secrets oauth login CLAUDE_SUBSCRIPTION --wait

For --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:

Terminal window
marshal secrets oauth login CLAUDE_SUBSCRIPTION \
--audit-log /tmp/bootstrap-debug.jsonl --run -- some-vendor-cli login

If you’d rather watch live instead, journald works too, tailed from a second terminal or pane:

Terminal window
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.

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.

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.

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.

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.

flageffect
--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.

flagdefault
--profile <name>nonea named profile, from profiles/; omit to run under the embedded profile: instead — the agent is still identified, it just names no profile
--isolationnetnsnetns enforces, cgroup identifies, none sets env vars only
--proxy <url>http://127.0.0.1:8080the address the agent is told to use
--bind <path>none, repeatableextra path bound read-write inside --isolation netns; ignored by other modes
--bind-group <name>none, repeatablea named bind group bound the same way as --bind; ignored by other modes
--dry-runoffprint 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.

Terminal window
marshal run --profile coding-agent --bind ~/.cache/uv -- uv sync
Terminal window
marshal run --profile llm-agent --isolation cgroup -- python agent.py
marshal run -- claude # no --profile: still identified as pid-<pid>, governed by the embedded profile

The 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:

Terminal window
which claude # /home/you/.local/bin/claude
readlink -f "$(which claude)" # /home/you/.local/share/claude/versions/2.1.220
marshal 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.

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.