Skip to content

feat(pi): add opt-in permission modes - #2664

Open
edithomas wants to merge 4 commits into
kunchenguid:mainfrom
edithomas:fm/pi-claude-permission-modes
Open

feat(pi): add opt-in permission modes#2664
edithomas wants to merge 4 commits into
kunchenguid:mainfrom
edithomas:fm/pi-claude-permission-modes

Conversation

@edithomas

Copy link
Copy Markdown

Intent

Implement a clean-room, opt-in Claude Code-inspired permission-mode vertical slice for the Firstmate Pi primary, using only public Pi documentation and observable behavior (no leaked or proprietary Anthropic material).

Build an optional Pi extension (.pi/extensions/fm-permission-modes.ts) that defaults to no behavioral change and exposes /fm-permissions off|plan|confirm. off (the default) preserves current Firstmate behavior exactly. plan blocks the built-in edit and write tools and any non-read-only bash command while allowing genuinely read-only inspection (read, ls, grep, find, and read-only bash). confirm requires an interactive approval before the mutating built-in file and shell tools (edit, write, and non-read-only bash); if no UI is available, refuse rather than silently permit. Read-only bash and read-only tools are not prompted in confirm mode.

Mode state is session-persisted through Pi supported extension state (pi.appendEntry), restored on session resume and reload, fresh sessions start off, and it is never written to a global config file. The active mode shows unobtrusively in the Pi footer (cleared when off). No delegation or second LLM planner is added. Operator documentation (docs/permission-modes.md) states plainly that this is an advisory harness control, not an OS sandbox, because Pi extensions execute with user privileges.

Decisions and tradeoffs: pure command classification and mode decisions are factored into a testable module (.pi/extensions/lib/fm-permission-policy.ts) with a conservative bash read-only classifier (allowlist of known read-only commands plus a blocklist of mutating patterns matched anywhere, defaulting unrecognized commands to non-read-only so plan blocks and confirm prompts rather than silently allowing a possible mutation). The gate is additive and never weakens the existing PreToolUse seatbelts or turn-end guard: a no-block decision returns the same empty no-block result shape the existing turnend-guard uses so Pi runs the existing seatbelts afterward, and a block short-circuits before them; approval in confirm approves only this gate and does not bypass those seatbelts. Only the built-in edit, write, and bash tools are gated; custom tools pass through. The extension is scoped to Pi and does not touch the existing watcher or turn-end semantics or other adapters.

Behavioral tests (tests/fm-permission-modes.test.sh) cover default-off compatibility, read-only plan calls, blocked mutation, confirm allow and deny, noninteractive refusal, and malformed command arguments, plus session-persisted state round-trip and an end-to-end proof against the real Pi ExtensionRunner that the gate is additive and never weakens the existing seatbelts. The new TypeScript files are added to the strict no-emit typecheck (tests/fm-pi-primary-types.test.sh). bin/fm-lint.sh and bin/fm-doc-audience-check.sh must pass.

What Changed

  • Add opt-in /fm-permissions off|plan|confirm controls for Pi with session persistence, footer status, and default-off compatibility.
  • Gate mutating built-in tools using conservative shell-command classification, interactive confirmation, and fail-closed headless behavior without bypassing existing seatbelts.
  • Document the advisory safety boundary and add behavioral, typecheck, and pinned-runtime CI coverage.

Risk Assessment

✅ Low: Captain, the change is well-bounded and the prior classifier, session-state, and mandatory-test defects are now resolved without introducing new material source risks.

Testing

No baseline test output was supplied; targeted tests passed against Pi 0.84.2, including the real ExtensionRunner additive-seatbelt proof, and the interaction transcript directly demonstrates footer, persistence, prompt, allow, and refusal behavior. No screenshot was captured because this is a terminal Pi extension and the deterministic harness exercised its actual UI API without requiring a credentialed interactive model session; exact displayed status and prompt values are preserved in the JSON artifact. Per phase rules, linting, typechecking/static analysis, and the full suite were not run.

Evidence: Permission-mode interaction transcript
{
  "freshSession": {
    "footer": { "key": "fm-permissions", "value": null },
    "offEdit": { "blocked": false, "result": {} }
  },
  "plan": {
    "footer": { "key": "fm-permissions", "value": "permission: plan" },
    "persisted": { "customType": "fm-permission-modes", "data": { "mode": "plan" } },
    "readOnlyGitStatus": { "blocked": false, "result": {} },
    "mutationTouch": {
      "blocked": true,
      "reason": "plan mode blocks non-read-only shell; use /fm-permissions off to allow mutations. Command: touch demo.txt"
    },
    "customTool": { "blocked": false, "result": {} }
  },
  "confirm": {
    "footer": { "key": "fm-permissions", "value": "permission: confirm" },
    "deniedWrite": { "blocked": true, "reason": "write denied by user in confirm mode" },
    "approvedWrite": { "blocked": false, "result": {} },
    "headlessEdit": {
      "blocked": true,
      "reason": "confirm mode requires interactive approval for the edit tool, but no UI is available; use /fm-permissions off in noninteractive runs"
    },
    "prompts": [
      { "title": "fm-permissions", "prompt": "confirm mode: allow the write tool?" },
      { "title": "fm-permissions", "prompt": "confirm mode: allow the write tool?" }
    ]
  },
  "resumedSession": {
    "footer": { "key": "fm-permissions", "value": "permission: plan" },
    "edit": {
      "blocked": true,
      "reason": "plan mode blocks the edit tool; use /fm-permissions off to allow mutations"
    }
  },
  "notifications": [
    { "message": "permission mode: plan", "level": "info" },
    { "message": "permission mode: confirm", "level": "info" }
  ]
}

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

