@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
Package details
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.yamllives 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.jsonlforjqdebugging
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). netis not enforceable at this layer in pi: there is no fetch tool to intercept; egress happens inside bash and falls underexecpolicy.- 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.