Policy

The policy file: schema, defaults, what a project may tighten, and the sandbox profile

Edit on GitHub

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 with launchsafe-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.json at 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 validate says a migration is available. A version 1 file is read the same way.
  • launchsafe-firewall policy migrate rewrites 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 as policy.json.v2.bak (.v2.2.bak and so on if a backup already exists). An invalid file, or one newer than the firewall, is refused and nothing is written. Like policy init, it needs you at the keyboard.
  • Managed and project files are not rewritten by policy migrate: their owners change "version": 2 to 3. Leave a repository's .launchsafe-firewall.json at 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.

KeyDefaultA project mayUser or managed policy only
userOutputTools[]Nothing (entries are ignored and reported)Add tool names
notUserOutputTools[]Add tool namesNothing
provenance.trustSelfAuthoredtrueSet false (every outside read taints)Set true
provenance.carryUntrustedWritestrueSet trueSet false
provenance.maxHashBytes4194304Lower itRaise it (up to 64 MiB)
agentScan.enabledtrueSet trueSet false
agentScan.taintOn"high"Move towards "high" ("never" < "critical" < "high")Move towards "never"
agentScan.userScopetrueSet trueSet false
mcpPinning.enabledtrueSet trueSet false
mcpPinning.newToolGraceHours24Lower itRaise it (up to 720)
mcpPinning.taintOnChangetrueSet trueSet false
canaries.enabledtrueSet trueSet false
canaries.tripOnRecursivefalseSet trueSet false
canaries.offerAtInstall, canaries.homeDecoystrue, falseNothing (read only from user and managed policy)Set either way
notifications.enabledtrueSet trueSet false
notifications.onAll nine kindsAdd kindsRemove kinds
notifications.onlyWhenUnattendedfalseNothingSet either way
notifications.minIntervalSec120NothingSet it (10 to 86400)
packageIntel.malicious"deny"Move towards "deny" ("off" < "approve" < "deny")Move towards "off"
packageIntel.typosquat"approve"Set "approve"Set "off"
packageIntel.lockfilestrueSet trueSet false
packageIntel.maxSnapshotAgeDays30Lower itRaise it (up to 3650)
sandbox.protectInstalledProfiletrueSet trueSet false
sandbox.strictAllowlisttrueSet trueSet false
sandbox.safeExcludedCommands[]Nothing (ignored and reported)Add command patterns
persistence.trustedContentCheckstrueSet trueSet false
persistence.holdWhenUntrusted["git-hook", "editor-task", "devcontainer"]Add tags (ci, env-hook)Remove tags
budgets.enabledtrueSet trueSet false
budgets.untrusted.*, budgets.trusted.*, budgets.loop.*See the exampleLower a limitRaise 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.unattendedIntentstrueSet trueSet 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 by policy validate).

Cases left as they are, because the managed file can close them by setting the key itself:

  • mcpDefault is 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, in mcpServers.
  • sendAllowHosts and budgets.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, sendAllowHosts and sandbox.safeExcludedCommands feed the sandbox profile; a managed file that sets them decides what the profile may allow.
  • dataAllowHosts overrides the data rules, not an MCP server's entry; a managed dataAllowHosts replaces all four kinds.
  • trustedReadRoots never outranks secretPaths or untrustedPaths, and a host in denyHosts is 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, destructiveTools and blockedTools: override the verb-based classification for specific tools (glob patterns allowed). blockedTools are 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:

ActionWhenOutcome
A write inside the workspace outside every named path (build outputs, caches, lockfiles and scratch are never narrowed)Untrusted, or unattendedApprove
A send to a repository or host the prompt did not name (and not on sendAllowHosts)UntrustedApprove
A push, PR, publish, deploy or message the prompt did not ask forUnattended, even trustedApprove, so held
A new package install the prompt did not ask for (restoring declared dependencies is not one)Unattended, even trustedApprove, so held
A destructive action without a delete verbAnyApprove

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.allowedDomains is fetchAllowHosts plus sendAllowHosts, converted to the sandbox's syntax. Raw IP addresses, loopback, cloud metadata endpoints, capture and tunnel hosts and anything in denyHosts are never added, even when listed.
  • network.deniedDomains is denyHosts.
  • enabled: true, allowUnsandboxedCommands: false, autoAllowBashIfSandboxed: false, excludedCommands: [], network.allowLocalBinding: true (local dev servers), network.allowUnixSockets: [] (the Docker socket is deliberately not allowed), and network.strictAllowlist: true at user scope when sandbox.strictAllowlist is 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.

CheckWeightFull points when
Hooks installed, first, fail-closed25Every hooks check is ok
Approval passphrase pinned in the hook command10The pinned key matches (5 when set but not pinned)
Managed settings register the firewall10Yes
Sandbox15sandbox.enabled in the effective settings
Sandbox network10An allowlist within the policy's hosts (5 with hosts outside it)
Sandbox escape5allowUnsandboxedCommands false, no unsafe excludedCommands, no Docker socket, nothing weakened
Sandbox hooks5A probe write in the firewall's state directory works
Package cooldown5Configured for each detected manager
Malicious-package data5Snapshot 7 days old or less
Canary files5At least one decoy planted, registry valid
Decision log5Verifies

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) and mail_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_dlp and canary skip 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.

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.

On this page