@iota-policy/pi-extension

iota embedded policy engine as a pi coding-agent extension: hash-pinned bless, agents.yaml enforcement for read/write/edit/bash, JSONL decision log

Packages

Package details

extension

Install @iota-policy/pi-extension from npm and Pi will load the resources declared by the package manifest.

$ pi install npm:@iota-policy/pi-extension
Package
@iota-policy/pi-extension
Version
0.2.0
Published
Jul 14, 2026
Downloads
291/mo · 291/wk
Author
gercilasun
License
Apache-2.0
Types
extension
Size
81.9 KB
Dependencies
1 dependency · 0 peers
Pi manifest JSON
{
  "extensions": [
    "./extensions"
  ]
}

Security note

Pi packages can execute code and influence agent behavior. Review the source before installing third-party packages.

README

@iota-policy/pi-extension

A permission system for pi, in one YAML file. pi 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 — 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

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:

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.

npm install -D @iota-policy/pi-extension
npx iota-pi install     # writes .pi/extensions/iota.ts, gitignores .iota/

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

# 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; full grammar in the spec.

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):

{"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.

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.