OpenWar's Phase 3 destructive-action gate has always existed: a tool call requires authorization, and unauthorized calls halt the runtime for an operator decision. The gate was reactive. The agent attempted; the gate reacted.
PermissionBridge gives the agent (or any bridged CLI) a structured way to ask BEFORE acting. The operator answers with structure too. Phase 3 still fires when no grant matches. Nothing relaxes; the conversation gets articulate.
- Agent calls
request_permission(new native tool, also exposed via MCP asopenwar:request_permission) before a destructive action. - Runtime prompts the operator with a multi-line form: action, reason, fallback, requested scope.
- Operator approves at the requested scope (
y), at session scope (s), at persistent scope (p), or denies (n/n: <note>). - Approved requests register a grant in the per-session
GrantLedger. - Next time the agent makes a destructive tool call, Phase 3 checks the ledger first. A matching grant skips the operator prompt and lets the call proceed; the runtime emits
permission_grant_consumedfor the audit trail. - Persistent grants survive the session via
~/.openwar/projects/<slug>/permission_grants.jsonl. The next run for the same project starts with them already in the ledger.
request_permission accepts the following arguments:
| Field | Required | Description |
|---|---|---|
action |
yes | Concrete description of what the agent wants to do. The operator reads this verbatim; write it like you would explain to a peer. |
reasoning |
yes | Why the action is needed. One or two sentences. |
scope |
no | One of this_call, this_session, persistent. Defaults to this_call. |
fallback |
no | What the agent will do if denied. Helps the operator gauge cost of denial. |
category |
no | Auth category tag for grant matching (e.g. filesystem_write, shell_exec). |
Response (returned to the agent as the tool result):
On denial, granted: false, no scope_granted, an operator_note (which may be empty), and a placeholder grant_id. Denial is NOT an error; the agent decides whether to retry, switch approach, or report back. Halting is operator-side via /revoke or by closing the session.
The tool has authorization_categories: [] (default-allowed). Requesting permission is never itself destructive.
| Scope | Lifetime | Behavior on Phase 3 match |
|---|---|---|
this_call |
Exactly one upcoming destructive tool call | Consumed on first match (flips consumed=true); no longer matches afterward |
this_session |
Until session end or revocation | Matches every Phase 3 fire whose category overlaps; never gets consumed |
persistent |
Until explicit revocation (across sessions) | Same as this_session, plus serialized to disk for the next session |
When persistent is requested but the run has no project_slug, the scope silently degrades to this_session. The operator sees this in the tool-result scope_granted field; the agent can act on it.
A grant matches an upcoming destructive tool call by category overlap. There is no literal action-string matching: Phase 3 sees structured tool calls (tool name + args), not free text, so matching the action string against a tool call's actual operation would be unreliable.
Match rules:
this_call+ category supplied: matches the next Phase-3-firing tool call whose required auth category overlaps the grant category. Non-destructive tool calls (read_file, etc.) between request and match do NOT consume the grant.this_call+ no category: matches the next Phase-3-firing tool call regardless of category. Use sparingly. The agent is saying "the very next risky thing I'm about to do".this_session/persistent+ category: matches every Phase-3-firing call with a matching category until session end or revocation.this_session/persistent+ no category: matches every Phase-3-firing call. Broad on purpose. Operators approving a session-scope or persistent grant without a category are saying "I trust this agent with arbitrary destructive actions in this scope". Be deliberate.
If multiple grants could match a call, the runtime prefers the most recently granted unconsumed this_call grant over this_session / persistent grants. The intent reading is "I just asked; let me do it." After all this_call grants are exhausted, session and persistent grants apply.
When the agent calls request_permission, the chat REPL renders:
Permission request from agent:
ACTION Delete the file src/legacy.ts
REASON File is unreferenced; cleaning up before the refactor.
FALLBACK Skip the cleanup; refactor proceeds with the file present.
CATEGORY filesystem_write
REQUESTED SCOPE this_call
Approve at what scope?
y grant at requested scope (this_call)
s grant for the rest of this session
p grant persistently (saved to project memory)
n deny
n: <msg> deny with a note for the agent
>
In headless openwar run against a TTY, the same prompt renders to stderr. Against a non-TTY (CI, redirected stdin), the runtime denies-by-default with operator_note: "no interactive operator available". The agent reads the denial and decides whether to fall back or escalate.
The chat REPL adds two operator-only commands:
/grantslists active grants in the current run (scope,category,action,reason,granted_at). Shows consumedthis_callgrants too so the operator can see what was used./revoke <grant_id>revokes a grant. Persistent grants are also marked revoked on disk. The agent will get re-prompted next time it requests a matching action.
Active grants are also surfaced after-the-fact via openwar inspect <brief_id> --permissions.
The integration is a single new branch in the dispatch path at src/phases/execute.ts. Before halting on an unauthorized tool call, the runtime queries ctx.grantLedger.findMatchingGrant(missing_categories). If a match is returned:
permission_grant_consumedis emitted into the trace with the grant id and the consuming tool call id.- An
auth_check_firedallow event is also emitted (soinspect --toolsshows the call as authorized). - The grant is consumed (for
this_call) and the dispatcher continues to the executor.
If no match, the existing Phase 3 halt path runs (operator prompt for y / Y / n).
Phase 3 still fires when no grant matches. PermissionBridge is an upstream hint, not a bypass.
Five new event types (all additive; TRACE_SCHEMA_VERSION bumped to 3):
| Event | Fields |
|---|---|
permission_requested |
grant_id, action, category, scope_requested, reasoning, fallback, at |
permission_granted |
grant_id, scope_granted, operator_note, at |
permission_denied |
grant_id, operator_note, at |
permission_grant_consumed |
grant_id, consuming_tool_call_id, at |
permission_revoked |
grant_id, revoked_at |
Read them via openwar inspect <brief_id> --permissions for a per-grant audit row (status, scope, category, action, timestamp) or openwar inspect <brief_id> --trace for the raw event stream.
Persistent grants live at ~/.openwar/projects/<slug>/permission_grants.jsonl. The file is append-only, same shape contract as the v0.6 memory store and v0.8 trace ndjson:
{ "v": 1, "op": "grant", "grant_id": "...", "action": "...", "category": "...", "scope": "persistent", "reasoning": "...", "granted_at": "2026-05-19T..." }
{ "v": 1, "op": "revoke", "grant_id": "...", "revoked_at": "2026-05-19T..." }Mid-line corruption skips that line; the rest of the file still loads. Write failures emit a one-time stderr warning and keep the grant in memory for the current session only. There is no TTL; persistent grants live until explicitly revoked.
Library consumers see the new API via the existing Session interface returned through RunOptions.onSession:
interface Session {
// v0.11.1 surface (unchanged)
cancelCurrentToolCall(): Promise<boolean>;
// v0.12.0 additions
listActiveGrants(): readonly Grant[];
revokeGrant(grant_id: string): boolean;
}Programs that drive their own grant flow (custom UIs, web bridges, integration tests) can read listActiveGrants() for state and call revokeGrant() to invalidate. The chat REPL uses the same surface to back /grants and /revoke.
agent calls openwar:request_permission with:
action: "Overwrite docs/api.md with the regenerated content"
category: "filesystem_write"
scope: "this_call"
reasoning: "the source schema changed; the docs are stale"
fallback: "skip the regen; flag the staleness in the next handoff"
operator sees the prompt, types: y
-> grant registered (this_call, filesystem_write)
-> agent receives { granted: true, scope_granted: "this_call", grant_id: "..." }
agent calls write_file (which requires filesystem_write):
-> Phase 3 would normally halt
-> grant ledger returns the grant; permission_grant_consumed emitted
-> write proceeds; grant is now consumed
next time the agent calls write_file, Phase 3 halts again (no matching grant).
agent calls openwar:request_permission with:
action: "Append release notes to RELEASES.md whenever I publish"
category: "filesystem_write"
scope: "persistent"
reasoning: "this is the standard publish-notes flow"
operator types: p
-> persistent grant registered + serialized to
~/.openwar/projects/<slug>/permission_grants.jsonl
(later, in a different session for the same project)
-> GrantLedger seeds the in-memory ledger from disk
-> agent's filesystem_write tool calls in that session are auto-authorized
by category match until the grant is revoked
operator runs: /revoke <grant_id>
-> revoke row appended to the JSONL store
-> grant marked revoked; further filesystem_write calls hit Phase 3 again
- Not auto-approval rules. v0.12 is interactive only. A future release may add
~/.openwar/policies.jsonfor rules like "auto-grant filesystem_write under /tmp/*", but that is its own design. - Not delegation across sessions. A
this_sessiongrant cannot be promoted to another session; each session has its own ledger. Persistent grants are the supported cross-session surface. - Not encrypted. Persistent grants live in plain JSONL like the rest of v0.6 project memory.
- Not multi-operator. Single operator only.
- Not a bypass for Phase 3. The gate still fires whenever no grant matches. Cancellation of a destructive call in flight is still a separate concern (v0.11.1).
{ "granted": true, "scope_granted": "this_call", "operator_note": "", "grant_id": "<uuid>" }