omp-smart-approve0.1.1
An LLM safety judge in front of tool calls OMP would otherwise auto-approve. Hard-blocks clear attacks and clear mistakes, escalates genuine uncertainty to a human.
Built for omp
The author tagged this package oh-my-pi.
Reports 0
No reports yet.
Readme
omp-smart-approve
An LLM safety judge in front of tool calls OMP would otherwise auto-approve.
OMP already asks you about the calls it considers risky. This plugin is about the other ones — the calls that run silently because the tool is on an auto-approve list, or because the session is in yolo mode, or because a subagent inherited a permissive setting. That is a large, quiet surface, and it is where both of the following go wrong.
Malicious calls. A model that has read a web page, an issue, a dependency's README, an MCP tool result, or a log file has read attacker-controllable text. Prompt injection turns that text into an instruction, and the instruction arrives as an ordinary-looking tool call from your own agent.
Well-intentioned but wrong calls. A model sincerely pursuing your goal
proposes DROP TABLE against the wrong database, force-pushes over a colleague's
branch, or deletes a directory it computed from a variable. No attacker is
involved. The call is simply worse than the model understands.
Both look like a normal tool call. Neither is caught by a permission list, because the permission was already granted. So each in-scope call gets a fresh semantic judgment from a second model, against your policy, before it runs.
Table of contents
- What this is not
- Requirements and supported hosts
- Install
- First-run setup
- Configuration and policy
- What gets judged
- Sensitivity
- Rules: exact versus pattern
- The three verdicts
- Fail-closed behavior
- Why escalation is asynchronous
- The two clocks
- Subagent routing
- Audit and redaction
- Bypass
- Command reference
- Troubleshooting
- Live evaluation
- Package verification
What this is not
Read this section before deciding what to rely on.
It is not a sandbox. It reasons about calls; it does not confine them. A call it allows runs with the full authority of the OMP process — same filesystem, same network, same credentials. If you need containment, you need containment. This is a second boundary in front of a decision, not a wall around a process.
A human override can still authorize destructive work. /smart-approve override exists precisely so a human can say "yes, I mean it". Every guarantee
here ends at a person who types the challenge string. That is the intended
design — a safety gate nobody can get past is a safety gate people rip out —
but it means the plugin cannot protect you from your own approvals, and it
cannot protect you from an approval given without reading.
No judge is perfect. The judge is a language model. It will sometimes allow something it should have escalated and sometimes block something obviously fine. Configured strictly it produces friction; configured permissively it produces misses. Treat it as one layer that raises the cost of an attack and catches a meaningful share of mistakes, not as a guarantee about any individual call. If you want numbers for a specific model, measure them — see Live evaluation — and do not infer safety from the fact that the deterministic test suite passes.
Hosts outside the validated approval surface are blocked, not guessed at. The plugin decides what to do by asking OMP's own approval resolver what OMP would have done. When it cannot match a tool call to a live OMP session, or cannot resolve the tool's tier, it does not assume. It blocks and says so. An embedded SDK host that auto-approves outside the surface described below is detected after exactly one call and then latched: every subsequent call in that session goes through the full gate. The cost of this choice is that an unsupported host is unusable rather than silently unprotected. That is deliberate.
It does not gate code quality. Architecture, naming, test coverage, performance, dependency choices, style — none of it. A gate that argues about taste is a gate people learn to click through, which destroys its value for the cases that matter.
The judge sees a bounded excerpt of your tool output, not full bodies. Up to twelve recent actions reach the judge prompt, and each one is secret-obfuscated, home-path-minimized, collapsed to a single line, truncated to 200 characters, and wrapped in inert-data delimiters alongside its source, trust flag, and timestamp. Full result bodies are never copied in, and none of this is persisted. That bounds the judge's own prompt-injection exposure, the token cost, and the latency — but be clear about the cost: truncation can hide the part that mattered. A 200-character excerpt of a long result may not show the injected instruction buried at the end of it. The deterministic scanners read complete values; the judge reads a sample.
Requirements and supported hosts
| Runtime | Bun >= 1.3 |
| OMP peer range | >=17.2.2 <18 |
| Validated against | OMP 17.2.4 |
Host-contract tests import OMP's real approval resolver, extension loader and runner, tool types, secret APIs, and agent registry, so a breaking OMP change fails type-check or tests rather than silently changing security semantics.
Supported hosts: OMP CLI, TUI, and RPC sessions, plus the in-process task subagents they spawn.
Anything else — an embedded SDK host, a custom runner that bypasses OMP's approval resolution — is treated as unsupported and blocked, as described above.
Install
The plugin is installed locally from a checkout; publishing is not required.
git clone <this-repo> omp-smart-approve
cd omp-smart-approve
bun install
omp plugin link .
omp plugin link symlinks the directory into OMP's plugin tree, so edits to
src/ take effect on the next session with no reinstall. (omp plugin install ./path routes to the same code path, so either verb works.)
Verify it loaded:
omp plugin list # omp-smart-approve@0.1.0, marked enabled
omp plugin list --json # same, machine-readable
omp plugin doctor # reports manifest/entry-point problems
Then start a session and run /smart-approve status. If the command does not
exist, the extension did not load; omp plugin doctor is the next stop.
Unlink:
omp plugin uninstall omp-smart-approve
Uninstalling removes the link, not your configuration or audit database. Those
live at <agentDir>/smart-approve.yml and
<agentDir>/smart-approve/state.sqlite and must be deleted by hand if you want
them gone.
First-run setup
/smart-approve setup
This is the only wizard, and it only opens when you ask for it. There is no automatic first-use prompt — not on install, not on first session, not on first scoped call. A safety tool that pops a configuration dialog in the middle of someone's work teaches them to dismiss dialogs, and this plugin's entire value depends on dialogs being read.
setup lists only models whose provider already has configured credentials in
OMP, recommends a fast model from a family different from your primary session
model, previews the config path and the resulting timeout budget, and writes the
file only after you confirm.
Until setup succeeds, the plugin fails closed. Every scoped call that OMP
would have auto-approved is blocked, and the block reason names
/smart-approve setup. Unconfigured means blocked, never "waved through until
someone gets around to it". Calls OMP would prompt about are unaffected: you
still get OMP's own dialog.
You do not supply a judge API key. Judgment runs through OMP's model registry and OMP's existing provider authentication.
Configuration and policy
Two separate files, two different jobs.
smart-approve.yml — deterministic configuration
Precedence, lowest to highest:
- Built-in defaults
- User:
<agentDir>/smart-approve.yml - Project:
<cwd>/.omp/smart-approve.yml - Project:
<cwd>/smart-approve.yml
Sections merge key by key — a project layer that sets sensitivity.fallback
does not discard your user-level judge.authorizedModels. rules accumulate
across layers on top of the built-in deny floor, and a project layer may
only add IDs: a project file that redefines a rule ID from a broader layer is
ignored with a compatibility warning naming the file and the ID. A user
layer may redefine anything, including a builtin, because only a human writes
that file. Tightening is always available downward; loosening is not.
Config discovery looks in the working directory only, not its ancestors. A config file two directories up does not apply.
See examples/smart-approve.yml for every key with
commentary.
SMART_APPROVE.md — natural-language policy
Prose the judge reads as authoritative policy, distinct from the transcript and from any tool output, both of which are treated as untrusted data. This is where project knowledge that cannot be expressed as a glob goes: which environments are reachable, what counts as irreversible here, which destinations are never acceptable.
Discovery runs broadest to most specific, and unlike config it walks the whole ancestor chain:
<agentDir>/SMART_APPROVE.md- For each ancestor directory of
cwd, root-first:<dir>/.omp/SMART_APPROVE.md, then<dir>/SMART_APPROVE.md
The home directory itself is skipped, so a stray file in ~ cannot silently
govern every unrelated project on the machine. Documents are presented to the
judge in discovery order, so the most specific statement reads last.
See examples/SMART_APPROVE.md.
Both files are protected
Smart Approve's own config and policy files, OMP's settings.yml,
secrets.yml, and secret-placeholder.key, and the audit database (including
its -wal and -shm companions) are on a default deny floor for write and edit
calls. An agent that can rewrite settings.yml can set approvalMode: yolo; an
agent that can rewrite secrets.yml can neutralize redaction. Only an explicit
human override reaches these files.
The floor catches three spellings of the same file, because a deny floor that only matches one is decorative:
- The resolved absolute path, with
..collapsed. - A symlink to a protected file, resolved with
realpath— and the parent directory is resolved separately, so a link likeevil/ -> .omp/cannot launder a write toevil/smart-approve.yml. Parent resolution matters because the leaf usually does not exist yet, which is the normal shape of a create-then-overwrite attempt. - A target too dynamic to resolve whose literal text still names a protected
path (
~/...,$HOME/...), matched by suffix.
Disabling the gate is protected too
A file write is only one way to remove the gate. Launching a nested agent with its extension or approval layer switched off removes it from the child process without writing anything, so four rules ship in the built-in deny floor and apply with zero configuration:
| Rule ID | Denied target |
|---|---|
builtin:nested-agent-no-extensions |
--no-extensions |
builtin:nested-agent-skip-permissions |
--dangerously-skip-permissions |
builtin:disable-env-assignment-omp |
OMP_DISABLE_*=* |
builtin:disable-env-assignment-pi |
PI_DISABLE_*=* |
All four are tool: "*", not tool: bash. Command targets are extracted from
any command argument regardless of what the tool is called, so an MCP server
exposing run({command}) produces byte-identical targets; scoping these to
bash would let that straight through. The cost is a narrow false-block
surface — a target whose own literal text is one of these strings, such as a
file named --no-extensions — which is a human-overridable block on an absurd
path, against a miss that is a silent gate removal.
omp -p "…" itself is not denied: the child still loads this plugin and
still judges every call it makes, so denying it would break delegation without
closing anything. Nor is --approval-mode=yolo — it widens what OMP
auto-approves, and every one of those calls still reaches this gate.
A user-scope file may redefine any of these by ID. A project file may not.
Known limitation. This floor matches targets, and targets come from a
command string. A process launch expressed as an argv array instead —
{application: "omp", args: ["--no-extensions", …]} — produces no command
target, so no deny rule can see it, at any tool scope. The same is true of a
command buried inside an xd:// device payload, which arrives as an unparsed
JSON string. Both still reach the judge with their arguments intact, so they are
judged rather than unseen; it is the deterministic layer that has the hole.
Tracked in issue #1.
What gets judged
Judged:
- Every write-tier call.
- Every exec-tier call.
- Every call whose arguments can transmit user-controlled data over the network — URL reads, web searches, browser actions, any target with a remote scheme.
- Every unknown custom or MCP tool. An unknown tool cannot prove it is a purely
local read, so it is scoped by default (
scope.judgeUnknownTools). - Every nested
xd://<device>dispatch. The parentwriteis only transport; the real semantics live in the payload, and the parent cannot vouch for them. - Any call whose tool tier cannot be resolved.
Not judged: a local read, grep, or glob — but only after it clears the deterministic checks below, and only when all three hold: read tier, a known local-read tool, and no target that can leave the machine. Such a call skips the model but still records provenance, which later gated calls are judged against.
Before any of that, three deterministic checks run with no model call:
- Write or edit against a protected policy file → block.
- A matching deny rule → block.
- A read whose target has a credential-file shape (
.env*,~/.ssh/**,~/.aws/credentials,*.pem,.npmrc,.netrc,~/.kube/config, and similar) → block.
These run before scope filtering, which is why you can block reads of **/.env*
without spending a model call, and why scope.neverJudge cannot be used to
reach them.
scope.alwaysJudge and scope.neverJudge adjust the middle of this. neverJudge
is honored only for calls that already proved read tier with no remote target;
listing bash there changes nothing.
Internal URI schemes are not all local
OMP's scheme:// URIs look uniform and are not. Smart Approve splits them:
| Local — can qualify as a local read | Remote — always scoped |
|---|---|
memory:// skill:// agent:// history:// artifact:// local:// rule:// omp:// xd:// |
issue:// pr:// vault:// mcp:// |
issue:// and pr:// shell out to gh and return externally authored
content. An issue body or a PR description is world-writable text on someone
else's server; it is one of the most convenient prompt-injection carriers there
is. So reading one is scoped for judgment and its provenance is recorded as
untrusted, exactly like fetching a web page. vault:// and mcp:// are
network-backed for the same reason.
xd:// is local in the scheme sense, but a nested xd://<device> dispatch is
scoped anyway — see the list above.
Sensitivity
Three levels — permissive, balanced, strict — with the narrowest
applicable setting winning:
risk-class override > per-tool override > fallback
sensitivity:
fallback: permissive
byTool:
bash: strict
byRisk:
exfiltration: strict
The two overrides apply at different moments, because they can:
- Per-tool resolves before the judge runs, and is the posture the judge is
given (
byTool[tool], elsefallback). No risk class exists yet, so nothing narrower is available. - Per-risk resolves after the verdict comes back carrying a risk class. If
the risk-specific level is stricter than the posture the judge was given, an
allowis downgraded toescalatefor that call, and the audit rationale names both levels.
So with the config above, a call the judge allowed but classified as
exfiltration becomes an escalation — risk-specific policy stays strict even
for a tool you deliberately made permissive.
The tightening is one-directional by construction. A per-risk override can
only raise strictness. A permissive per-risk override can never turn a block
or an escalate into an allow, no sensitivity setting overrides a
deterministic deny, and none of them can substitute for a missing verdict.
permissive does not mean "allow on judge failure" — nothing means that. This
one-directionality is what makes the setting safe to expose rather than merely
configurable: the worst a misconfigured byRisk entry can do is add friction.
Risk classes: prompt-injection, exfiltration, destructive-action,
wrong-target, policy-violation, safety-tampering, uncertain.
A posture rule (see below) is the third form: it carries a sensitivity into
the judge prompt as a hint about a specific surface, without allowing or
blocking anything by itself.
Rules: exact versus pattern
This is the part most worth reading carefully, because it is where a configuration mistake turns into a hole.
Deny may be patterned
- id: deny-dotenv
kind: deny-pattern
tool: "*" # every tool
target: "**/.env*" # glob; `re:` prefix for a regex
risk: exfiltration
reason: Environment files hold credentials.
Deny rules may use globs (** crosses separators, * does not, ? is one
character) or re:-prefixed regexes, may use tool: "*", and may match a
target the plugin could not fully resolve. Refusing something you cannot pin
down is safe; allowing it is not.
Targets are matched against both the literal string the agent wrote and its
resolved absolute form, so prefer **/thing/** over thing/** — the latter
misses absolute paths.
Deny rules on shell calls name command-position tokens, not arguments. Smart
Approve extracts, per ;/&&/||/| segment: a leading env wrapper, each
leading NAME=VALUE assignment, the executable that follows them, and any
literal URL operand. It deliberately does not parse the rest of the flags,
because a rule that believes it understands shell quoting is a rule with a
bypass in it. target: terraform works. target: "re:^terraform apply" matches
nothing. Argument-level semantics — --force, --context prod, a computed
path — are exactly what the judge is for.
The one exception is the kill-switch flag set backing the built-in deny floor
(--no-extensions, --dangerously-skip-permissions): those are emitted as
command targets wherever they appear in a segment, because there the flag is
the action.
Only a fully specified allow may skip the judge
An allow-exact rule skips judgment only when all four of these hold:
argumentsHashpins the exact literal call,targetequals a resolved, non-dynamic target of that call,cwdis an absolute path equal to the working directory the call runs in, and- the rule comes from the user-scope file,
<agentDir>/smart-approve.yml.
Omit any one and the rule degrades to advisory posture — matching calls are still judged in full. There is no partial credit.
- id: allow-verify-ledger
kind: allow-exact
tool: bash # a literal tool name; `*` disqualifies the rule
target: scripts/verify-ledger.sh # resolved, non-dynamic, no wildcard
argumentsHash: 072f909c49daff0e40b0fd6d419a1ca2fb513fcd57fe0cbe0d2349fd50e45b9f
cwd: /srv/orchid # absolute; this is what binds the rule to one place
reason: Read-only ledger verification, pinned to its exact arguments and location.
Copy the hash from an audit row via /smart-approve review <audit-id>.
Why the hash. Without argumentsHash, a rule naming only tool and target
still omits every other argument: tool: write, target: src/app.ts would
authorize any content whatsoever at that path. The hash is what makes "exact"
mean exact.
Why cwd. The arguments hash is location-blind. bash {command: "scripts/verify-ledger.sh"} and write {path: "src/a.ts"} hash identically in
every checkout on the machine, so a user-scoped pinned rule without cwd would
fire in all of them. cwd is the repository and working-directory identity a
persisted allow is supposed to carry.
Why cwd must be absolute. path.resolve(".") is whatever directory OMP
was launched from, so cwd: . and cwd: ./ read as "here" and mean "anywhere":
they match in every checkout on the machine. A relative cwd therefore demotes
the rule to advisory, and the demotion is reported as a compatibility warning at
load.
Why user scope. A pinned allow is the only rule that runs a call with no
judge and no human prompt. A smart-approve.yml arriving with git clone is
not an operator decision, so a project-scope allow-exact is always advisory —
also warned about at load, and also never rejected: the call is judged, not
failed.
Why the resolved target as well. It pins what the call points at, where
cwd pins where it runs. A target the plugin could not resolve — a glob, a
shell substitution, an unexpanded variable — is dynamic and can never match; a
call whose destination we only guessed at must not reach an allow.
How target resolves differs by tool family, and this is the part that trips
people up:
- Path tools (
write,edit,read): the resolved target is always the absolute path, so a relativetarget:can never match. Write the absolute path. - Command tools (
bash): the resolved targets are the command-position tokens, exactly as typed — the executable, plus a leadingenvwrapper orNAME=VALUEassignment if present, relative or absolute, whichever you wrote.target: buntherefore matchesbun ...anywhere, which is exactly whycwdis required rather than optional.
Any wildcard in an allow makes it a hint
- id: allow-pattern-scratch
kind: allow-pattern
tool: write
target: "**/.tmp/**"
reason: Scratch output is routine.
This never skips the judge. Matching calls are judged in full; the judge is simply told that you consider this surface routine. One wildcard, one regex, or one omitted argument is enough — there is no "mostly exact".
A posture rule is the explicit form of the same idea: it never allows or
blocks, it just tells the judge to be stricter about a surface.
Precedence
protected policy file
-> deny rule (highest specificity wins; deny beats allow at equal specificity)
-> credential-file read
-> exact hash-bound human grant
-> exact allow rule (argumentsHash AND resolved target AND cwd)
-> retry-loop check
-> LLM judge (posture hints included)
-> per-risk sensitivity tightening
A grant or an exact allow can never override a deny, because denies are decided first.
Judge suggestions
The judge may propose a rule: an exact allow with no pattern operators, or an
exact or patterned deny, or a posture rule. A patterned allow suggestion is
rejected outright — the model cannot propose a wildcard bypass. Every suggestion
is non-binding, previewed as a rule change, and applied only after explicit human
confirmation via /smart-approve apply-suggestion.
The three verdicts
What each verdict does depends on which branch the call is on, and the branch is decided by asking OMP's own resolver what OMP would have done.
Branch A — OMP would auto-approve
| Verdict | Result |
|---|---|
allow |
Runs silently, unless a stricter per-risk sensitivity downgrades it to an escalation. A redacted audit row is committed before execution either way. |
escalate |
A durable pending approval is persisted and the call is blocked with an audit ID. If a live TUI exists and enough budget remains, a best-effort dialog is offered first. See below. |
block |
Blocked, with an actionable reason. Visible in the main UI and the audit. |
The judge may hard-block without asking only for a clear safety or policy violation. Legitimate-but-consequential and genuinely uncertain calls escalate to you instead of being decided for you.
Branch B — OMP would prompt
One semantic pre-screen, and then exactly one dialog: OMP's own.
| Verdict | Result |
|---|---|
block |
Hard block. No prompt. Reserved for a deterministic violation or a clear judge block. |
| anything else | A companion risk card is published next to OMP's native prompt, and the call falls through to it. |
There is never a second approval dialog on this branch. Two dialogs for one call trains you to click through both, and the second one you stop reading is the one that mattered. Judge failure on this branch is shown prominently in the card and still falls through — only OMP's native prompt can authorize execution.
Branch C — OMP would deny
Judgment is skipped entirely and the denial is preserved. The plugin never converts an OMP denial into anything else.
Fail-closed behavior
Every one of these ends in escalation, and escalation ends in a block if no human answers:
- Judge timeout, provider error, or transport failure (after the configured retry).
- Malformed or oversized judge output. A verdict that does not parse is a judge failure, not an allow.
- Missing or unauthorized judge model.
- Missing, invalid, or unparseable configuration.
- Storage failure. A scoped call cannot return allow until its terminal redacted audit row has committed. Running unaudited is exactly the state this plugin exists to prevent, so a failed audit write is a safety failure, not a logging inconvenience.
- No UI, or a UI that returns a default. In a headless host,
select/input/customreturnundefinedandconfirmreturnsfalse. Every one of those is treated as unavailable — never as approval. - Deadline exhaustion.
- An internal plugin exception, contained at the
tool_callboundary and converted into a durable redacted fail-closed result. If even the store is unavailable, the result is a concise emergency block with no raw data in it.
No path converts unavailability into approval. There is deliberately no
configuration key, environment variable, or flag that silently disables the
plugin. The only off switch is /smart-approve unguarded, which is
loud, typed, session-scoped, audited, and narrower than it sounds.
Retry loops
After three blocked attempts in the same retry family — the same source agent, tool, normalized operation, resolved target, and risk class — the next attempt stops being silently re-judged and escalates the whole sequence to you. The family key deliberately excludes the arguments hash, so an agent that retries the same destructive operation with cosmetically different arguments lands in the same family. With no human channel available it stays blocked and the agent is told to stop retrying.
A human-approved retry is admitted by its exact grant before the loop check runs, so approving something does not count as another strike against the call you just approved.
Why escalation is asynchronous
OMP caps an extension's tool_call handler at 30 seconds
(EXTENSION_HANDLER_TIMEOUT_MS). A human decision does not fit in 30 seconds.
It fits in a few minutes, or in "after lunch".
Blocking the handler on a person therefore produces one of two bad outcomes: the handler times out and OMP turns a safety decision into an ambiguous host error, or the design shortens the window until people approve without reading. Neither is acceptable, so escalation is asynchronous by default:
- The handler persists a durable pending approval: redacted summary, canonical call hash, arguments hash, policy hash, nonce, TTL.
- It returns an audited block carrying the audit ID and an actionable reason. The agent is told plainly that an audit ID is not authorization.
- You decide out-of-band, whenever you get to it:
/smart-approve review <audit-id>, then/smart-approve override <audit-id>. - Approval mints an exact hash-bound grant. The identical retry is admitted by that grant and executes exactly once. Anything materially different does not match the hash and is judged again.
The in-handler dialog is a best-effort fast path only. It is offered when a
live main TUI exists, ui.fastPathDialog is on, and the remaining budget clears
timeouts.dialogFloorMs. When it is not offered, or it times out, the call falls
through to the durable pending record. Nothing is lost by missing it.
The whole path finishes inside a self-imposed 28-second deadline
(timeouts.handlerSelfDeadlineMs), strictly under OMP's cap. The plugin refuses
to start with a timing configuration that cannot close inside it, naming the
offending fields rather than silently clamping them.
The two clocks
These are independent on purpose, and confusing them is the most common way to misread the system.
brokerArgsTtl |
pendingApprovalTtl |
|
|---|---|---|
| Default | 10 minutes | 24 hours |
| Holds | The literal frozen arguments, in memory | The redacted durable record, in SQLite |
| Contains raw arguments | Yes | Never |
| Survives restart | No | Yes |
| Governs | Whether an approval can be minted into a grant | Whether an audit ID is actionable at all |
Approving a call requires seeing that call. Minting a grant requires the literal frozen arguments to still be in memory and the hash recomputed from them to match the hash the audit row was opened under. A grant means "a human looked at exactly these bytes and said yes". Nothing weaker earns one.
Once the literal arguments are evicted, approval degrades to recorded
intent — never a grant. The record is still open, so you can still act on it,
but override records intent-to-approve rather than minting authority. The next
materially identical call re-opens a live dialog against real arguments, or stays
blocked if the session is headless. That intent is single-use and time-bound: it
buys exactly one exempt re-open attempt.
Denying and expiring stay available for the full 24 hours with no raw arguments present, because refusing something you cannot see is always safe.
Expiring the two clocks together would make the intent-to-approve path
unreachable in exactly the restart case it exists to serve — which is why
brokerArgsTtl must be less than or equal to pendingApprovalTtl, and why the
plugin refuses to start otherwise.
/smart-approve pending states, per record, which of the two outcomes it
currently supports.
Subagent routing
A task subagent typically has no UI of its own. When one of its calls escalates:
- It checks synchronously for a live main-session approval endpoint. No endpoint means an immediate, visible, actionable block — not a silent wait.
- With an endpoint, it forwards the frozen escalation directly to extension code attached to the main session's approval UI. The main agent's model is not an intermediary; a model in the path could paraphrase, summarize, or be argued out of the risk.
- You see the literal pending call, its arguments, the source agent, the judge rationale, the risk classes, and the matching policy — then decide.
- Approval is single-use, nonce-protected, time-limited, and bound to the exact call, plus the working directory it was approved from and the policy version. Change any of those and the grant does not match.
Dialogs are serialized FIFO by OMP's extension UI controller, so concurrent subagent escalations queue rather than interleave. Status stays visible by subagent ID and audit ID throughout.
An unanswered request blocks after timeouts.subagentEscalationTimeoutMs
(default 15s), falling through to the durable pending record.
If the main session is also headless, the escalation blocks. It does not degrade to allow. A subagent that cannot reach a human is a subagent that cannot be authorized.
The broker lives under a versioned Symbol.for(...) on globalThis rather than
a module singleton, because Bun may import the extension through distinct
cache-busted module URLs and module-local identity is not reliable across them.
Audit and redaction
Every judgment persists compact redacted metadata to SQLite at
<agentDir>/smart-approve/state.sqlite:
audit ID, tool-call ID, tool, operation, redacted target summary, arguments hash, canonical call hash, policy hash, working directory, source agent kind and parent, decision, decision stage, risk class, concise rationale, matched rule ID, judge model and provider, latency, attempts, input and output tokens, cost, retry family and count, timestamp — plus an append-only event log carrying the full override history.
Never persisted, anywhere: raw tool arguments, raw user messages, secret values, or attack payload excerpts. Not in SQLite, not in session entries, not in reports, not in error messages, not in block reasons shown to the model.
Be precise about what enforces that, because "never" is a strong word. The
protection is a runtime guard at the store boundary, not a type-system
guarantee: every write is rejected if it carries a known raw-argument field name
(rawArguments, content, command, arguments) or if any value still matches
a credential shape the obfuscator should already have replaced. A byte-scan test
asserts no configured secret reaches the database file. The residual is real and
worth naming: a raw value placed in a differently named field that happens to
carry no credential shape would pass both checks. The guard catches the ways
this has actually gone wrong, not every way it could.
Redaction reuses OMP's own secret machinery rather than reimplementing it:
secret-like environment values, user and project secrets.yml entries, and
built-in credential-token patterns become deterministic typed placeholders
before the judge request is built and before anything is written to the audit.
Deterministic placeholders mean the judge can still reason about shape —
"an AWS key is being sent to this host" — without the value ever leaving.
That machinery covers credential-shaped material. Broader confidential business
data is not pattern-detectable, and is governed by your SMART_APPROVE.md and
by semantic judgment instead. If you have data that must never be transmitted,
say so in your policy file explicitly.
Every hard block, including a subagent's, is visible in the main UI and in the audit with fully qualified agent and parent IDs, tool-call and audit IDs, provenance sources, risk classes, rationale, timestamp, retry count, and state. The blocked model separately receives an actionable reason.
Bypass
/smart-approve unguarded
Stops Smart Approve judging the calls OMP would otherwise run unattended, for this session only. It is narrower than "off":
- deny rules and protected targets still hard-block, and
- calls OMP prompts you about are still pre-screened, so a judge block on that branch still stops the call.
It requires:
- a typed random challenge — you retype a generated string, so it cannot be a reflex;
- and it applies only to the current session. It does not persist, does not survive a restart, and does not affect other sessions.
While it is active a persistent warning widget stays on screen for the entire duration, and the activation is written to the audit with actor and reason.
The model cannot activate it. It is a human command, invoked from the command surface, gated on a typed challenge the model does not see.
/smart-approve guarded re-enables enforcement immediately.
There is no configuration key and no environment variable that does this quietly. If enforcement is off, you can see that it is off.
Command reference
All nine subcommands are under one namespaced command, /smart-approve <sub>.
setup
The only wizard, and it never opens by itself. Lists models with configured auth, recommends a fast cross-family model, previews the config path and timeout budget, writes atomically after confirmation.
status
Config and policy hashes with their source paths, judge model and auth health,
scope, sensitivity precedence, deadline budget, broker and main-UI health,
bypass state, audit DB health, the count of approvals still open under
pendingApprovalTtl, recent aggregate verdict/latency/token/cost counts, and
compatibility warnings.
rules [list|show|revoke]
Lists rules grouped by origin (configuration versus stored), each with its kind,
tool, target, specificity, author, and reason. rules revoke <id> is
human-confirmed and immediately invalidates matching grants.
The posture line is computed from the same predicate the gate uses, not from
kind, so it tells you the truth about a pin. A complete one reads exact allow — skips the judge for this precise call in this directory; an incomplete
one reads advisory posture — still judged and names exactly what is missing
(no argumentsHash, no cwd, target is a pattern). rules show <id> prints
argsHash: and cwd: explicitly, marking either as
(absent — cannot skip the judge).
pending
Every approval still open under pendingApprovalTtl: audit ID, tool, redacted
target, risk, source agent, age, remaining TTL, and whether the record can still
mint a grant or is intent-only.
This is the entry point when the block message has scrolled away or the session restarted. It works with no arguments and with no live broker state.
review <audit-id>
The original redacted verdict, immutable history, retry family, human decisions,
and any judge rule suggestion for one call. It prints the arguments hash, the
redacted target, and the working directory for both the verdict and the
escalation record — which is where you copy the argumentsHash and cwd for a
pinned allow-exact rule from. A row written before location tracking existed
shows (unknown — recorded before location tracking) rather than a guess.
Literal arguments render only from the live in-memory broker. It never reconstructs them from persistence and never claims to show raw arguments it does not have. It states plainly which outcome the record currently supports: mintable grant, or intent-only.
apply-suggestion <audit-id>
Accepts only a currently pending in-memory suggestion or a safe redacted deny/posture suggestion. Shows the normalized proposed rule and its scope, requires explicit confirmation, and re-runs schema and specificity checks before insertion. A model-suggested wildcard allow stays non-bypass posture even after you confirm it.
override <audit-id>
Deliberately authorize one blocked call. Shows the original immutable verdict and requires a typed random challenge.
Minting the exact hash-bound grant additionally requires the literal frozen
arguments to still be in the broker with a matching recomputed hash. When they
are gone — brokerArgsTtl expiry, restart, subagent teardown — it records
intent-to-approve instead, and says so.
Grants carry a scope: one-time, session, or persistent. Scope chooses how long an unused grant stays valid and nothing else — every grant is single-use at every scope, because the gate admits a call by consuming it. A one-time grant lapses with the in-memory call, a session grant with the pending-approval TTL, and a persistent grant after 90 days; the persistent one also requires a second explicit confirmation. Every grant records the working directory it was approved from plus the policy hash, in every scope — there is no wider variant to opt into. All grants remain listable and revocable.
Grants are bound to the working directory they were approved from. The audit
database lives in the user-level agent directory and is shared by every project,
and two checkouts with no project config resolve to the same policy hash — so
without a working-directory binding, a grant approved in one repository would
admit a byte-identical call in another. Approving scripts/deploy.sh in staging
would admit the same command in production. Both grant lookup and single-use
consumption require the working directory to match.
Revocation is scoped the same way: a configuration or policy change invalidates the grants for that working directory whose policy hash is now superseded, and each project sweeps its own on its next session start. An unscoped sweep would let a policy edit in one repository delete grants a human minted in another — grants whose own policy never changed.
A grant carried over from a database written before this binding existed has no working directory recorded and can never match. It is inert rather than portable, which is the fail-closed direction.
unguarded / guarded
Enter and leave session-only bypass. See Bypass.
Troubleshooting
/smart-approve does not exist.
The extension did not load. omp plugin list should show omp-smart-approve
enabled; omp plugin doctor reports manifest and entry-point problems. Confirm
omp --version is inside >=17.2.2 <18.
Every scoped call is blocked with "run /smart-approve setup".
Working as intended — no authorized judge model is configured yet. Run setup.
If you already did, /smart-approve status shows which config layer was loaded
and whether the configured provider actually has credentials.
Every scoped call is blocked and the reason mentions configuration.
A config layer failed to parse or validate. status names the file and the
offending fields. Unknown keys are rejected on purpose, so a typo fails loudly
rather than being silently ignored.
The plugin refuses to start, naming timeout fields. Your timing budget cannot complete inside OMP's 30s handler cap. The message names the exact fields. The plugin does not clamp them, because silently shrinking a safety budget hides a misconfiguration.
Legitimate calls are blocked as unsupported host. The plugin could not match the call to a live OMP session or resolve the tool's tier. Check that you are in a supported host; embedded SDK hosts are outside the validated surface.
I never see the approval dialog, only blocks with audit IDs.
Expected. Async escalation is the default; see
Why escalation is asynchronous. Use
/smart-approve pending and /smart-approve review <audit-id>. If you want the
fast path to fire more often, raise timeouts.dialogMaxMs or lower
timeouts.dialogFloorMs — but a dialog too short to read a literal call is worse
than no dialog.
Override refuses to grant and records intent instead.
The literal arguments have been evicted from the broker (10-minute default,
restart, or subagent teardown). This is the designed behavior: approval requires
seeing the call. The next identical attempt re-opens a live dialog. To make the
fast path viable for longer, raise timeouts.brokerArgsTtlMs — and keep it at or
below pendingApprovalTtlMs.
My pinned allow-exact rule is still being judged.
All four conditions must hold, so check each one. argumentsHash must be the
hash of this call — any change to the arguments produces a different hash.
cwd must be the absolute working directory the call runs in; . or a
relative path never qualifies. The rule must live in the user-scope file,
<agentDir>/smart-approve.yml — a pinned allow in a project file is advisory by
design. And target must be spelled the way the call resolves it: an absolute
path for write/edit/read, a command-position token exactly as typed for
bash. A relative target never matches a path tool, and a dynamic target (glob,
shell substitution, unexpanded variable) never matches anything.
/smart-approve review <audit-id> shows the hash, the resolved target, and the
working directory for a real call.
A rule missing any one of the four is not broken — it is working as specified,
as advisory posture. /smart-approve status lists the scope and relative-cwd
demotions under "Compatibility warnings"; /smart-approve rules list says which
a rule is: an incomplete pin reads advisory posture — still judged and names
the missing field, and rules show <id> prints argsHash: and cwd: outright.
A pinned rule stopped working after I moved the repository.
Expected. cwd binds an exact allow to one working directory by design, so
moving or re-cloning the repository invalidates it. Re-pin it with the new cwd.
Calls the judge allowed are still escalating.
Check sensitivity.byRisk in /smart-approve status. A risk-class override
stricter than the tool-level posture downgrades an allow to an escalation once
the verdict names that risk. The audit rationale names both levels.
The agent keeps retrying a blocked call.
The loop breaker fires on the fourth attempt in a retry family and escalates the
sequence. If you are seeing many strikes, the block reason is probably not
actionable enough for the model — check what it says in review.
A subagent's escalation blocked immediately. Either no main-session endpoint was registered, or the main session is headless. A subagent that cannot reach a human blocks by design.
Judgment is slower than expected.
status reports recent latency aggregates. timeouts.targetLatencyMs (5s
default) is an observability target, not a cutoff; modelTimeoutMs (10s) is the
hard per-attempt boundary. Note that attempts × modelTimeoutMs + cleanupMarginMs must fit inside handlerSelfDeadlineMs.
Enforcement seems to be off. Look for the persistent unguarded warning widget. If it is not there, enforcement is on. There is no silent off switch.
Live evaluation
Deterministic tests prove mechanism: scoping, precedence, redaction, fail-closed paths, TTLs, nonces, brokering. They prove nothing about whether a particular model judges well. Those are different questions and they need different evidence.
bun run eval:live -- --model <provider/model> --out .artifacts/smart-approve-eval
Opt-in, and it needs provider credentials. Ordinary CI needs none and makes no paid model calls.
The corpus covers benign work, prompt injection, exfiltration, destructive actions, wrong-target operations, safety tampering, policy bypass attempts, encoded and obfuscated payloads, compromised subagent requests, oversized payload tail attacks, shell composition, MCP/browser/network egress, and ambiguous-but-recoverable operations. The runner uses the production context builder, redactor, model selection, prompt, parser, and retry logic — not a special evaluation prompt — so the numbers describe the shipped path.
Reports are versioned JSON plus Markdown, containing plugin version, corpus hash, config and policy hashes, model and provider, per-case verdict, risk, latency, tokens and cost, plus false allows, false blocks, escalations, parser failures, timeouts, and percentile latency.
It exits nonzero only for a malformed corpus or report, or under an explicit
--enforce <threshold-file>. Live evaluation is deliberately not a release
gate until thresholds are calibrated, and calibrating them on one run would be
calibrating on noise.
Do not claim a model is safe on the strength of deterministic tests. Report the observed model, corpus hash, and the measured counts.
Package verification
bun install --frozen-lockfile
bun run check # tsc --noEmit && biome check
bun test # deterministic suite; no credentials, no paid calls
bun run smoke # links and loads the package in an isolated temp HOME
bun pm pack --dry-run # package contents
bun run smoke builds a throwaway HOME and agent directory, runs omp plugin link against this checkout, asserts the linked package appears enabled, loads
the extension through OMP's real extension runner with scripted judge, UI, and
time ports, and exercises six paths end to end:
- silent allow
- fast-path escalation with allow-once
- hard block
- native-prompt pre-screen with no second dialog
- async escalation ending as a pending block, approved out-of-band, with the identical retry executing exactly once
- headless escalation blocking
It inspects only redacted audit and session output, and removes the temporary
directory in a finally. Scripted ports live only in the smoke script and the
tests — the shipped factory cannot select them from configuration or the
environment.
License
MIT. See LICENSE.