Reference
Everything you need to write clash policies. Policies are written in Starlark (.star), the only source format clash loads.
Effects
Every rule ends with an effect:
- allow — auto-approve the action
- deny — block the action
- ask — prompt the user for confirmation
policy("default", {
"Bash": {
"git": allow(),
"git": {"push": deny()},
"git": {"commit": ask()},
},
})
First match wins. Rules are evaluated in order — the first matching rule determines the effect. Put specific rules (like denies) before broad ones (like allows).
Domains
Clash matches rules across three domains. A single rule can cover multiple tools.
Exec — shell commands
policy("default", {
"Bash": {
"git": allow(),
"git": {"push": deny()},
"cargo": {"test": allow()},
("cargo", "rustc"): allow(), # multiple binaries
},
})
Policy dicts build rules by nesting tool names, binary names, and subcommands. Deeper nesting = more specific matching.
Scope: Exec rules evaluate the top-level command the agent invokes. They do not apply to child processes spawned by that command. Sandbox restrictions on filesystem and network access are enforced on all child processes at the kernel level.
Command trees
For commands with many subcommands, nest dicts and use tuple keys:
policy("default", {
"Bash": {
"git": {
"push": deny(),
("pull", "fetch"): allow(),
"remote": {
"add": ask(),
},
},
},
})
Typed match keys: Mode() and Tool()
Use Mode() to apply different rules based on the agent's current permission mode (e.g. plan mode vs code mode). Use Tool() as an explicit alternative to raw strings for tool names:
load("@clash//std.star", "allow", "deny", "Mode", "Tool", "policy")
policy("default", {
Mode("plan"): {
Tool("Read"): allow(),
Tool("ExitPlanMode"): allow(),
},
Tool("Bash"): {"git": allow()},
"WebSearch": deny(),
})
Fs — file operations
File access for agent tools is controlled via sandboxes. Attach a sandbox to tool rules:
load("@clash//std.star", "allow", "deny", "policy", "sandbox", "subpath")
project_sandbox = sandbox(
name = "project",
default = deny(),
fs = {
subpath("$PWD", follow_worktrees = True): allow("rwc"), # project dir, worktree-aware
"$HOME/.ssh": allow("r"), # read-only ~/.ssh
},
)
policy("default", {
("Read", "Glob", "Grep"): allow(sandbox = project_sandbox),
("Write", "Edit"): allow(sandbox = project_sandbox),
})
The fs dict in a sandbox maps path strings (or subpath() for worktree support) to capabilities. The fs domain maps to agent tools: Read / Glob / Grep → fs read, Write / Edit → fs write.
Net — network access
domains({"github.com": allow()})
domains({"github.com": allow(), "crates.io": allow()})
The net domain maps to: WebFetch → net with the URL's domain, WebSearch → net with wildcard domain.
Tool — agent tools
policy("default", {
"WebSearch": deny(),
("Read", "Glob", "Grep"): allow(),
("Skill", "Agent"): allow(),
})
Use tool names directly as dict keys to control agent tools by name. This works for any tool, including those that don't map to exec/fs/net capabilities (e.g., Skill, Agent).
Patterns
In the compiled match tree, patterns are used inside condition nodes to match against observable values.
Wildcard
"wildcard" matches anything:
{ "condition": { "observe": "tool_name", "pattern": "wildcard", "children": [{ "decision": { "allow": null } }] } }
Literal
{ "literal": <value> } matches a resolved value exactly:
{ "condition": { "observe": { "positional_arg": 0 }, "pattern": { "literal": { "literal": "git" } }, "children": [...] } }
{ "condition": { "observe": "tool_name", "pattern": { "literal": { "literal": "Bash" } }, "children": [...] } }
Regex
{ "regex": "pattern" } for flexible matching:
{ "condition": { "observe": { "positional_arg": 0 }, "pattern": { "regex": "^cargo-.*" }, "children": [...] } }
Combinators
{ "any_of": [...] } matches any sub-pattern. { "not": <pattern> } negates:
{ "condition": { "observe": "tool_name", "pattern": { "any_of": [
{ "literal": { "literal": "Read" } },
{ "literal": { "literal": "Glob" } },
{ "literal": { "literal": "Grep" } }
] }, "children": [{ "decision": { "allow": null } }] } }
Values
Values appear inside Literal patterns and are resolved at eval time:
| Form | JSON | Description |
|---|---|---|
| Literal string | { "literal": "git" } |
A constant string value |
| Environment var | { "env": "HOME" } |
Resolved from environment at eval time |
| Path join | { "path": [{ "env": "HOME" }, { "literal": ".ssh" }] } |
Segments joined with / |
Precedence
Rules use first-match semantics: the first matching rule wins. Order matters — put specific rules before broad ones.
Example:
policy("default", {
"Bash": {
"git": {
"push": deny(),
glob("**"): allow(),
},
},
})
git push origin main matches the deny first (listed first, matches). git status skips the deny (doesn't match "push") and matches the wildcard allow.
If the rules were reversed, git push would match the allow first and the deny would never fire.
When a request matches rules in multiple domains, deny-overrides applies across domains: deny > ask > allow.
Policy composition
In Starlark, break policies into reusable pieces using variables and merge():
# ~/.clash/safe_git.star
load("@clash//std.star", "allow", "ask", "deny", "glob")
safe_git_rules = {
"Bash": {
"git": {
"push": deny(),
"reset": deny(),
"commit": ask(),
glob("**"): allow(),
},
},
}
# ~/.clash/policy.star
load("@clash//std.star", "allow", "deny", "domains", "merge", "policy", "sandbox", "subpath")
load("safe_git.star", "safe_git_rules")
project_sandbox = sandbox(
name = "project",
default = deny(),
fs = {subpath("$PWD", follow_worktrees = True): allow("rwc")},
)
settings(default = deny())
policy("default", merge(
{("Read", "Write", "Edit", "Glob", "Grep"): allow(sandbox = project_sandbox)},
safe_git_rules,
domains({"github.com": allow(), "crates.io": allow()}),
))
Starlark load() imports values from other .star files. All composition (function calls, merge(), imports) resolves at compile time.
One format: .star for everything
Clash policies are written in Starlark. The same .star file is edited by humans and mutated by CLI commands like clash policy allow, clash policy deny, and clash policy remove, which round-trip the file using a Starlark AST formatter so hand-written structure is preserved.
Migrating from policy.json
Earlier versions of clash supported a JSON-based policy.json format. To migrate:
clash policy convert # writes policy.star alongside policy.json
clash policy convert --replace # also removes the old policy.json
After conversion, only policy.star is loaded.
Updating policies
The update() method combines two policies. In a.update(b), b's default effect is used, tree nodes from both are concatenated (a's first, then b's), and sandboxes are merged (first defined wins on name conflicts).
load("@clash//builtin.star", "base")
load("@clash//std.star", "allow", "deny", "domains", "merge", "policy", "sandbox", "subpath")
project_sandbox = sandbox(
name = "project",
default = deny(),
fs = {subpath("$PWD", follow_worktrees = True): allow("rwc")},
)
settings(default = deny())
policy("default", merge(
{("Read", "Write", "Edit", "Glob", "Grep"): allow(sandbox = project_sandbox)},
{"Bash": {"git": allow()}},
domains({"github.com": allow()}),
))
Built-in policy (@clash//builtin.star)
The builtins export from @clash//builtin.star bundles rules for:
- Clash CLI — allows
clash status,clash policy list/show/explain, andclash bugwith appropriate sandboxes - Claude Code tools — allows interactive tools (
Agent,Skill,AskUserQuestion,ToolSearch, etc.) with a sandbox scoped to~/.claude
Merge with builtins to get sensible defaults. If you don't, you'll need your own rules for these tools.
Sandbox policies
Allowed exec rules can carry a sandbox that constrains what the spawned process can access at the kernel level (Landlock on Linux, Seatbelt on macOS).
Defining a sandbox
In Starlark, use the sandbox() builder and pass it to allow() / deny() / ask() via the sandbox keyword:
load("@clash//std.star", "allow", "deny", "policy", "sandbox")
cargo_env = sandbox(
name = "cargo",
default = deny(),
fs = {"$PWD": allow("rwc")},
net = allow(),
)
settings(default = deny())
policy("default", {
"Bash": {"cargo": allow(sandbox = cargo_env)},
})
What sandboxes enforce
Sandbox restrictions on filesystem and network access are inherited by all child processes and cannot be bypassed. However, sandboxes do not enforce exec-level argument matching on child processes.
Sandbox network modes
net = allow()in a sandbox — allows all network accessnet = [domains({"localhost": allow()})]— localhost-only, enforced at the kernel levelnet = [domains({"domain.com": allow()})]— domain-filtered via local HTTP proxynet = deny()or omitted — denies all network access
Policy settings
The settings() function configures global policy behavior. It is optional; defaults apply when omitted.
settings(default=deny(), on_sandbox_violation="stop")
default
The default effect when no rule matches. Accepts allow(), deny(), or ask(). Defaults to "deny".
default_sandbox
A sandbox to apply by default to all shell command rules that do not specify their own sandbox.
on_sandbox_violation
Controls model behavior when a sandbox blocks an operation. Added as a parameter to settings():
settings(default=deny(), on_sandbox_violation="stop")
Values:
"stop"(default) — Tell the model to stop and suggest a policy fix. Don't retry."workaround"— Tell the model to try an alternative approach. If no workaround is possible, suggest the policy fix."smart"— Let the model assess context to decide whether to suggest a fix or find an alternative.
harness_defaults
Controls whether clash automatically injects rules that allow the agent to access its own infrastructure directories. Defaults to True.
When enabled, clash injects rules at the lowest priority (after all user-defined rules) to allow access to:
| Path | Permissions | Purpose |
|---|---|---|
~/.claude/ |
read, write, create, delete | Memories, settings, plugin cache, skills |
<project>/.claude/ |
read only | Project config |
<transcript_dir>/ |
read, write, create, delete | Session transcripts |
Your rules always take precedence over harness defaults.
settings(harness_defaults=False) # disable harness defaults
Or via environment variable: CLASH_NO_HARNESS_DEFAULTS=1.
clash status hides harness rules by default and shows a count. Use clash status --verbose to see them tagged with [harness].
Common recipes
Conservative (untrusted projects)
load("@clash//std.star", "allow", "deny", "policy", "sandbox", "subpath")
readonly_sandbox = sandbox(
name = "readonly",
default = deny(),
fs = {subpath("$PWD", follow_worktrees = True): allow("r")},
)
settings(default = deny())
policy("default", {
("Read", "Glob", "Grep"): allow(sandbox = readonly_sandbox),
})
Developer-friendly
load("@clash//std.star", "allow", "ask", "deny", "domains", "merge", "policy", "sandbox", "subpath")
project_sandbox = sandbox(
name = "project",
default = deny(),
fs = {subpath("$PWD", follow_worktrees = True): allow("rwc")},
)
settings(default = ask())
policy("default", merge(
{("Read", "Write", "Edit", "Glob", "Grep"): allow(sandbox = project_sandbox)},
{
"Bash": {
("cargo", "npm"): allow(),
"git": {
("status", "diff", "log", "add"): allow(),
"commit": ask(),
("push", "reset"): deny(),
},
"sudo": deny(),
"rm": {"-rf": deny()},
},
},
domains({"github.com": allow(), "crates.io": allow(), "npmjs.com": allow()}),
))
Full trust with guardrails
load("@clash//std.star", "allow", "ask", "deny", "policy")
settings(default = allow())
policy("default", {
"Bash": {
"git": {
"push": {"--force": deny()},
"reset": {"--hard": deny()},
},
"rm": {"-rf": deny()},
"sudo": deny(),
},
})
Sandboxed build tools
load("@clash//std.star", "allow", "deny", "domains", "policy", "sandbox", "subpath")
cargo_env = sandbox(
name = "cargo",
default = deny(),
fs = {subpath("$PWD", follow_worktrees = True): allow("rwc")},
net = allow(),
)
npm_env = sandbox(
name = "npm",
default = deny(),
fs = {subpath("$PWD", follow_worktrees = True): allow("rwc")},
net = [domains({"registry.npmjs.org": allow()})],
)
readonly_sandbox = sandbox(
name = "readonly",
default = deny(),
fs = {subpath("$PWD", follow_worktrees = True): allow("r")},
)
settings(default = deny())
policy("default", {
("Read", "Glob", "Grep"): allow(sandbox = readonly_sandbox),
"Bash": {
"cargo": allow(sandbox = cargo_env),
"npm": allow(sandbox = npm_env),
},
})
Policy schema (JSON IR)
JSON IR schema for compiled clash policies. Policies are authored as Starlark (.star) files and compiled to this format.
Document structure
{
"schema_version": 5,
"default_effect": "<effect>",
"sandboxes": { "<name>": <sandbox-policy> },
"tree": [ <node>, ... ]
}
| Field | Type | Description |
|---|---|---|
schema_version |
integer | Internal version identifier |
default_effect |
string | Effect when no rule matches: "allow", "deny", or "ask" |
sandboxes |
object | Named sandbox definitions (may be empty) |
tree |
array | Root-level nodes of the match tree |
Nodes
The tree is a uniform trie of two node types:
Condition
Observe a value from the query context, test against a pattern, recurse into children on match:
{ "condition": { "observe": <observable>, "pattern": <pattern>, "children": [ <node>, ... ] } }
| Field | Type | Description |
|---|---|---|
observe |
observable | What to extract from the query context |
pattern |
pattern | What to test the observed value against |
children |
array of nodes | Evaluated (in order) if the pattern matches |
Decision
A leaf node that produces an effect:
{ "decision": { "allow": null } }
{ "decision": "deny" }
{ "decision": { "ask": null } }
{ "decision": { "allow": "<sandbox-name>" } }
| Form | Description |
|---|---|
{ "allow": null } |
Allow without sandbox |
{ "allow": "<name>" } |
Allow with named sandbox |
"deny" |
Deny |
{ "ask": null } |
Ask the user |
{ "ask": "<name>" } |
Ask the user, with sandbox if approved |
Observables
What to extract from the query context for pattern matching.
"tool_name"
"hook_type"
"agent_name"
"mode"
{ "positional_arg": 0 }
"has_arg"
{ "named_arg": "file_path" }
{ "nested_field": ["input", "url"] }
| Observable | JSON | Description |
|---|---|---|
| Tool name | "tool_name" |
The agent tool being invoked (e.g. "Bash", "Read") |
| Hook type | "hook_type" |
The hook event type |
| Agent name | "agent_name" |
The agent identifier |
| Mode | "mode" |
The agent's current permission mode (e.g. "plan", "code") |
| Positional arg | { "positional_arg": N } |
Nth positional argument (0-indexed) |
| Has arg | "has_arg" |
True if any positional arg matches the pattern |
| Named arg | { "named_arg": "key" } |
Value of a named argument |
| Nested field | { "nested_field": ["a", "b"] } |
Path into structured tool_input JSON |
Evaluation
Evaluation is a single DFS pass over the tree:
- For each node in children (in order):
- Decision: return the decision immediately
- Condition: extract the observable value from the query context, test against the pattern. If it matches, recurse into children. If a child produces a decision, return it. Otherwise, backtrack and try the next sibling.
- If no node produces a decision, return the
default_effect.
First-match semantics: the first matching path through the tree wins. Specificity is encoded by sibling order — put more specific conditions before broader ones.