🔧 **Review** - 3 issues found → auto-fixed (2) ✅
  • 🚨 .pi/extensions/lib/fm-permission-policy.ts:220 - Required intent says the classifier must default unrecognized commands to non-read-only and plan must block every non-read-only bash command. This returns true when only the command prefix matches the allowlist, so mutations such as find . -delete, git branch new, or ls; python -c "open('x','w').write('x')" bypass plan and receive no confirm prompt. Validate the complete command and mutating flags at isReadOnlyShellCommand, rather than relying on a leading-command match plus an incomplete blocklist.
  • 🚨 .pi/extensions/fm-permission-modes.ts:146 - Required intent says fresh sessions start off. After plan/confirm is selected, a Pi session_start for /new with no persisted entry leaves restored undefined and never resets mode, so the previous session's mode leaks into the fresh session. Reset to DEFAULT_PERMISSION_MODE before restoring entries, or assign mode = restored ?? DEFAULT_PERMISSION_MODE.
  • 🚨 tests/fm-permission-modes.test.sh:63 - Required intent calls for behavioral coverage and an end-to-end proof against the real Pi ExtensionRunner. Every test uses node_guard || return 0, so when the globally installed Pi package is absent the entire new suite prints skips and exits successfully; the repository CI lane installs tasks-axi but not Pi. Make the required suite fail when Pi is unavailable or provision a pinned Pi package in its CI lane so these assertions actually execute.

🔧 Fix: Harden permission modes and require pinned Pi tests
1 error still open:

  • 🚨 .pi/extensions/lib/fm-permission-policy.ts:142 - Required criterion says “plan blocks ... any non-read-only bash command” and unrecognized commands must default to non-read-only. The revised allowlist still accepts unrestricted arguments: sed -n 'w out' input writes out, fd -x ./mutator executes a program, and attached mutating flags such as sort input -ooutput or date -s@0 evade the blocklist. These calls bypass both plan blocking and confirm prompting. At isReadOnlyShellCommand, validate each allowed command’s complete argv/options or remove commands with write/exec capabilities instead of relying on an incomplete mutation blocklist.

🔧 Fix: Validate complete shell argv before read-only approval
✅ Re-checked - no issues remain.

✅ **Test** - passed

✅ No issues found.

  • Inspected the target diff and focused permission-mode test surface against the authoritative intent.
  • Installed CI-pinned Pi 0.84.2 into a disposable worktree directory, then ran FM_PI_PACKAGE_DIR="$PWD/.tmp-no-mistakes/pi/node_modules/@earendil-works/pi-coding-agent" tests/fm-permission-modes.test.sh.
  • Ran a node --input-type=module interaction harness against the real extension, exercising fresh-session off behavior, plan read/mutation decisions, custom-tool passthrough, confirm allow/deny, headless refusal, footer state, persistence, and resume restoration.
  • Validated the generated JSON evidence structure, removed the disposable Pi installation, and confirmed the worktree remained clean.
✅ **Document** - passed

✅ No issues found.

⚠️ **Lint** - 1 warning
  • ⚠️ linter found issues (exit code 127)
✅ **Push** - passed

✅ No issues found.

Edi Z added 4 commits August 20, 2026 12:52
Add an optional Pi extension that exposes /fm-permissions off|plan|confirm
for the Firstmate Pi primary, defaulting to off so behavior is preserved
exactly until a mode is enabled.

plan blocks the built-in edit and write tools and any non-read-only bash
command while allowing genuinely read-only inspection; confirm requires an
interactive approval before the mutating built-in file and shell tools and
refuses rather than silently permits when no UI is available.

Mode state is session-persisted through Pi appendEntry extension state
(restored on resume and reload, fresh sessions start off) and is never
written to a global config file; the active mode shows unobtrusively in the
Pi footer.

The gate is additive and never weakens the existing PreToolUse seatbelts or
turn-end guard: a no-block decision lets them run afterward and a block
short-circuits before them. Pure classification and mode decisions live in
a testable module; docs/permission-modes.md states plainly that this is an
advisory harness control, not an OS sandbox.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant