# Secrets and egress

Save provider keys with `jazz config set <provider>` and enter them at the prompt.
For example, `jazz config set openai` stores an OpenAI key without putting it in shell history.
For CI or containers, supply the provider's environment variable. Keep credentials out
of prompts, workflow files, and committed configuration.

This page explains how Jazz stores credentials, redacts tool output, and controls network access.

## Where secrets live

Config writes route through the OS keyring, or a `chmod 600` `$JAZZ_HOME/secrets.json` where
there is no keyring.

`jazz config set llm.openai.api_key` stores the key separately from `config.json`.

The OS keyring is shared by every Jazz home on the account, so each home files its entries under
its own vault service, `jazz.<hash of the home path>.vault`. A `JAZZ_HOME` or `--data-dir` home
cannot read the keys of another. `jazz mcp add --env` and `--header` values are secrets too: they go to the keyring,
and `~/.agents/mcp.json` (mode 0600) keeps only their names.

Older per-secret keyring entries migrate into the home’s vault before writes and alongside the
first read. A completed migration is remembered for the process lifetime. If a keyring read,
write, or deletion fails, the next uncached lookup or mutation retries the incomplete migration;
already migrated vault values remain intact. Copying and retiring each legacy entry share the
same lock as vault updates, preventing another process from migrating a value after its deletion.
Deletion leaves the vault unchanged while migration is incomplete, so a later retry cannot restore a secret that appeared to have been removed.

On macOS, the `security` CLI alone creates, reads and deletes the vault's keychain items. The
login keychain asks for your password whenever an item is read by a program other than the one
that wrote it, so keeping one program on both sides means no prompts. Values go to `security -i`
on stdin, never as command arguments, which other local users can see. `security -i` refuses
lines over 4,095 bytes, so the vault is split into chunks of up to 3,800 characters, and one
`security` process reads them all. A write fills a second set of chunks and only then deletes
the first. An interrupted write leaves the previous vault readable.

Jazz 0.16.19 and 0.16.20 wrote each home's vault through `osascript` instead, which made the
keychain ask for its password on every start. The first start after updating moves that vault's
secrets into the new vault and deletes it. It reads the old vault through whichever program
wrote it, with keychain dialogs turned off, so there is no prompt. Only a vault both programs
wrote needs one dialog, and at a local terminal Jazz waits 60 seconds for an answer. Every step
is recorded in `~/.jazz/logs/jazz.log` under `keyring:`.

Read-back is one-way. `jazz config show` and `jazz config get` redact every secret, including MCP
env and header values and values merged in from the keyring or the environment; `--reveal` is the
explicit exception. A token is printed once, when it is minted.

Provider keys can also come from the environment, which is the normal path in a container where
no keyring exists. Never put a token in an agent prompt, a workflow file, or committed project
config.

## The shell environment is scrubbed

`execute_command` does not inherit your whole environment. A variable is removed before the
command runs when a word of its name marks a secret (`SECRET`, `TOKEN`, `PASSWORD`, `PASSWD`,
`PASSPHRASE`, `CREDENTIALS`, `APIKEY`, `AUTH`, `PASS`, `COOKIE`, or `KEY` after another word, as in
`APP_KEY`), or when it is one Jazz reads a secret from (`JAZZ_PEER_TOKEN_*`, `JAZZ_WEBHOOK_*`,
`JAZZ_NOTIFY_<TARGET>_WEBHOOK_URL` and the other notify target secrets). A model that decides to
`env` or to shell out to something chatty cannot hand your provider keys or a Discord webhook URL
to it. Words are matched whole, so `KEYBOARD_LAYOUT` and `TOKENIZER_PATH` pass through.

When a command needs a secret variable, name it in the agent's `envAllowlist`.

## Egress is its own axis

A tool that sends data off this machine is marked `egress`, independently of its risk level.

A read-only tool is not automatically safe. `web_search` mutates nothing and still hands your
query to a third party. `http_request` sends what the model chose, to a host the model chose.

