# @iota-policy/pi-extension

**A permission system for pi, in one YAML file.** [pi](https://github.com/badlogic/pi-mono) ships no permission layer by design — your agent runs with your full user account. This extension is that layer: declare what the agent may read, write, run, and it enforces the verdict *before* anything executes, in-process, ~0.2 ms per decision, zero extra tokens. Part of the [iota project](https://github.com/gokalper/iota-policy) — a portable policy format for coding agents.

- `agents.yaml` lives in your repo like `.gitignore`
- Unknown commands prompt you; in CI they auto-deny — no override exists
- Repo policies need your hash-pinned approval (bless); edits show you a diff
- The policy file itself is write-protected from the agent — self-escalation attempts get blocked and logged (we've watched it happen)
- Every decision goes to `.iota/decisions.jsonl` for `jq` debugging

## Install

```bash
pi install npm:@iota-policy/pi-extension    # global; add -l for project-only
```

This is the only package you install into pi. Its dependency `@iota-policy/core` is the policy engine (parser + evaluator) and comes along automatically as a normal npm dependency — it is a library, not a pi extension, and installing it into pi directly will just show as invalid.

Then create a policy (or let your repo's committed one prompt for bless). The simplest path is in-session — launch pi and run the `/iota init` command:

```bash
pi
# inside pi:
/iota init        # scaffolds a starter agents.yaml, then prompts you to bless it
```

If there's no policy, iota also nudges you toward `/iota init` on session start. To scaffold from the shell instead (e.g. in CI), use `npx -p @iota-policy/pi-extension iota-pi init`.

<details>
<summary>Alternative: manual shim without pi's package manager</summary>

```bash
npm install -D @iota-policy/pi-extension
npx iota-pi install     # writes .pi/extensions/iota.ts, gitignores .iota/
```
</details>

## Bless: repo policies need your approval

A repo-committed policy arrives with someone else's code, so it governs nothing until you approve it. On first use, iota shows what the policy grants — with every weak `best_effort` pattern called out — pinned to the file's sha256:

```
iota: bless repo policy?
Repo policy /path/to/agents.yaml
sha256 3f9c2ab41d…

• fs.read: ** — deny: **/.env*, .git/config
• fs.write: src/**, test/**, *.md
• exec actions: test
• [!] BEST-EFFORT pattern: "git *" → allow (weak matching, evadable — review carefully)
• exec default: ask
• net: registry.npmjs.org (default: deny)

Apply this policy? Any change to the file will require re-approval.
```

Decisions live in `~/.iota/bless.json` (outside every workspace) and are sticky in both directions. **Any** change to the file invalidates an approval and re-prompts **with a line diff against the version you approved** — an agent that rewrites its own policy triggers exactly the review it was trying to avoid, and you see precisely what it changed. A **rejection also persists** until the file changes: a policy you refused stays refused across sessions instead of nagging you every start.

**Continuity:** while a changed file awaits your decision — or after you reject its new revision — the **last version you approved keeps governing**. A teammate's edit arriving via `git pull` can't downgrade you from "governed" to "ungoverned"; you stay protected by the policy you approved until you approve the new one. Only a policy you *never* approved is treated as absent: it cannot govern you, and it cannot pretend to. In headless runs (`pi -p`, JSON mode) approvals can't happen — the last approved version (or nothing, on first use) governs; bless interactively once first. Your user-level baseline (`~/.config/iota/agents.yaml`) needs no bless and can never be widened by a repo policy (tighten-only merging).

## Writing a policy

```yaml
# agents.yaml
version: 0.1

fs:
  read:
    allow: ["**"]                          # globs: * segment, ** any depth, {a,b}
    deny: ["**/.env*", "**/*.pem"]         # deny ALWAYS wins; dotfiles are matched
  write:
    allow: ["src/**", "test/**", "*.md"]   # nothing else is writable
  delete: ask                              # or a full allow/deny mapping

exec:
  actions:                                 # exact program + frozen flags; can't be
    test: "npx vitest run <paths?>"        # pattern-evaded (pipes/$()/sh -c fall
    deps: "npm install <pkg?>"             # through to default)
  patterns:                                # escape hatch — must confess weakness:
    - match: "git *"
      verdict: allow
      best_effort: true                    # surfaced at bless time + in every log line
  default: ask                             # unmapped commands prompt; headless = deny

net:
  allow: ["registry.npmjs.org", "*.github.com"]   # wildcard ≠ apex
  default: deny
```

Typed slots validate arguments by type, not regex: `<paths>` accepts only workspace-relative paths (no flags, no absolute, no `..`), `<pkg>` npm-name-shaped tokens, `<word>` single safe tokens; `?` variants may be empty. So `npx vitest run src/a.test.ts` matches `test`, but `npx vitest run --reporter=evil` falls to `default`.

Built-in guarantees, not configurable: paths outside the workspace are always denied; writes to `agents.yaml` and harness configs are always denied (self-protection); a repo policy can only *narrow* your user baseline (`~/.config/iota/agents.yaml`), never widen it.

Two more ready-made policies (docs-only, read-only investigator) in the [project README](https://github.com/gokalper/iota-policy#sample-policies); full grammar in the [spec](https://github.com/gokalper/iota-policy/blob/main/SPEC.md).

## Verdict behavior

| Verdict | Interactive (TUI/RPC) | Non-interactive (`-p`, JSON) |
|---|---|---|
| `allow` | passes through | passes through |
| `deny` | blocked with reason | blocked with reason |
| `ask` | confirm prompt | **denied**, logged (SPEC §3.1, no override) |

No `agents.yaml` → not governed, logged as `ungoverned` (SPEC §3.2). A present-but-**invalid** `agents.yaml` fails closed: every governed tool call blocks until it parses. Denial reasons instruct the model not to retry, route around, or lobby you with policy-loosening suggestions.

## Decision log

Every evaluation appends one JSON line to `.iota/decisions.jsonl` (configure via `createIotaExtension({ logPath })`, disable with `null`):

```json
{"ts":"2026-07-13T06:22:04.648Z","v":"0.2.0","toolName":"write","toolCallId":"call_7b","operation":{"kind":"fs.write","path":"agents.yaml"},"reason":"'agents.yaml' is a protected policy/harness path…","bestEffort":false,"resolution":"blocked","durationMs":0.086,"verdict":"deny","rule":"baseline.self_protection"}
```

`resolution`: `allowed` | `blocked` | `asked_user_allowed` | `asked_user_denied` | `denied_non_interactive` | `ungoverned`.

```bash
jq -r 'select(.resolution=="blocked") | .reason' .iota/decisions.jsonl   # what got stopped and why
jq -r 'select(.bestEffort) | .operation.command' .iota/decisions.jsonl   # traffic riding the escape hatch
```

## Honesty notes

- **Intent, not execution.** This governs tool calls; a child process spawned by an allowed bash command is not constrained here. Pair with an OS sandbox as the enforcement backstop — the iota compiler will emit one from the same `agents.yaml` (roadmap).
- **`net` is not enforceable at this layer in pi**: there is no fetch tool to intercept; egress happens inside bash and falls under `exec` policy.
- Custom/unknown pi tools are outside the v0.1 vocabulary and pass through, logged as `ungoverned`.
- Overhead measured in practice: ~0.1–0.5 ms per decision, in-process, zero extra tokens.
