# Policy Evaluation Semantics
How clash compiles and evaluates policies.
---
## Capability Model
Clash operates on three capability domains, not individual tools. Tool invocations are mapped to capability queries at evaluation time:
| `Bash` | `exec` | bin = first non-env-assignment word of command, args = rest |
| `Read` | `fs(read)` | path = `file_path` |
| `Write` | `fs(write)` | path = `file_path` |
| `Edit` | `fs(write)` | path = `file_path` |
| `WebFetch` | `net` | domain extracted from `url` |
| `WebSearch` | `net` | domain = `*` (any) |
| `Glob`/`Grep` | `fs(read)` | path = `path` or `pattern` |
| `Skill`, `Agent`, etc. | `tool` | name = tool name |
| Unknown tools | — | no capability match → default effect |
> **Enforcement scope:** This capability mapping applies to top-level tool calls intercepted by Claude Code hooks. When a Bash command spawns child processes, those child processes are *not* re-evaluated against exec rules — only the top-level command is matched. Filesystem and network restrictions from sandbox policies are enforced at the kernel level and apply to all descendant processes. See [#136](https://github.com/empathic/clash/issues/136) for tracking exec-level enforcement of child processes.
---
## Compilation Pipeline
```
Starlark source (.star) or JSON
│
▼
JSON IR (schema v5) ← clash_starlark (if .star)
│
▼
CompiledPolicy (match tree) ← compile.rs, match_tree.rs
│
├── default_effect: Effect
├── sandboxes: HashMap<String, SandboxPolicy>
└── tree: Vec<Node> (uniform trie structure)
├── Condition { observe, pattern, children }
└── Decision { Allow | Deny | Ask }
```
The match tree is a uniform trie IR where:
- One node type (`Condition`) with an observable + pattern + children
- Capability domains (exec/fs/net) are Starlark compile-time sugar, not IR concepts
- Sandboxes are decoupled and referenced by name
- Evaluation is a single DFS pass with no node visited twice
### Compilation Steps
0. **Evaluate Starlark** (if `.star`) — run `main()` to produce JSON IR via `clash_starlark`
1. **Parse** — JSON IR → `CompiledPolicy` (match tree)
2. **Validate sandbox references** — verify each `SandboxRef` in decision nodes points to an entry in `CompiledPolicy.sandboxes`
3. **Sort by specificity** — children sorted by pattern specificity: `Literal(3) > Regex(2) > AnyOf/Not(1) > Wildcard(0)`, with ties broken by observable specificity
4. **Detect unreachable branches** — warn if a wildcard sibling precedes more specific siblings
Built-in rules for clash CLI commands and Claude Code interactive tools are provided by `@clash//builtin.star` and combined with the user's policy via the `update()` method in Starlark. This keeps the compilation pipeline simple — no implicit policy injection.
---
## Rule Ordering
Within a capability domain, rules use **first-match semantics**: the first rule whose matcher matches the request determines the effect. Rules are evaluated in the order they appear in the policy.
This means **order matters**. Put more specific rules before broader ones:
```python
# Correct: deny matches git push first, allow catches everything else
exe("git", args = ["push"]).deny()
exe("git").allow()
# Wrong: allow matches git push first, deny never fires
exe("git").allow()
exe("git", args = ["push"]).deny()
```
There is no automatic sorting or conflict detection — the policy author controls evaluation order directly.
---
## Evaluation Algorithm
```
evaluate(tool_name, tool_input):
1. Build QueryContext from tool invocation
(tool_name, positional args, tool_input JSON)
2. DFS walk the match tree:
for each node in children:
- Decision: return the decision (allow/deny/ask + optional sandbox ref)
- Condition: extract observable value from context,
test against pattern:
- if matches: recurse into children
- if no child produces a decision: backtrack
- if no match: skip to next sibling
3. If DFS produces no match → return default_effect
4. Resolve sandbox: if decision has SandboxRef,
look up in CompiledPolicy.sandboxes
5. Build PolicyDecision with effect, sandbox, and trace
```
> **Note:** This evaluation runs once per Claude Code tool call via the PreToolUse hook. It does not run for child processes spawned by allowed Bash commands. Child processes inherit kernel-level sandbox restrictions (fs/net) but are not checked against exec rules.
### First-Match Semantics
Within a capability domain, the first matching rule wins. Rules are evaluated in the order they appear in the policy — the author controls precedence through ordering.
### Path Resolution
Relative paths in tool inputs are resolved against the current working directory before matching against path filters. This means `{ "subpath": { "path": { "env": "PWD" } } }` correctly matches both absolute paths under CWD and relative paths.
---
## Decision Trace
Every evaluation produces a `DecisionTrace` recording:
- **matched_rules**: rules where the matcher passed, with their effect and sandbox reference (if any)
- **skipped_rules**: rules that were considered but didn't match, with reason
- **final_resolution**: human-readable summary of how the final effect was determined
This enables the `clash explain` command and structured audit logging.
---
## Sandbox Attachment
Runtime constraints (filesystem and network sandbox policies) are attached to decisions via named sandbox references.
### How It Works
Sandbox definitions are declared at the top level of the policy and referenced by name in `Decision` nodes. When a decision includes a `SandboxRef`, the evaluator looks up the corresponding `SandboxPolicy` from `CompiledPolicy.sandboxes` and attaches it to the `PolicyDecision`.
```python
policy(
sandboxes = [cwd_sb],
rules = [
exe("cargo").allow(sandbox="cwd_access"),
],
)
```
This keeps sandbox definitions decoupled from the decision tree — the same sandbox can be referenced by multiple rules.
### Enforcement
The sandbox policy is enforced at the kernel level:
- **Linux**: Landlock LSM restricts file and network access
- **macOS**: Seatbelt sandbox profiles restrict file and network access
Network enforcement has three tiers:
- **Allow** — unrestricted network access
- **AllowDomains** — a local HTTP proxy enforces domain filtering. The OS sandbox restricts the process to localhost-only connections; the proxy checks each request against the allowlist. On macOS, Seatbelt enforces the localhost restriction at the kernel level. On Linux, seccomp cannot filter `connect()` by destination (pointer argument), so proxy enforcement is advisory for programs that bypass `HTTP_PROXY`/`HTTPS_PROXY`.
- **Deny** — all network access denied at the kernel level
All sandbox policies automatically include read/write/create/delete/execute access to system temp directories, so sandboxed tools (compilers, package managers, etc.) can create temporary files without explicit policy rules. On macOS this covers `/private/tmp` and `/private/var/folders`; on Linux `/tmp` and `/var/tmp`; plus `$TMPDIR` if set to a non-standard location.
### Worktree-Aware Path Expansion
The `{ "subpath": { "path": { "env": "PWD" }, "worktree": true } }` path filter supports git worktrees at compile time. When the resolved path is inside a git worktree, the compiler detects this by reading the `.git` file's `gitdir:` pointer and the `commondir` file, then expands the single `subpath` into an `or` filter covering:
1. The original resolved path
2. The worktree-specific git directory (e.g., `/path/to/repo/.git/worktrees/my-branch`)
3. The shared common directory (e.g., `/path/to/repo/.git`)
This ensures that git commands (commit, push, etc.) work correctly inside worktrees, since git stores its data (objects, refs, config) in the main repository's `.git/` directory outside the worktree's own directory tree.
When the path is not inside a worktree, `"worktree": true` has no effect — the filter compiles to a plain `subpath`. The default policy uses `"worktree": true` on `env PWD` subpath rules for CWD access rules.
Sandbox enforcement covers filesystem and network access only. Exec-level argument matching (e.g., distinguishing `git push` from `git status`) is not enforced on child processes within the sandbox — only the top-level command is checked against exec rules. See [#136](https://github.com/empathic/clash/issues/136) for the tracking issue.
---
## Deny-Overrides Precedence
The deny-overrides principle applies **across capability domains**: if a request matches rules in multiple domains (exec, fs, net, tool), deny > ask > allow.
Within a single domain, **first-match wins** — the policy author controls precedence through rule ordering. To express "deny everything except X", put the allow rule before the deny rule:
```python
# Allow writes under CWD, deny writes everywhere else
cwd(write = allow)
# The default=deny handles everything not matched above
```
See [ADR-002](./adr/002-deny-overrides.md) for the full rationale.