This is why egress kicks a read-only tool out of a disclosure tier and into the explicit `allow`
list for peers and webhooks. "This caller may read" and "this caller may transmit" are different
grants, and [the security model](./index.md) keeps them separate.

The [tool inventory](../tools/index.md) lists exactly which tools send.

## Network egress

HTTP requests and web fetches run without approval by default, including private addresses and
calls after untrusted content. Set global `network.httpApproval` to a URL array to approve only
matching URLs automatically; unmatched requests ask or park. Approvals authorize one exact call
and never change the list. Redirect URLs must also match the grant, credentials are stripped
across origins, and response byte/time limits still apply. See [HTTP approvals](../configure/jazz.md#web-request-safety).

The private-destination restrictions below apply to other model-selected URL tools, including
`read_pdf`, rendered pages, and the [browser](#browser-safety). HTTP tools instead use their
scoped global HTTP authorization.

Other URL tools use the guarded fetch too: `read_pdf` with a URL, and the HTML rendered by
`create_pdf` and `create_composition`. For these tools it enforces four things.

- **Public destinations.** The hostname is resolved, and every address it resolves to must be
  public. Loopback (`127.0.0.0/8`, `::1`), private (`10/8`, `172.16/12`, `192.168/16`, `fc00::/7`),
  link-local (`169.254/16`, which holds the cloud metadata service, and `fe80::/10`), CGNAT
  (`100.64/10`), `0.0.0.0`, IPv4-mapped IPv6 and the other special-purpose ranges are refused. The
  URL parser rewrites spellings like `2130706433`, `0x7f000001` and `0177.0.0.1` to `127.0.0.1`
  first, so they are refused too.
- **Every redirect hop.** Redirects are followed by hand, at most 20, and each target passes the
  same check before it is requested. An allowed host cannot bounce a request to metadata.
- **Credentials stay with their origin.** A cross-origin hop keeps only headers that carry no
  credential (`Accept`, `Content-Type`, `User-Agent` and the like). `Authorization`, `Cookie` and
  any custom header the model set are dropped. A cross-origin 307 or 308 that would resend the body
  is returned to the model instead of followed.
- **Bounded bodies.** Responses are streamed against the tool's byte cap and the timeout runs until
  the body is read, so an endless or slow response costs at most the cap and the timeout.

A URL that reaches this machine or your local network asks for approval instead of failing, and
approving adds the address to the global `network.allowPrivateHosts`, so the next request goes
through unasked. List hosts ahead of time in the same setting (see
[private network hosts](../configure/jazz.md#private-network-hosts)):

```json
{ "network": { "allowPrivateHosts": ["homeassistant.local", "192.168.1.0/24"] } }
```

A hostname entry allows whatever that name resolves to, so list names you control. An address or
CIDR entry allows those addresses behind any name. Only the global config file sets it.

## Browser safety

The [browser tools](../concepts/browser-use.md) give the model a real browser, whose pages run their
own scripts. Three rules bound it.

- **Every request is checked, not just the first.** Navigations, images, scripts, `fetch()` calls
  and redirects from the page all pass the destination check above: public addresses only, unless
  the host is in `network.allowPrivateHosts`. A page on the public internet cannot make the
  browser call `localhost`, your router, or the cloud metadata address. A navigation the agent
  starts to a private address asks for approval, like `read_pdf`. A link the page opens to one is
  refused, and the agent has to request that URL directly. With a URL list in
  `network.httpApproval`, only matching URLs load, plus subresources from the origins the run
  opened from them. `file:` URLs are always refused.
- **Nothing persists.** Each run gets a fresh temporary profile, deleted when the run ends. The
  browser is closed on completion, error, and interruption. Jazz never attaches to your own
  Chrome, so your logins and cookies are out of reach. A `browser.endpoint` you configure
  is the exception: that browser's host sees what the agent reads and types.
- **What a page says is data.** Snapshots arrive in the `untrusted-content` envelope and mark
  the run as having read external content. Afterwards `browser_navigate` to a URL the run did not
  receive asks, like other outbound tools. `browser_act` always needs approval unless the policy
  is `high-risk`, and unattended runs park on it.

A password you type with `ask_user_secret` reaches a page only through `browser_act`. The
approval names the page, Jazz refuses plain `http` pages that are not on this machine, and the
model only ever sees the placeholder. The page itself receives the value, so approve it only for
a site you expect to sign in to.

A page that shows a password or payment card field is flagged. `browser_act` then asks on every
call there, even under the `high-risk` policy or an allowlist, and the approval carries a warning.
A plugin can add flags through the `classify.page` hook. A flag only adds scrutiny: a plugin cannot
remove a flag the page's own structure raised, lower a risk, skip an approval, or clear the
untrusted-content marking.

What this does not cover:

- **It is not a sandbox for the page.** A malicious page can still try to exploit the browser.
  Chrome's own sandbox is on, except as root in a container, where it cannot run. Run unattended
  browsing as a dedicated user or in a container, as for any tool.
- **WebSocket and WebRTC traffic** does not pass through request interception, so the
  destination check does not apply to it.
- **DNS answers are cached for a call.** A host is resolved once per browser call, so a page
  that rebinds a name between two requests in the same call is not caught.
- **What the agent types goes to the page.** A page that looks like the site you expected can
  collect a typed secret. The approval shows the page address; read it.
- **Flags are an extra alarm, not a boundary.** The structural check does not see fields inside a
  frame from another site, and a plugin's classification reads titles and labels that the page
  wrote, which can be written to steer it.

## Computer use safety (experimental)

The [computer tools](../concepts/computer-use.md) let the model read and act in desktop apps. They
are experimental and run on macOS with Apple Silicon only. Five rules bound them.

- **Only apps you granted, and only as far as their class allows.** You grant each app by bundle
  id, for a time you choose, with `jazz computer grant`. Terminals, password managers, System
  Settings and script editors cannot be granted at all, because controlling one would bypass the
  shell tool's approvals or hand the model your credentials or permissions. Browsers can only be
  read, and editors and Finder take clicks but no typing. The class is fixed by the app and a
  grant cannot raise it. Grants are read again before every action, so a revoke or an expiry
  takes effect on the next one.
- **Enabling authorizes computer use; app grants require consent.** Turning on the computer
  tools authorizes the capability. The agent can request an app grant in chat for your approval;
  `jazz computer grant` runs from your own terminal. The agent's file tools ask about every
  change under `$JAZZ_HOME/computer` under every policy. An agent with shell approval under the
  `high-risk` policy can still edit that state, as it can any other Jazz state.
- **Only a person watching.** The tools exist only in a terminal conversation. A script, a
  schedule, a goal or loop cycle, a webhook, a peer request, a bot, or a detached job does not
  get them.
- **What a window says is data.** An observation arrives in the `untrusted-content` envelope and
  marks the run as having read external content, so outbound tools stop auto-approving below the
  `high-risk` policy afterwards. `computer_input` and `computer_foreground` need approval, and a
  password field accepts only a secret you collected with `ask_user_secret`. Jazz also refuses
  the shortcuts that empty the Trash, lock the screen or log out, and text that downloads and
  runs code or deletes data.
- **You can stop it.** `jazz computer stop` ends the run at its next action and stops the driver
  at once. One run holds the desktop at a time, and a crashed run releases it.

The first successful session pins the SHA-256 of the `cua-driver` executable. When the binary
changes, Jazz blocks computer use and offers in-chat approval for the new build. A missing driver
can be installed through an in-chat `computer_install_driver` approval. The driver runs in its
promptless standard mode with no manifest inherited from your shell.

What this does not cover:

- **It is not an operating-system sandbox.** An app the agent controls can do anything that app
  can do, including things Jazz never sees. The class of an app limits what Jazz tries, not what
  the app does in response.
- **The model sees what the window shows.** Screenshots and on-screen text go to your model
  provider. Pixels cannot be redacted: Jazz redacts secrets it knows in the accessibility text,
  and reads only windows of granted apps, but a window that displays a secret in an image shows
  it. Keep sensitive apps out of the grant and close their windows.
- **The driver's hash covers one executable.** If the driver hands work to a separate helper
  app, that app is not hashed.
- **Text in a window can still steer the model.** The envelope and the approvals limit what
  follows, but they do not stop a model from being persuaded to click something inside an app it
  was granted.
- **The record is metadata.** `jazz computer log` holds the action, the app and a shortened,
  redacted element label, never a screenshot or typed text.

## Secret values in tool output

Files read normally, whatever they hold: `read_file`, `grep`, `find`, `ls`, `cp` and `mv` treat
`.env`, `secrets.json`, `~/.zshrc` and `~/.jazz/config.json` like any other file. What Jazz holds
back is the secret values inside them. Every tool result passes through one redaction step before
anything logs it or shows it to the model, the transcript or an approver, so file contents, command
output, MCP and HTTP responses, errors, and approval previews are all covered.

Tools that cut their output redact first, so no cut can split a secret into a piece that is no
longer recognized:

- `read_file` redacts the whole file before it takes a line range or applies its character cap, and
  keeps the file's line numbers: every line of a private key block shows the placeholder. A
  `sinceByte` read redacts the appended text together with the lines before the offset, and an
  offset inside a line holding a secret returns that whole line redacted.
- `execute_command` and `wait_for` redact stdout and stderr before capping them at 256 KB, with
  16 KB of lookahead past the cap, so a key block that crosses the cap is recognized. `list_background_jobs`
  redacts a job's output before it keeps the tail.
- `grep` and `edit_file`'s `replace_pattern` match against the redacted text. A file holding a
  secret is searched again through its redacted form, in every output mode, so a pattern probing a
  value (`^DB_PASSWORD=[a-m]` with `outputMode: "count"`) finds nothing, while a search for the
  name finds the line and shows it redacted. A `replace_pattern` match on a line holding a secret
  is refused.
- `create_pdf` and `create_composition` serve the local text files their HTML loads (an
  `<iframe src=".env">`, a stylesheet, a script) with secret values redacted. `read_file` does not
  attach a PDF whose text holds a secret; it points the model at `read_pdf`, whose output is
  redacted.

Jazz replaces recognized secrets with `[redacted:<name>]`:

- **Values Jazz knows**, replaced exactly wherever they appear: every secret setting (provider API
  keys, OTLP headers, the daemon and operator tokens, peer and webhook tokens, webhook signing
  secrets, notify target secrets), as resolved from the keyring, the environment or the config
  file; MCP env and header values whose name marks a secret (`SIGNOZ_API_KEY`, `Authorization`);
  and every environment variable of the Jazz process whose name marks it as a secret (`*_API_KEY`,
  `*_TOKEN`, `*_SECRET`, `*_PASSWORD`, `*_KEY`, `*_PASS`, `JAZZ_NOTIFY_*_WEBHOOK_URL`, ...). The
  placeholder names the setting or variable, for example `[redacted:llm.openai.api_key]`. Values
  held only in the keyring are looked up again after a config change and at most once a minute.
- **Secrets Jazz never saw**, recognized by their shape: `NAME=value` lines and YAML `name: value`
  lines whose name marks a secret (`.env` files, shell profiles, `env` output, compose files,
  kubeconfig, diff lines, including coloured ones), quoted secret-named literals in JSON, YAML and
  source (`"apiKey": "..."`, also JSON-escaped), `.netrc` passwords, private key blocks (also when
  the end marker is cut off), JWTs, Discord and Slack webhook URLs, `Bearer`/`Basic` credentials,
  passwords in URLs, and the key formats of OpenAI, Anthropic, GitHub, Slack, AWS, Google, Stripe,
  npm and Telegram. A name marks a secret when its last word does (`DB_PASSWORD`, `APP_KEY`,
  `refresh_token`), so `TOKEN_URL`, `token_type` and `max_tokens` are left alone. `$VAR` references,
  templates, `<placeholders>`, booleans, paths and values shorter than 8 characters (for known
  values) are left alone.
- **Structured results by key**: in an HTTP response body or an MCP tool's structured content,
  a string under a secret-named key (`access_token`, `client_secret`, `password`) is replaced
  whole.

`write_file` and `edit_file` refuse text carrying a placeholder that stands for a secret: one the
target file holds, or one Jazz knows. An edit copied from redacted output cannot overwrite the real
value, while placeholder-shaped text that names no such secret, such as documentation of this
feature, is written as it is. `write_file` also refuses to replace an existing file when the new
content leaves out or changes a line holding a secret, and tells the model to use `edit_file` on
the other lines instead. The agent can still change a `.env` file: it edits the lines around a
secret, or replaces a secret line with a new value you asked for.

Jazz's own configuration is readable like any file, but a `write_file`, `edit_file`, `mv`, `cp` or
`rm` that changes it always asks for approval, under every auto-approve policy and allowlist
(`yolo` included). That covers `config.json` (or `$JAZZ_CONFIG_PATH`), a project's
`.jazz/config.json`, and everything else under `$JAZZ_HOME` except the directories that hold
authored content: `skills`, `workflows`, `personas`, `memory`, `workspace`, `compositions`,
`generated` and `webapps`. Symlinked spellings count, and so does case on macOS and Windows. With
nobody to ask, the run parks for approval.

Recognition by shape is best-effort. A secret with an unrecognized name and format, or one a shell
pipeline transforms before it reaches a tool result (base64, splitting it across lines), is not
caught. Shell children also lose credential-named environment variables (see [Shell environment](#the-shell-environment-is-scrubbed)), and host
isolation remains the job of a dedicated OS user or container (see
[unattended runs](./unattended-runs.md)).

## Secrets the person types

Some secrets exist only in your head: the password of an encrypted PDF, a one-time code, a disk
passphrase. The agent asks for one with `ask_user_secret`, and the value never reaches the model:

- **You type it hidden.** In the terminal, both interfaces draw one `•` per character and never
  show the value, before or after you press Enter. Esc declines. `jazz run` at a terminal reads it
  the same way; piped into a run, the first line of stdin is taken as it is.
- **The model gets a placeholder**, `[redacted:<name>]` (for example `[redacted:pdf-password]`),
  and passes it as-is to the argument that needs the secret.
- **The value is held in memory for the rest of the run**, and forgotten when the run ends.
  While it is held, it is a known secret: every tool result, approval preview and log line has it
  replaced by its placeholder, whatever its length. It is never written to the transcript, the
  conversation log, the run record or a provider request.
- **Only declared arguments get the value.** Just before a tool runs, Jazz puts the value in place
  of the placeholder in the arguments that tool declares for secrets: `password` of `read_pdf` and
  `pdf_page_count`, `command` of `execute_command`, `text` of `browser_act`, and `text` of
  `computer_input` and `computer_foreground`. A command or a typed browser or desktop action
  carrying one always asks you, under every auto-approve policy, and its approval shows the
  placeholder. `browser_act` sends the value off this machine, to the page you are signed in to;
  see [browser safety](#browser-safety). The computer tools enter it into an app on this machine;
  see [computer use safety](#computer-use-safety-experimental). Any other tool or
  argument carrying the placeholder is refused, so it cannot reach a file, a URL, a web search, an
  MCP server, a notification or a sub-agent's prompt. A placeholder standing for a secret Jazz holds
  in its config (`[redacted:llm.openai.api_key]`) is never replaced by anything.
- **Every typed secret is saved for later runs.** The agent must name it (`cloudflare-token`); the
  value also goes to the OS keyring, or `$JAZZ_HOME/secrets.json` where there is none, and the name and
  prompt to `$JAZZ_HOME/saved-secrets.json`. Every agent on this Jazz home can see the names with
  `list_saved_secrets`; none can see a value. Manage them with [`jazz secrets`](../commands.md#jazz-secrets).
- **Loading a saved secret asks you, every time.** A later run loads one with `use_saved_secret`,
  which asks you under every auto-approve policy, `yolo` included, before the keyring is read. A
  run nobody can answer parks instead. Starting the run with `--dangerously-allow-reading-secrets`
  (or `JAZZ_DANGEROUSLY_ALLOW_READING_SECRETS=1`) drops that question, and the one on each command
  or typed action that uses a secret; those then follow the run's ordinary approval policy. Use it
  only for unattended runs you trust with every saved secret.
- **A placeholder the run does not hold is refused.** Passed to a tool that takes secrets, it would
  otherwise arrive as literal text. The tool is not run, and the model is told to load the secret
  with `use_saved_secret` or have you type it.
- **A parked run asks again.** A run nobody can answer (a goal, a loop, `jazz run --park`) parks
  with the prompt and name only. Type the secret in a terminal with `jazz runs secret <run id>`; it
  goes to the resumed run in memory. A run resumed for any other reason starts with no typed
  secrets, so the model loads a saved one or asks again.
- **In a chat bridge, only a private chat collects one.** Telegram, Discord, WhatsApp and iMessage
  ask for it in a one-to-one chat with the bot and take your next message as the value; that
  message is never forwarded to the agent as a turn. The Telegram bridge deletes your message as
  soon as it is read; on the other platforms a bot cannot delete it, so the prompt tells you to
  delete it yourself. In a group, a server channel or a Photon space, the secret is not collected:
  the chat is told to use a private chat instead, and the agent is told the same.

## Content from outside is labelled

Sub-agent answers retain their external-content provenance while running in the background.
`wait_subagents` and `list_subagents` mark results containing those answers or their errors as
untrusted, naming every child whose content is shown.

Results that carry someone else's words arrive inside an `<untrusted-content>` envelope that names
the source before and after the text: `web_fetch`, `web_search`, `http_request`, `read_pdf` URLs,
MCP tools and resources, `ask_peer`, the output of every `execute_command` and custom command
tool, and `read_file` of a file outside the working directory. Command output counts because Jazz
cannot tell what a command read: `himalaya` printing your inbox and `ls` look the same from
outside. The system prompt tells the model to read that content as data and to take instructions
only from you. After a run reads external content, egress tools need approval below `high-risk`;
see [unattended runs](./unattended-runs.md#egress-after-untrusted-input).

External-content exposure is also recorded as host-owned transcript metadata. Clearing tool
output, automatic or manual compaction, trimming a full context window, the chat's history cap,
the compaction journal a resumed run reads back, a sub-agent's answer, a Ctrl+B background task's
result, a batch stopped by Esc or a timeout, and a transcript returned from a detached host all
carry that restriction forward; saving and resuming that conversation still requires approval for
new destinations under `read-only` and `low-risk`.
The model's summary cannot remove the restriction. Older transcripts still containing an external
tool envelope remain restricted, but exposure already lost from an older compacted transcript
cannot be recovered.

## MCP servers are external input

A server definition arrives from outside, including its command, its arguments, and the tools it
advertises.

Jazz records whether you trust it separately from the definition itself. Your trust is not part
of the config somebody handed you: it is read only from your global `~/.jazz/config.json`, and
applies only to servers defined in your own `~/.agents/mcp.json`. A repository's
`./.agents/mcp.json` can add servers, which stay untrusted, but cannot replace one of yours or
inherit its trust by reusing its name.

OAuth tokens are bound to the server's name and URL. A definition that reuses a name with another
URL gets no token.

An untrusted server's tools are not exposed broadly. Treat adding one the way you would treat
`curl | sh`, because the trust decision is the same shape.

## What this does not do

None of it replaces operating-system permissions or network isolation. A shell tool running as
your user reaches whatever your user reaches.

Scrubbing the environment stops a key being read out of `env`. It does not stop a read tool or a
shell command from reading `~/.aws/credentials`: the read tools return it with the secret values
they recognize redacted, and `execute_command` is gated by approval. A secret neither pass
recognizes reaches the model.

If that matters for your deployment, the answer is a dedicated OS user or a container, not a
tighter approval policy. See [unattended runs](./unattended-runs.md).