# pi-ast-guard

[![npm](https://img.shields.io/npm/v/pi-ast-guard)](https://www.npmjs.com/package/pi-ast-guard)

**Languages:** [English](README.en.md) | [简体中文](README.md)

## Introduction

`pi-ast-guard` (formerly `pi-damage-control`) is an AST-based safety guard extension for the [Pi](https://pi.dev) coding agent. It intercepts destructive shell commands and file operations before they execute, while avoiding false positives from plain text, markdown, heredocs, and task descriptions.

> **Inheritance note:** This project inherits from [`pi-damage-control`](https://github.com/ghoseb/pi-damage-control) (originally by Baishampayan Ghose; the original repository has been removed). It continues maintenance and improvements while keeping the original AST parsing approach and policy engine.

## Installation

```bash
pi install npm:pi-ast-guard
# or
# pi install git:github.com/rainmanhhh/pi-ast-guard
```

## DNA Mode (Do Not Ask)

Tired of confirmation dialogs on every operation? Enter DNA mode with `/ag:dna`: `ask` actions are no longer prompted — they are **auto-approved or denied according to the policy**; `block` rules still intercept as usual, so the safety floor is never dropped.

Auto-answers are driven by settings under `settings.dna`: read/write inside/outside (4 params), tool allowlist/blocklist, and a max-violations limit.

| Setting | Default | Meaning |
|---------|---------|---------|
| `dna.readInside` | `allow` | Reads inside the workspace |
| `dna.readOutside` | `allow` | Reads outside the workspace (read-only, low risk) |
| `dna.writeInside` | `allow` | Writes/deletes/moves inside the workspace |
| `dna.writeOutside` | `block` | Writes/deletes/moves outside the workspace (high risk, denied by default) |
| `dna.maxViolations` | `3` | Total accumulated auto-denials in this DNA session (all rules incl. tool allow/block list); hitting it forces an abort and resets all counts (also reset when the conversation ends) |
| `dna.allowTools` | `[]` | Tool allowlist (non-empty enables allowlist mode, only these tools can be called) |
| `dna.blockTools` | `[]` | Tool blocklist (used when allowlist is empty, blocks these tools) |

> Workspace = cwd + `settings.extraDirs`. Command-level `ask` (e.g. publish commands) is allowed by default in DNA mode; auto-denied operations abort the current turn and are recorded in the decision log.

## How It Works

- Parses Bash command ASTs with `just-bash` — **no regex fallback**
- Evaluates semantic command rules from `config/default-policy.yaml`
- Extracts file operation intents from Bash commands and Pi file tools
- Applies path policies for zero-access, read-only, no-delete, and out-of-project writes
- Shows a confirmation dialog when a policy action is `ask`; denies automatically when no UI is available (fail-closed)

## Configuration

Create a project-level policy file:

```text
.pi/ast-guard.yml
```

Or a global policy file:

```text
~/.pi/agent/ast-guard.yml
```

Policies are layered: the global policy (`~/.pi/agent/ast-guard.yml`) forms the base layer (falling back to the bundled default policy when missing), and the project policy (`.pi/ast-guard.yml`) merges on top when present — `settings` are overridden field-wise, `rules` are overridden by `id` (project wins on duplicates), and rules with different ids are all kept.

Bundled default policy: [`config/default-policy.yaml`](./config/default-policy.yaml)

### Policy Language

Top-level policy structure:

```yaml
settings:
  language: auto
  parseFailure: ask
  showStatus: true
  # Extra workspace dirs: together with cwd they form the "complete workspace"; outsideWorkdir: true means "outside the workspace dir list"
  extraDirs: []
  # DNA mode (Do Not Ask): auto-answers for ask actions
  dna:
    readInside: allow
    readOutside: allow
    writeInside: allow
    writeOutside: block
rules: []
```

`settings.language` controls the extension UI language (`zh` Chinese / `en` English / `auto` follow the system, default `auto`) — notifications, blocked/confirmation dialogs, command descriptions, and default-policy rule reasons. `auto` detects the system locale (system region settings on Windows; `LANG`/`LC_ALL` on Unix-like systems, falling back to English). It follows the project policy and takes effect after `/ag:status` (reload merged in).

#### Path Rules

Path rules live in the top-level `rules` list alongside command rules. The `type` field encodes the path policy subtype:

- `path:zeroAccess` — blocks reads, writes, deletes, and moves
- `path:readOnly` — blocks writes, deletes, and moves; reads allowed
- `path:noDelete` — blocks deletes and moves only

Example path rule:

```yaml
- id: path-secrets-env
  type: path:zeroAccess
  action: block
  reason: environment files may contain secrets
  match:
    path:
      any: .env*
      except: [.env.example]
```

`match.path.any` can be a string or a list of strings:

```yaml
match:
  path:
    any: [LICENSE, LICENSE.*, COPYING, COPYING.*]
```

Supported path patterns (glob semantics: `*` matches within a single path segment, `**` matches any depth):

- Exact/filename: `README.md`, `.env` (slash-less patterns match the same name **at any location**, e.g. `ast-guard.yml` also matches `~/.pi/ast-guard.yml`)
- Directory: `.git/`, `node_modules/` (the directory itself and everything under it)
- Wildcards: `*.pem`, `docker-compose.*.yml`, `dist/**`, `**/secrets/**` (segment-aware; `**/secrets/**` matches any path containing a `secrets` segment at any depth)
- Prefix: `build-*` (trailing `*`)
- Current working directory macros: `$CWD`, `$CWD/…`, `!$CWD` (outside the workspace)
- Relative patterns resolve against the workspace root (cwd); `~` resolves to HOME

Out-of-project write confirmation example:

```yaml
- id: path-outside-project-write
  type: path:readOnly
  reason: writes outside the workspace require confirmation
  match:
    path:
      outsideWorkdir: true
      except: [/tmp/, /dev/null]
```

#### Command Rules

Command rules use `type: command`:

```yaml
- id: git-reset-hard
  type: command
  action: block
  reason: git reset --hard discards worktree changes
  match:
    command: git
    subcommand: reset
    flags:
      any: [--hard]
```

Common match fields:

- `command` — exact command name
- `commandAny` — one of several command names
- `subcommand` — first non-option command operand
- `subcommandAny` — one of several subcommands
- `argsAny` — **any one** of the listed args must match
- `argsAll` — **all** listed args must match
- `argsNone` — none of the listed args may appear
- `argsContainAny` / `argsContainAll` — substring matching on args
- `flags.any` / `flags.all` / `flags.none` — semantic flag matching
- `optionsBeforeSubcommand.value` — global option values before subcommand detection, for `git -C repo ...`
- `visibleTextAny` / `visibleTextAll` / `visibleTextNone` — matches visible static text, for SQL executors

Rules are enabled by default. The **Allow this session** choice in the ask dialog temporarily disables the triggered rules for the current session.

Rules accept an optional `priority` (default `0`, higher wins): when multiple rules match, only the highest-priority group takes effect and is resolved by action strength (`block > ask > allow`), so a high-priority `allow` rule can exempt lower-priority `ask`/`block` rules. Source offsets: project `0` / home `-0.3` / bundled default `-0.6`.

Actions:

- `allow` — allow
- `ask` — ask for confirmation
- `block` — block directly

`action` is optional; when omitted it defaults to `ask`.

Rules disabled by default never trigger checks (enable them by editing the config):

```yaml
- id: git-commit
  type: command
  action: ask
  reason: git commit needs confirmation
  match:
    command: git
    subcommand: commit
```

Complete example:

```yaml
settings:
  parseFailure: ask
  showStatus: true
rules:
  - id: path-secrets-env
    type: path:zeroAccess
    action: block
    reason: environment files may contain secrets
    match:
      path:
        any: .env*
        except: [.env.example]
  - id: path-outside-project-write
    type: path:readOnly
    reason: writes outside the workspace require confirmation
    match:
      path:
        outsideWorkdir: true
        except: [/tmp/, /dev/null]
  - id: git-commit
    type: command
    action: ask
    reason: git commit needs confirmation
    match:
      command: git
      subcommand: commit
```

## Ask Dialog

When an action evaluates to `ask`, a four-way selection dialog is shown (30s timeout, fail-closed):

- **Allow once** — allow only this tool call (one-shot; does not affect the next ask)
- **Allow this session** — recorded as a session-level decision (decision layer); the rule is no longer asked about within the scope in this session
- **Deny this session** — recorded as a session-level decision (decision layer); the rule is blocked silently within the scope for the rest of the session (no dialog)
- **Deny Once** — block this call (one-shot; does not affect the next ask)

**Session-level decisions are an exact-path matching layer on top of rule matching**: each decision is `(rule, scope path)`; deny takes priority over allow (regardless of recency); a newer decision for the same (rule, scope) overrides the older one, while different scopes accumulate.

**Denials (Deny this session / Deny Once), cancelled or timed-out dialogs, and auto-denied no-UI cases all abort the current turn** (the agent stops and pi returns to waiting for user input); the block message is still delivered to the AI as the tool result.

If the dialog is cancelled or times out, the call is blocked. Without UI (print/JSON mode), ask decisions are blocked automatically.

### Scope input (coarse-grained rules)

When a rule's match domain extends beyond a workspace-anchored local area (coarse-grained), picking "Allow this session" / "Deny this session" pops an **additional input dialog** to determine the scope:

| Input | Scope |
|-------|-------|
| (empty) | The triggering target file only (its parent directory must exist, so not-yet-created files are fine) |
| `.` | The target file's directory |
| `../..` | From two levels above the target file's directory (relative inputs resolve against the target file's directory) |
| Absolute path | Use that path as the scope directly |

Input is validated: wildcards are rejected; **the scope must intersect the rule's match domain** (e.g. an `outsideWorkdir` rule rejects absolute paths inside the workspace, `/etc/**` rejects `/var`); directory scopes must really exist; on failure the user is prompted again. Cancelling the input aborts the turn (fail-closed).

Coarse-grained classification (any match triggers the input):

- `outsideWorkdir: true`
- Root-anchored absolute patterns with ≤ 2 literal segments after resolution (e.g. `/etc/**`, `/var/log/`; `/home/user/data/**` with depth ≥ 3 counts as fine-grained)
- First segment is a wildcard or the pattern covers the workspace or above (`.` , `./**`, `..`, `../**`, `**/…`, `*/…`)

Slash-less patterns (`ast-guard.yml`, `*.log`) do **not** pop the input: picking the session choice automatically scopes to the triggering target file (the same name elsewhere still asks next time). Command-only rules (no path to anchor) and fine-grained path rules keep rule-level decisions (covering all paths of the rule).

### Recent decisions & clearing by index

**Session-level decisions are recorded in the "Recent decisions" section of the `/ag:status` panel** (last 10 entries), each row numbered (newest first = 1):

```
Recent decisions
1. 14:32 Allow this session → outside-ask → C:/tmp/x.log (touch C:/tmp/x.log)
2. 14:31 Deny this session → git-commit (git commit)
```

One-shot decisions do not affect the next ask and are not shown. Run `/ag:forget 1,3` to clear single decisions by index (comma-separated for multiple); indices are renumbered after clearing and when new decisions arrive — run `/ag:status` first to see the current numbering.

## Available Commands

| Command | Description |
|---------|-------------|
| `/ag:forget <indices...>` | Clear session decisions by index (comma-separated, e.g. `1,3`); indices are renumbered afterwards |
| `/ag:dna` | Enable/disable Do Not Ask mode |
| `/ag:status` | Reload the policy and show the status panel (incl. numbered "Recent decisions"); run again to refresh |

## Development

Uses [Bun](https://bun.sh) as the package manager:

```bash
bun install
bun run test        # unit tests (Vitest)
bun run test:watch  # watch mode
bun run typecheck   # TypeScript type checking
bun run check       # full check (lint + tests + types)
bun run bench       # mitata benchmarks
```

Tests live in `tests/`, organized by module (`bash/`, `rules/`, `policy/`, `engine/`, `extension/`, `intents/`). Known issues and follow-ups: [docs/known-issues.md](docs/known-issues.md).

### Local Development & Verification

Point `~/.pi/agent/settings.json` directly at the source (changes take effect immediately):

```jsonc
{
  "extensions": ["E:/workspace/pi-ast-guard/src/index.ts"]
}
```

Then run `pi` in any test project — a 🛡 status-bar icon means the extension loaded. Commits run `bun run lint` automatically via the pre-commit hook; run `bun run check` locally to pass it. The `ast-guard-demo` sandbox (with `.env`, `dist/`, a custom policy, and a full verification checklist) is available for manual testing.

## Credits

This project inherits from [pi-damage-control](https://github.com/ghoseb/pi-damage-control) (originally by Baishampayan Ghose; the original repository has been removed). Inspired by [claude-code-damage-control](https://github.com/disler/claude-code-damage-control).

## License

MIT © rainmanhhh
