Policy
The policy file: schema, defaults, what a project may tighten, and the sandbox profile
The policy is a single JSON file. The firewall ships with safe defaults, so a policy file is optional: you write one only to allow something the defaults block, to tell it which MCP servers are trusted, or to tighten further.
- User or machine policy:
~/.launchsafe-firewall/policy.json. Create a starting one withlaunchsafe-firewall policy init. - Administrator policy: a managed file at
/Library/Application Support/LaunchSafeFirewall/policy.json(macOS) or/etc/launchsafe-firewall/policy.json(Linux). It applies over the user policy. - Project policy:
.launchsafe-firewall.jsonat a workspace root. It may only tighten.
Check what is in effect with launchsafe-firewall policy show. Validate a file with launchsafe-firewall policy validate.
Schema version
Every policy sets "version". The current schema is version 3. Every key is validated strictly: an unknown key, at the top level or inside any object, is an error, and so is a value of the wrong type or out of range. This release reads versions 1, 2 and 3.
- Version 2 files keep working, unchanged. Version 3 only added keys, all with defaults, so a version 2 file means exactly what it would mean as version 3. It is migrated in memory and
policy validatesays a migration is available. A version 1 file is read the same way. launchsafe-firewall policy migraterewrites your user file as version 3. It is lossless: every key, value and key order is kept and only the number changes, and it checks that the old and new file validate to the same settings before writing. The original bytes are first saved next to it aspolicy.json.v2.bak(.v2.2.bakand so on if a backup already exists). An invalid file, or one newer than the firewall, is refused and nothing is written. Likepolicy init, it needs you at the keyboard.- Managed and project files are not rewritten by
policy migrate: their owners change"version": 2to3. Leave a repository's.launchsafe-firewall.jsonat version 2 while anyone who works on it runs a firewall older than 0.3. - An older firewall reading a version 3 file fails safe. It refuses a schema version above its own, treats the file as invalid (its keys keep the strict defaults), and sends every action other than a read to a person (
FW-POLICY-ERROR) until the firewall is upgraded or the file is put back to version 2.
Example
{
"version": 3,
// Hosts that may be FETCHED from even in an untrusted session (GET-like).
// Suffix match on a dot boundary: "github.com" also matches "api.github.com";
// "=github.com" matches only that host; "*.github.com" only subdomains.
// Setting this REPLACES the built-in list, so include the defaults you want.
"fetchAllowHosts": ["registry.npmjs.org", "github.com", "..."],
// Hosts that may be SENT to (POST/upload/push) even in an untrusted session.
// Empty by default on purpose.
"sendAllowHosts": [],
// Hosts never contacted.
"denyHosts": [],
// Destinations allowed to RECEIVE a kind of sensitive data that is otherwise
// never sent. Entries are hosts, or "mcp:<server>" for an MCP server.
"dataAllowHosts": {
"credential": [],
"card": [],
"iban": [],
"national_id": []
},
// Published test values are not sensitive data (test cards, example IBANs,
// specimen SSNs, sk_test_ keys). A project policy can only turn this off.
"allowTestValues": true,
// MCP servers, by the server name (the middle of mcp__<server>__<tool>).
"mcpServers": {
"my-internal-tools": { "results": "trusted", "privateData": false },
"github": { "results": "untrusted", "privateData": true,
"blockedTools": ["delete_repo"] }
},
// The policy for a server not listed:
"mcpDefault": { "results": "untrusted", "privateData": true },
// Extra path globs (in addition to the built-ins):
"untrustedPaths": [], // reading these taints the session
"protectedPaths": [], // writing these needs a person / is held
"secretPaths": [], // these are credential files
"trustedReadRoots": [], // absolute roots outside the workspace safe to read
// Modes:
"untrustedWorkspace": false, // treat the whole checkout as outside content
"redactSecretsInOutput": false, // mask secrets in tool output before the model sees them
"allowInstructionWritesWhenTrusted": true,
"unattended": false, // always queue instead of ask
"unattendedHours": [{ "from": "22:00", "to": "07:00" }],
"unattendedPermissionModes": ["dontAsk"],
"userOutputTools": [], // tools that only show you something
"notUserOutputTools": [],
"provenance": { "trustSelfAuthored": true, "carryUntrustedWrites": true, "maxHashBytes": 4194304 },
"agentScan": { "enabled": true, "taintOn": "high", "userScope": true },
"mcpPinning": { "enabled": true, "newToolGraceHours": 24, "taintOnChange": true },
"canaries": { "enabled": true, "tripOnRecursive": false, "offerAtInstall": true, "homeDecoys": false },
"notifications": {
"enabled": true,
"on": ["queued", "proposal", "denied_dlp", "canary", "loop", "agent_config", "mcp_changed", "mcp_withheld", "mail_quarantined"],
"onlyWhenUnattended": false,
"minIntervalSec": 120
},
"taskScope": { "mode": "log", "unattendedIntents": true },
"packageIntel": { "malicious": "deny", "typosquat": "approve", "lockfiles": true, "maxSnapshotAgeDays": 30 },
"sandbox": { "protectInstalledProfile": true, "strictAllowlist": true, "safeExcludedCommands": [] },
"run": { "allowWritePaths": [], "privateHosts": [] },
"persistence": { "trustedContentChecks": true, "holdWhenUntrusted": ["git-hook", "editor-task", "devcontainer"] },
"budgets": {
"enabled": true,
"untrusted": { "bytesPerHost": 65536, "bytesTotal": 262144, "requestsPerHost10m": 40, "mcpCalls10m": 200, "mcpSends10m": 20 },
"trusted": { "bytesTotal": 4194304, "requestsPerHost10m": 300, "mcpCalls10m": 200 },
"loop": { "sameAction10m": 8, "nonAllow10m": 25 },
"exemptHosts": []
}
}The vault, gateway, emailGuard and phone keys are documented on their own pages, linked below.
Keys: defaults and what a project file may do
A project file may tighten; loosening is for the user or managed policy only.
| Key | Default | A project may | User or managed policy only |
|---|---|---|---|
userOutputTools | [] | Nothing (entries are ignored and reported) | Add tool names |
notUserOutputTools | [] | Add tool names | Nothing |
provenance.trustSelfAuthored | true | Set false (every outside read taints) | Set true |
provenance.carryUntrustedWrites | true | Set true | Set false |
provenance.maxHashBytes | 4194304 | Lower it | Raise it (up to 64 MiB) |
agentScan.enabled | true | Set true | Set false |
agentScan.taintOn | "high" | Move towards "high" ("never" < "critical" < "high") | Move towards "never" |
agentScan.userScope | true | Set true | Set false |
mcpPinning.enabled | true | Set true | Set false |
mcpPinning.newToolGraceHours | 24 | Lower it | Raise it (up to 720) |
mcpPinning.taintOnChange | true | Set true | Set false |
canaries.enabled | true | Set true | Set false |
canaries.tripOnRecursive | false | Set true | Set false |
canaries.offerAtInstall, canaries.homeDecoys | true, false | Nothing (read only from user and managed policy) | Set either way |
notifications.enabled | true | Set true | Set false |
notifications.on | All nine kinds | Add kinds | Remove kinds |
notifications.onlyWhenUnattended | false | Nothing | Set either way |
notifications.minIntervalSec | 120 | Nothing | Set it (10 to 86400) |
packageIntel.malicious | "deny" | Move towards "deny" ("off" < "approve" < "deny") | Move towards "off" |
packageIntel.typosquat | "approve" | Set "approve" | Set "off" |
packageIntel.lockfiles | true | Set true | Set false |
packageIntel.maxSnapshotAgeDays | 30 | Lower it | Raise it (up to 3650) |
sandbox.protectInstalledProfile | true | Set true | Set false |
sandbox.strictAllowlist | true | Set true | Set false |
sandbox.safeExcludedCommands | [] | Nothing (ignored and reported) | Add command patterns |
persistence.trustedContentChecks | true | Set true | Set false |
persistence.holdWhenUntrusted | ["git-hook", "editor-task", "devcontainer"] | Add tags (ci, env-hook) | Remove tags |
budgets.enabled | true | Set true | Set false |
budgets.untrusted.*, budgets.trusted.*, budgets.loop.* | See the example | Lower a limit | Raise a limit (whole numbers from 1) |
budgets.exemptHosts | [] | Nothing (ignored and reported) | Add hosts |
taskScope.mode | "log" | Move towards "enforce" ("off" < "log" < "enforce") | Move towards "off" |
taskScope.unattendedIntents | true | Set true | Set false |
userOutputTools and notUserOutputTools: the built-in catalogue holds the host tools captured from Claude Code 2.1.289 events: SendUserFile and SubagentHandback (show something to you), mcp__ccd_session__mark_chapter, mcp__ccd_session__dismiss_task and mcp__ccd_host__request_keep_awake (app UI only), mcp__ccd_session__spawn_task (proposes a session; its prompt is logged), and Artifact publishing (a send to claude.ai, never "to you"). The mcp__ ones count only when Claude Code reports the server's source as sdk. A name in userOutputTools is treated as showing something to you: any path in its arguments is read for the credential rules only and nothing taints. A name in notUserOutputTools is never a host tool. doctor warns when Claude Code is newer than the catalogue.
Lists replace, they do not append
When you set a list key (for example fetchAllowHosts) in a user or managed policy, it replaces the built-in list for that key. It does not extend it. Run launchsafe-firewall policy show to see the effective defaults and copy the ones you want to keep.
Invalid policy fails safe
If the user or managed policy file is unreadable or invalid, the broken file is not applied (its keys keep the strict defaults) and a policy error sends every risky action to a person, so a broken file never silently opens things up. Every other layer that parsed still applies: a broken user file keeps the administrator's managed policy (its denyHosts, blocked tools and server entries stay hard denies) and the project's tightening. A broken managed file also drops the user's layer, which could otherwise open what the administrator closed. doctor and policy validate report the exact problem.
A project may only tighten
A .launchsafe-firewall.json in a repository is attacker-reachable content, so it is applied with tighten-only semantics. It may add to denyHosts, protectedPaths, secretPaths and untrustedPaths; mark a server untrusted or block tools; switch on untrustedWorkspace, redactSecretsInOutput and unattended; set allowInstructionWritesWhenTrusted: false; and move the other keys only in the tightening direction of the table above. A project cannot switch decoy detection off, make notifications quieter, make the package checks looser, switch off the sandbox profile protection, or mark commands safe to run outside the sandbox. Anything that would loosen the policy (allowing a destination, trusting a server, adding a trusted read root) is ignored and reported by doctor and policy validate.
The administrator's file over the user's
The managed file is applied after the user's, so every key it sets wins (a list it sets replaces the user's; inside objects such as budgets, each field it sets wins). Cases that are closed:
mcpServers: a user's exact name or pattern no longer hides a managed entry. A user entry that a managed entry covers starts from the managed entry and merges tighten-only.userOutputTools: unless the managed file sets it itself, a user entry naming a tool of a server the managed file has an entry for is dropped (and noted bypolicy validate).
Cases left as they are, because the managed file can close them by setting the key itself:
mcpDefaultis for servers no file lists. A user entry for a server the managed file does not list applies as written. An administrator who wants every server judged their way lists them, or a"*"entry, inmcpServers.sendAllowHostsandbudgets.exemptHosts: hosts the user lists are not counted against the managed budgets. Set those keys in the managed file ([]to allow none) to fix them.fetchAllowHosts,sendAllowHostsandsandbox.safeExcludedCommandsfeed the sandbox profile; a managed file that sets them decides what the profile may allow.dataAllowHostsoverrides the data rules, not an MCP server's entry; a manageddataAllowHostsreplaces all four kinds.trustedReadRootsnever outrankssecretPathsoruntrustedPaths, and a host indenyHostsis denied (FW-CAPTURE-HOST) whatever allow list also names it.
MCP server settings
results:"trusted"(results do not taint) or"untrusted"(results are outside content). A server Claude Code reports as project-defined is forced untrusted whatever its name, because a repository must not be able to mark its own server trusted.privateData: whether reading from this server counts as reading private data.readTools,sendTools,destructiveToolsandblockedTools: override the verb-based classification for specific tools (glob patterns allowed).blockedToolsare never callable.
A user-configured server named filesystem or fs, started with directories that are all inside the workspace, is trusted without an entry (its roots are read from its launch arguments).
Within one file, a server's exact name wins over a pattern, and the first matching pattern wins over later ones. A project file's entries only add restrictions: blockedTools, sendTools and destructiveTools are unions, results: "untrusted" and privateData: true win, and a server a project names is always untrusted and private. The administrator's entries hold under the user's the same way, and policy validate notes each user entry whose loosening did not apply.
Files that run code later (persistence)
The persistence paths carry a tag: ci (GitHub, GitLab, CircleCI, Buildkite and other CI definitions, Dependabot and Renovate), git-hook (.git/hooks, .husky, .githooks, lefthook, pre-commit, .git/config, and the same files of submodules and linked worktrees), editor-task (.vscode, .idea, .zed, .fleet, *.code-workspace, project-local .nvim.lua, .exrc, .vimrc), devcontainer (.devcontainer, devbox.json, .gitpod.yml, .replit) and env-hook (.envrc, mise, .npmrc, .yarnrc, .yarnrc.yml, .pnpmfile.cjs). .gitattributes and flake.nix or shell.nix are judged by their content. .tool-versions and .nvmrc only pin versions and are not persistence points.
With trustedContentChecks on, a trusted session's write inside the workspace is allowed unless the content it leaves adds a code-running capability (FW-AUTORUN; launchsafe-firewall explain FW-AUTORUN lists them). A shell write to a git hook or an editor task cannot be checked and asks. holdWhenUntrusted names the tags whose changes an untrusted session makes are held as proposals: hook bodies always, editor tasks and dev containers only in their auto-run forms. Other changes ask (FW-PERSISTENCE). A project may add tags and add paths through protectedPaths; it may not remove either.
Budgets
Counters are kept per session inside the data directory. Bytes are the payload that leaves (the URL for a GET, the command line or body for a send, the arguments of an MCP call, plus the size of a file it uploads) and count for allowlisted hosts too. Not counted: registries and source hosts reached by a package manager or git (registry.npmjs.org, pypi.org, files.pythonhosted.org, crates.io, proxy.golang.org, github.com for clones, ghcr.io and similar), loopback, the hosts in sendAllowHosts, and exemptHosts. A request sent through a proxy or redirected with --connect-to or --resolve counts for the host it really reaches.
requestsPerHost10m, mcpCalls10m, mcpSends10m and the loop limits are per 10 minutes; the byte limits are per session. loop.sameAction10m counts the same action with no file written in between (an edit resets it). loop.nonAllow10m counts actions that asked, were held or were blocked.
Approving an action that reached a budget (answering the prompt, or queue approve) raises that budget for the session by the same amount again, counted from where you approved it. When the counters file cannot be read, the next action that sends data in an untrusted session asks once, then counting starts again. Each group may be given in part; the other limits keep their defaults. A project may only lower limits.
Task scope (taskScope)
Each prompt you type sets the session's task scope: the workspace paths it names, the GitHub repositories and hosts it names, and the publish-type verbs it uses as a request (push, open a PR, publish or release, deploy or ship, send or post or message, delete or remove or drop, install, migrate). With mode: "enforce", FW-SCOPE then asks (or holds, when no one is there) for:
| Action | When | Outcome |
|---|---|---|
| A write inside the workspace outside every named path (build outputs, caches, lockfiles and scratch are never narrowed) | Untrusted, or unattended | Approve |
A send to a repository or host the prompt did not name (and not on sendAllowHosts) | Untrusted | Approve |
| A push, PR, publish, deploy or message the prompt did not ask for | Unattended, even trusted | Approve, so held |
| A new package install the prompt did not ask for (restoring declared dependencies is not one) | Unattended, even trusted | Approve, so held |
| A destructive action without a delete verb | Any | Approve |
An interactive trusted session is never narrowed. A prompt that names no path and no verb ("make it faster") narrows nothing. The scope never loosens: an action inside it is judged by every other rule exactly as before.
Parsing is strict on purpose: no fuzzy names, only paths that exist inside the workspace, nothing from fenced code blocks, quoted lines or quoted strings (pasted logs, issues and emails are someone else's words), and no verb that is negated ("don't push"), asked about ("should I deploy?") or used as a noun ("the deploy script fails"). A continuation ("yes", "continue", a slash command) keeps the scope; a follow-up merges; any other prompt replaces it. A subagent's task prompt is not your prompt and never sets the scope. The prompt text is stored only as a sha256 hash.
mode: "log" (the default) computes the same and writes it to the decision log without changing any decision. status shows "N actions would have been asked by task scope" and explain <log seq> shows it per decision. Widening takes your approval passphrase: launchsafe-firewall scope widen <session> --path P | --repo R | --host H | --intent I. A widening is a signed event; an unsigned or forged one makes the session untrusted and is ignored.
Package checks (packageIntel)
Each key may be set on its own. The malicious check needs the local snapshot (launchsafe-firewall update); without it the check is skipped and doctor warns. A version listed in a range the firewall cannot compare, or an install without an exact version of a partly-listed package, asks a person. A project file may only tighten.
The sandbox profile
launchsafe-firewall doctor --fix writes Claude Code's sandbox settings key (and nothing else) from this policy:
network.allowedDomainsisfetchAllowHostsplussendAllowHosts, converted to the sandbox's syntax. Raw IP addresses, loopback, cloud metadata endpoints, capture and tunnel hosts and anything indenyHostsare never added, even when listed.network.deniedDomainsisdenyHosts.enabled: true,allowUnsandboxedCommands: false,autoAllowBashIfSandboxed: false,excludedCommands: [],network.allowLocalBinding: true(local dev servers),network.allowUnixSockets: [](the Docker socket is deliberately not allowed), andnetwork.strictAllowlist: trueat user scope whensandbox.strictAllowlistis on.- Settings already in the file that weaken the sandbox are removed, and the plan says so.
The sandbox sees hosts, not methods. doctor labels each host fetch-only (from fetchAllowHosts only) or send (in sendAllowHosts), and upload-capable when it accepts uploads over HTTPS even when it is only used for fetching (GitHub, GitLab, the npm registry, PyPI, GHCR, Docker Hub). sendAllowHosts is empty by default, so the default profile holds only fetch hosts. Hosts named in a prompt (per session) are never added. To reach another host from inside the sandbox, add it to fetchAllowHosts and run doctor --fix again.
With protectInstalledProfile, once a profile is installed, an agent change that weakens it is denied (FW-TAMPER) in any session: switching the sandbox off, turning allowUnsandboxedCommands or autoAllowBashIfSandboxed back on, adding to excludedCommands, allowUnixSockets or filesystem.allowWrite, adding a host outside this policy, turning off the strict allowlist, or removing the sandbox key. Narrowing is an ordinary settings change (FW-AGENT-SETTINGS).
The protection score
doctor scores how well the firewall is protecting this machine. A check that does not apply here (such as a cooldown without a package manager) is left out of the denominator.
| Check | Weight | Full points when |
|---|---|---|
| Hooks installed, first, fail-closed | 25 | Every hooks check is ok |
| Approval passphrase pinned in the hook command | 10 | The pinned key matches (5 when set but not pinned) |
| Managed settings register the firewall | 10 | Yes |
| Sandbox | 15 | sandbox.enabled in the effective settings |
| Sandbox network | 10 | An allowlist within the policy's hosts (5 with hosts outside it) |
| Sandbox escape | 5 | allowUnsandboxedCommands false, no unsafe excludedCommands, no Docker socket, nothing weakened |
| Sandbox hooks | 5 | A probe write in the firewall's state directory works |
| Package cooldown | 5 | Configured for each detected manager |
| Malicious-package data | 5 | Snapshot 7 days old or less |
| Canary files | 5 | At least one decoy planted, registry valid |
| Decision log | 5 | Verifies |
doctor --fix is never read-only (an agent running it is denied). It checks for a terminal and that no Claude Code session started it, reads the approval passphrase, shows the diff and asks for "yes"; then it backs up the file, writes it atomically (only the sandbox key changes) and records the exact sandbox object. An unknown sandbox key or an unreadable settings file makes it refuse. --dry-run prints everything and asks nothing.
Desktop notifications
A local native notification (macOS osascript, Linux notify-send with a graphical session) tells you when something is waiting. It informs and never approves: it has no buttons, and approving needs launchsafe-firewall queue review in your terminal with your passphrase. The text is a fixed sentence plus a count; tool input, paths, URLs and the agent's words never reach it. The helper is only ever looked up in fixed system paths, never through PATH.
enabled: master switch. On by default.on: the kinds that notify.queued(an action held for review),proposal(a held change to the agent's instructions),denied_dlp(a credential or personal data blocked from leaving),canary,loop,agent_config,mcp_changed,mcp_withheld(the gateway withheld an MCP result) andmail_quarantined(the email guard withheld a message).onlyWhenUnattended: when true, an attended session never notifies.minIntervalSec(10 to 86400, default 120): at most one notification per kind in this time; events in between are counted into the next one.denied_dlpandcanaryskip this but never fire more than once per 10 s.
If the helper cannot start, the decision is unaffected and one notify_failed entry a day is written to the decision log.
Related policy keys
The vault, gateway, emailGuard and phone keys, with their defaults and what a project may do, are on their own pages: Vault, MCP gateway, Email guard and Phone approvals. The run keys are on Run and sandbox.