clester
End-to-end testing tool for clash. Simulates Claude Code hook invocations and CLI commands against the clash binary, then asserts on the results.
Quick Start
Writing Tests
Test scripts are YAML files in clester/tests/scripts/. Each script defines an isolated test environment (policy, settings), a sequence of steps (hook invocations or CLI commands), and expected outcomes.
Minimal Example
meta:
name: git commands are allowed
clash:
policy_sexpr: |
(default deny "main")
(policy "main"
(allow (exec "git" *)))
steps:
- name: git status is allowed
hook: pre-tool-use
tool_name: Bash
tool_input:
command: git status
expect:
decision: allow
- name: npm is denied
hook: pre-tool-use
tool_name: Bash
tool_input:
command: npm install
expect:
decision: deny
Script Format
meta
Required. Test metadata.
meta:
name: human-readable test name # required
description: optional details # optional
clash
Optional. Configures the clash policy for the test. Use policy_sexpr for s-expression policies (current format).
clash:
policy_sexpr: |
(default deny "main")
(policy "main"
(allow (exec "git" *))
(deny (exec "git" "push" *))
(allow (fs read (subpath "/tmp")))
(allow (net "github.com")))
settings
Optional. Configures Claude Code settings files at user, project, and project-local levels.
settings:
user: # ~/.claude/settings.json
permissions:
allow:
deny:
project: # .claude/settings.json
permissions:
deny:
project_local: # .claude/settings.local.json
permissions:
allow:
steps
Required. An ordered list of steps. Each step is either a hook step (simulates a Claude Code hook invocation) or a command step (runs a clash CLI command). Every step must have exactly one of hook or command.
Step Types
Hook Steps
Simulate Claude Code calling clash via hooks. Use these to test policy evaluation — whether a given tool invocation would be allowed, denied, or asked.
- name: descriptive step name
hook: pre-tool-use # hook type (see below)
tool_name: Bash # Claude Code tool name
tool_input: # tool-specific input
command: git status
expect:
decision: allow # expected policy decision
Hook types:
| Hook | Purpose |
|---|---|
pre-tool-use |
Evaluate policy before a tool runs (returns allow/deny/ask) |
post-tool-use |
Notify after a tool runs (informational, no decision) |
permission-request |
Handle a permission prompt (returns allow/deny/ask) |
notification |
Handle a notification event (informational) |
Tool names and inputs:
# Bash — command execution
tool_name: Bash
tool_input:
command: git status
# Read — file reading
tool_name: Read
tool_input:
file_path: /etc/passwd
# Write — file writing
tool_name: Write
tool_input:
file_path: /tmp/output.txt
content: hello world
# Edit — file editing
tool_name: Edit
tool_input:
file_path: /tmp/file.txt
old_string: foo
new_string: bar
# WebFetch — HTTP requests
tool_name: WebFetch
tool_input:
url: "https://github.com"
# WebSearch — web search
tool_name: WebSearch
tool_input:
query: "test query"
Command Steps
Run clash CLI commands directly. Use these to test interactive policy modification — adding, removing, or inspecting rules mid-test.
- name: add an allow rule
command: policy allow '(exec "npm" *)'
expect:
exit_code: 0
The command value is the arguments to clash (not including clash itself). It's parsed with shell-style quoting, so single quotes work for s-expressions.
Common commands:
# Add rules
command: policy allow '(exec "npm" *)'
command: policy deny '(exec "git" "push" *)'
command: policy allow '(fs read (subpath "/tmp"))'
# Remove rules
command: policy remove '(allow (exec "npm" *))'
# Preview without persisting
command: policy allow '(exec "npm" *)' --dry-run
# Inspect the policy
command: policy list
command: policy explain bash "git push"
command: status
Assertions
Every step has an expect block. All fields are optional — only specified fields are checked.
expect:
decision: allow # expected policy decision: "allow", "deny", or "ask"
exit_code: 0 # expected process exit code (default: not checked)
no_decision: true # expect no hook-specific output (for post-tool-use/notification)
reason_contains: "policy" # substring match on the decision reason
stdout_contains: "(allow" # substring match on stdout
stderr_contains: "warning" # substring match on stderr
| Field | Use with | Purpose |
|---|---|---|
decision |
hook steps | Check the policy decision (allow/deny/ask) |
exit_code |
both | Check the process exit code |
no_decision |
hook steps | Verify no decision was returned (informational hooks) |
reason_contains |
hook steps | Substring match on the decision reason |
stdout_contains |
command steps | Substring match on stdout |
stderr_contains |
command steps | Substring match on stderr |
Patterns
Testing static policies
Set up a policy and verify tool invocations are evaluated correctly:
clash:
policy_sexpr: |
(default deny "main")
(policy "main"
(allow (exec "git" *))
(deny (fs read "/etc/passwd")))
steps:
- name: git is allowed
hook: pre-tool-use
tool_name: Bash
tool_input:
expect:
- name: reading /etc/passwd is denied
hook: pre-tool-use
tool_name: Read
tool_input:
expect:
Testing interactive policy changes
Modify the policy mid-test and verify the changes take effect:
clash:
policy_sexpr: |
(default deny "main")
(policy "main"
(allow (exec "git" *)))
steps:
# Baseline
- name: npm is denied
hook: pre-tool-use
tool_name: Bash
tool_input:
expect:
# Modify policy
- name: allow npm
command: policy allow '(exec "npm" *)'
expect:
# Verify change took effect
- name: npm is now allowed
hook: pre-tool-use
tool_name: Bash
tool_input:
expect:
Testing dry-run (no side effects)
Verify that --dry-run previews changes without persisting them:
steps:
- name: dry-run shows the new rule
command: policy allow '(exec "npm" *)' --dry-run
expect:
exit_code: 0
stdout_contains: "(allow (exec \"npm\" *))"
- name: npm is still denied (dry-run didn't persist)
hook: pre-tool-use
tool_name: Bash
tool_input:
expect:
Testing deny overrides allow
Verify that deny rules take precedence:
clash:
policy_sexpr: |
(default deny "main")
(policy "main"
(allow (exec "git" *)))
steps:
- name: git push is allowed (baseline)
hook: pre-tool-use
tool_name: Bash
tool_input:
expect:
- name: deny git push
command: policy deny '(exec "git" "push" *)'
expect:
- name: git push is now denied
hook: pre-tool-use
tool_name: Bash
tool_input:
expect:
- name: git status is still allowed
hook: pre-tool-use
tool_name: Bash
tool_input:
expect:
How It Works
Each test script runs in an isolated environment:
/tmp/clester-XXXXX/
├── home/ <- $HOME for the test
│ ├── .claude/
│ │ └── settings.json <- from settings.user
│ └── .clash/
│ └── policy.sexpr <- from clash.policy_sexpr
└── project/ <- cwd for the test
├── .claude/
│ ├── settings.json <- from settings.project
│ └── settings.local.json
└── .git/ <- so clash finds the project root
HOMEis set to the temphome/directory, so clash reads/writes$HOME/.clash/policy.sexprin isolation.CLASH_CONFIGandCLASH_POLICY_FILEare removed to prevent system config from leaking in.- Command steps that modify the policy (e.g.,
policy allow) write to the samepolicy.sexprfile that subsequent hook steps read from — this is how interactive policy changes are tested. - The temp directory is cleaned up when the test finishes.
Running
# Run all tests
# Run a single test
# Run with verbose output
# Validate scripts without executing