smart-approve

High-risk-only approval hook with LLM risk analysis, behavior detection, protected-path interception, and decision memory for oh-my-pi (OMP) and pi-agent.

Packages

Package details

extension

Install smart-approve from npm and Pi will load the resources declared by the package manifest.

$ pi install npm:smart-approve
Package
smart-approve
Version
2.5.3
Published
Aug 3, 2026
Downloads
848/mo · 342/wk
Author
mentalfl0w
License
MIT
Types
extension
Size
96.6 KB
Dependencies
0 dependencies · 0 peers
Pi manifest JSON
{
  "extensions": [
    "./dist/index.js"
  ]
}

Security note

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

README

smart-approve

A custom "bash" tool that replaces OMP's built-in bash, with LLM-powered risk analysis, behavior detection, protected-path interception, and decision memory. Compatible with both oh-my-pi (OMP) and upstream pi-agent.

Safe commands pass through with zero interruption. When a dangerous behavior is detected, the custom tool's execute() method runs LLM risk analysis and shows an approval dialog — all outside OMP's 30-second EXTENSION_HANDLER_TIMEOUT_MS, so there is no time pressure. In headless (subagent) contexts, dangerous operations are blocked outright.

How it works

LLM calls bash tool
       │
   analyzeCommand() — argument parsing + regex secondary net
       ├─ hard-block (rm -rf /, fork bomb, curl|sh…) → block always
       ├─ no behaviors → execute (zero interruption)
       └─ dangerous behavior → needs review ↓
              │
          allow-list hit (session or permanent)? ── yes → execute
              │ no
          ctx.hasUI === false (headless) → block
              │ has UI:
              setStatus("analyzing…")
              gatherSessionContext() — original user task + recent agent plan
              RPC prompt → @tiny (bounded by analysisTimeoutMs)
                  @tiny fails/times out? → @smol → @default (each bounded)
                  success → dialog shows risk / summary / detail / recommendation
                  failure → dialog shows rule-based label only
              ctx.ui.select(title, [session allow, permanent allow, deny])
                  session   → in-memory Set (cleared on restart)
                  permanent → persisted to JSON file
                  deny      → block
              approved → ctx.invokeTool() delegates to native bash tool → return output to LLM

Hard-block wins over the allow-list: an entry that predates a rule upgrade (or was hand-edited into smart-approve-allow.json) can never bypass a hard-block.

write/edit to protected paths is handled separately via the tool_call hook — pure path matching + confirmation dialog, no LLM analysis (the path itself is sufficient signal).

Architecture: custom tool, not handler interception

OMP's EXTENSION_HANDLER_TIMEOUT_MS (30s, hardcoded) wraps tool_call event handler dispatch — but not custom tool execute() methods. This extension exploits that:

  1. pi.registerTool({ name: "bash", ... }) — registers a replacement with the same name, shadowing the built-in
  2. All approval logic (behavior detection → LLM analysis → ui.select → execution) lives inside execute(), free from the 30s timeout
  3. Execution is delegated to the native bash tool via ctx.invokeTool(), inheriting shell path resolution, env hardening, PTY, and output truncation

The previous architecture intercepted bash via pi.on("tool_call") and was killed by the 30s timeout during LLM analysis. The custom tool architecture eliminates this entirely.

LLM risk analysis: persistent RPC session

Risk analysis does not cold-start a new omp -p subprocess per call. Instead, the extension spawns one omp --mode rpc child on first use and reuses it over a JSONL stdio protocol for every analysis in the session:

  • One process, many prompts — extension loading (including provider-retry-proxy) and process startup are paid once per host session, not per analysis
  • Per-attempt timeout — each prompt is bounded by analysisTimeoutMs (default 30s, 0 = no timeout); a hung remote model cannot freeze the bash tool
  • Model fallback chain — @tiny first (cheapest), then @smol, then @default; each attempt time-bounded; total failure degrades to rule-label confirmation
  • Interruption — the tool's AbortSignal is forwarded to the RPC child (abort command), so a user interrupt cancels an in-flight analysis instead of leaving it running
  • Lifecycle — the child is lazily spawned on first use, killed via session_shutdown, reaped after rpcIdleTimeoutMs (default 10 min) of inactivity, and exits on its own if the host dies (stdin EOF closes → process exits code 0, no orphans)

The LLM receives:

  • Session context — original user task + recent agent plan (injection-guarded)
  • Detected behaviors — localized labels
  • The command — as-is

And returns structured JSON: risk (low/medium/high), summary, detail, recommend.

Features

1. Behavior-based detection (not just regex)

Parses git arguments to detect behaviors that regex alone misses:

Command Behavior detected How
git push origin +main force-push +refspec, not just --force
git branch -D feature branch-delete combined short flags like -rD
git clean -fd git-clean --dry-run excluded
git reset --hard hard-reset --hard flag
git worktree remove worktree-remove subcommand parsing

Regex rules remain as a secondary net covering 30+ patterns: rm -rf (case-insensitive, including -Rf/-RF and --recursive --force), fork bombs, curl|sh, mkfs, dd, kill -9, sudo, docker rm, kubectl delete, 20+ git destructive operations, and more.

2. Protected path interception (write/edit)

Intercepts write/edit tool calls via the tool_call hook and matches the target path against glob patterns:

  • .env, .env.* (.env.example explicitly allowed)
  • **/.ssh/**, **/.kube/config, **/.aws/credentials
  • **/.git-credentials, **/.netrc, **/.npmrc, **/.pypirc
  • **/id_rsa, **/id_ed25519, **/*.pem, **/*.key, **/*.p12, **/*.kdbx
  • **/auth.json, **/.config/gh/hosts.yml, **/.config/gcloud/**

Matching is symlink-aware: resolves realpath before matching, so a symlink alias can't evade a deny.

Path matching is pure and fast — no LLM analysis needed. The 30s handler budget is more than sufficient for path matching + confirmation dialog.

3. Decision memory

The confirmation dialog offers three choices:

Option Storage Lifetime
Allow for this session In-memory Set<string> Cleared on restart
Always allow ~/.omp/agent/smart-approve-allow.json Persists across restarts
Deny Blocks the command

Keys are scoped to tool + normalized-content + cwd, so the same command in a different project still triggers review. When the UI doesn't support select, it degrades to a simple confirm (two-way).

4. Session context for LLM review

Reads the agent's conversation history via ctx.sessionManager.getBranch() / getEntries() and extracts:

  • Original user task — the first user message (truncated to 1000 chars)
  • Recent agent plan text — the last 2 assistant text blocks (each truncated to 800 chars)

All context is wrapped in <untrusted_context> blocks with injection guards. Tool outputs and tool-call arguments are explicitly excluded (largest injection surface). When sessionManager is unavailable, it safely degrades to null — LLM review still works, just without context.

5. Hard-block behaviors

The following behaviors are always hard-blocked — no LLM review, no dialog, no allow-list override:

  • Delete root path (rm -rf /)
  • Fork bombs
  • Remote fetch-and-execute (curl|sh)
  • Writes to /etc/passwd, /etc/shadow, /etc/sudoers, /etc/hosts
  • Writes to raw block devices (/dev/sd*, /dev/nvme*, …)
  • Disk format (mkfs, dd to block device)
  • Shutdown / reboot

6. Execution via native bash tool delegation

Commands are never executed directly by the extension. After passing the approval gate, execution is delegated to OMP's built-in bash tool via ctx.invokeTool(). This inherits all native behavior:

  • Shell path resolution — no ENOENT from missing PATH in worker processes
  • PTY support — interactive commands work when the native tool uses PTY
  • Env hardeningPAGER=cat, GIT_TERMINAL_PROMPT=0, etc.
  • Output truncation — head/tail windows with artifact spill
  • Cross-platform — no hardcoded binary paths

7. OMP + pi dual compatibility with graceful degradation

Aspect Implementation
Dual manifest package.json declares both omp.extensions and pi.extensions
Host detection process.execPathprocess.argv[1] → PATH lookup (omppi)
LLM invocation Persistent omp --mode rpc / pi --mode rpc child, JSONL over stdio
Model fallback @tiny → @smol → @default (each time-bounded) → rule-only confirmation (no LLM)
Headless ctx.hasUI === false blocks all dangerous operations immediately
Bilingual zh/en, auto-adapts to locale (LC_ALL > LC_MESSAGES > LANG > macOS AppleLocale)

Install

npm install smart-approve

Then configure OMP to load the extension:

# ~/.omp/agent/config.yml   (or ~/.pi/agent/config.yml for pi-agent)
extensions:
  - smart-approve
tools:
  approvalMode: yolo
  • tools.approvalMode: yolo — auto-approve safe commands; this extension is the sole gate for dangerous ones
  • extensions: [smart-approve] — load the extension from node_modules

The custom "bash" tool shadows the built-in by name — no bash.enabled change is needed. Restart the host after installing or editing.

Configuration

Config lives at ~/.omp/agent/smart-approve.json (or ~/.pi/agent/smart-approve.json on pi-agent). All fields are optional — defaults apply when missing:

{
  "enabled": true,
  "protectedPaths": [
    ".env", ".env.*", "!.env.example",
    "**/.ssh/**", "**/.kube/config", "**/.aws/credentials",
    "**/*.pem", "**/*.key", "**/*.p12", "**/*.kdbx",
    "**/id_rsa", "**/id_ed25519", "**/auth.json"
  ],
  "llmAnalysis": true,
  "rememberDecisions": true,
  "contextMaxChars": 3000,
  "analysisTimeoutMs": 30000,
  "rpcIdleTimeoutMs": 600000,
  "model": "@tiny"
}
Field Default Description
enabled true Master switch
protectedPaths 20+ built-in patterns Glob patterns for write/edit interception; ! prefix negates
llmAnalysis true Whether to invoke the model for risk analysis; false = rule-only confirmation
rememberDecisions true Whether to offer session/permanent remember options in the dialog
contextMaxChars 3000 Max chars of session context to feed the LLM
analysisTimeoutMs 30000 Per-attempt timeout for the RPC risk-analysis prompt in ms; 0 = no timeout. On timeout/failure the model chain advances @tiny → @smol → @default, then rule-label confirmation
rpcIdleTimeoutMs 600000 Idle lifetime of the persistent RPC child in ms; 0 = keep alive until session end. After this long without a prompt the child is killed (frees its memory) and lazily respawned on the next analysis
model @tiny Model spec for risk analysis (role alias, provider/id, or bare id). Attempt chain: configured model runs first, then @tiny → @smol → @default as fallbacks (deduped)

Allow-list (decision memory)

Permanent allow entries are stored at ~/.omp/agent/smart-approve-allow.json:

{
  "permanent": [
    {
      "tool": "bash",
      "key": "git push origin main",
      "cwd": "/home/user/myproject",
      "timestamp": "2026-07-23T05:00:00.000Z"
    }
  ]
}

Session allows are in-memory only, cleared on restart. You can edit or delete this file to revoke remembered decisions.

Extension API surface used

API Purpose
pi.registerTool({ name, parameters, execute }) Register custom "bash" tool shadowing the built-in
pi.zod Injected zod module for tool parameter schemas
ctx.invokeTool(params, opts) Delegate execution to native bash tool
child_process.spawn(hostBin, ["--mode", "rpc", ...]) Persistent RPC child for LLM risk analysis
pi.on("tool_call", handler) Intercept write/edit on protected paths
ctx.hasUI Detect headless/subagent context
ctx.sessionManager.getBranch() / getEntries() Gather session context for LLM review
ctx.ui.setStatus(id, text) Show "analyzing…" status
ctx.ui.confirm(title, body) Confirmation dialog (fallback when select unavailable)
ctx.ui.select(title, choices) Three-way choice: session allow / permanent allow / deny
return { block: true, reason } Block contract for tool_call handler (write/edit only)

Project layout

smart-approve/
├── README.md
├── package.json          ← omp.extensions / pi.extensions manifest (v2.4.4)
├── LICENSE               ← MIT
├── src/
│   ├── index.ts          ← SmartApprove orchestrator: register bash tool + write/edit hook
│   ├── bash-tool.ts      ← custom "bash" tool (shadows built-in, delegates via ctx.invokeTool)
│   ├── types.ts          ← ExtensionAPI, ToolDefinition, AgentToolResult, etc.
│   ├── host.ts           ← HostResolver + ModelInvoker (persistent RPC LLM analysis)
│   ├── rpc-invoker.ts    ← RPC client: spawn/reuse/kill omp --mode rpc child
│   ├── behaviors.ts      ← behavior catalog, git parser, composite analysis
│   ├── paths.ts          ← ProtectedPathMatcher (symlink-aware)
│   ├── config.ts         ← ConfigStore
│   ├── allowlist.ts      ← AllowList (session + permanent)
│   ├── context.ts        ← SessionContextGatherer
│   ├── dialog.ts         ← confirmWithRemember + formatAnalysis
│   ├── i18n.ts           ← locale detection + bilingual strings (zh/en)
│   └── logger.ts         ← Logger (file + stderr)
└── dist/
    └── index.js          ← bundled output (bun build, ~50KB)

Runtime artifacts

~/.omp/agent/smart-approve.json          — config (user-editable)
~/.omp/agent/smart-approve-allow.json    — permanent allow-list (auto-maintained)
~/.omp/logs/smart-approve.log            — diagnostic log

License

MIT