Skip to content

feat: add runtime permission decisions - #95

Merged
Vasanthdev2004 merged 1 commit into
mainfrom
feat/runtime-permission-bootstrap
Jun 6, 2026
Merged

feat: add runtime permission decisions#95
Vasanthdev2004 merged 1 commit into
mainfrom
feat/runtime-permission-bootstrap

Conversation

@Vasanthdev2004

@Vasanthdev2004 Vasanthdev2004 commented Jun 6, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • add a blocking runtime permission request/decision contract for prompt-gated tools
  • wire TUI allow / deny / always flows into the agent loop, including persistent sandbox grants
  • split stream-json and session permission lifecycle into permission_request and permission_decision
  • document the stream-json permission lifecycle and headless prompt behavior

Tests

  • git diff --check
  • go test ./...
  • go build ./cmd/zero

Notes

  • Subagents covered headless/session and TUI slices, then a final read-only review approved the combined diff.
  • Headless exec still fails closed for prompt-gated tools unless already approved; interactive TUI now supplies decisions.

Summary by CodeRabbit

  • New Features

    • Implemented interactive permission prompts with allow/deny/always-allow options.
    • Added explicit permission_request and permission_decision events to the protocol.
    • Permission decisions now include reasoning details.
    • Introduced permission grant persistence for repeated tool access.
  • Documentation

    • Updated Stream-JSON protocol examples with permission request/decision events.

@github-actions

github-actions Bot commented Jun 6, 2026

Copy link
Copy Markdown
Contributor

Zero automated PR review

Verdict: No blockers found

Blockers

  • None found.

Validation

  • [pass] Diff hygiene: git diff --check
  • [pass] Tests: go test ./...
  • [pass] Build: go run ./cmd/zero-release build
  • [pass] Smoke build: go run ./cmd/zero-release smoke

Scope

Head: 28493d3eff96
Changed files (18): docs/STREAM_JSON_PROTOCOL.md, internal/agent/loop.go, internal/agent/loop_test.go, internal/agent/types.go, internal/cli/exec.go, internal/cli/exec_protocol_test.go, internal/cli/exec_writer.go, internal/sandbox/engine.go, internal/sessions/replay.go, internal/sessions/store.go, internal/streamjson/streamjson.go, internal/streamjson/streamjson_test.go, and 6 more

This deterministic review checks validation status and basic diff hygiene. A human reviewer still owns product judgment and design quality.

@coderabbitai

coderabbitai Bot commented Jun 6, 2026

Copy link
Copy Markdown

Ready to act? Review this PR in Change Stack to turn feedback into patch suggestions you can inspect and refine.

Review Change Stack

Walkthrough

This PR implements an interactive permission request and decision workflow. It introduces new permission event types (request vs. decision), adds sandbox-based permission gating to the agent, surfaces in-view permission prompts in the TUI, and updates the session/exec event pipelines to properly classify and persist these permission events.

Changes

Permission Request/Decision Workflow

Layer / File(s) Summary
Permission decision types and contracts
internal/agent/types.go
Introduce PermissionDecisionAction enum and constants (allow, deny, always_allow), new PermissionRequest and PermissionDecision structs, extend PermissionEvent with decision outcome metadata, and add OnPermissionRequest callback to Options.
Core agent permission request/decision logic
internal/agent/loop.go
Implement sandbox-based permission preflight evaluation in executeToolCall: compute permission decision, optionally request approval via OnPermissionRequest, normalize the decision action, persist always-allow grants, and emit standardized denial events/results. Introduce helpers for sandbox request construction, permission gating predicate, decision normalization, grant persistence, and result/event building.
Agent permission request/decision tests
internal/agent/loop_test.go
Validate permission request invocation before tool execution, denial when request is denied (with both generic and custom denial messages), and grant persistence when decision is always-allow (verifying sandbox store lookup and final event metadata).
Session and streamjson event type split
internal/sessions/store.go, internal/streamjson/streamjson.go, internal/streamjson/streamjson_test.go, internal/sessions/replay.go
Add EventPermissionRequest and EventPermissionDecision constants to session event type enum, extend streamjson Event struct with optional DecisionReason field, update payload preview routing to handle new event types, and add serialization test for decision reason.
Sandbox grant persistence method
internal/sandbox/engine.go
Add Engine.Grant method to delegate grant operations to the configured grant store, supporting persistence of always-allow decisions.
CLI/exec permission event classification and output
internal/cli/exec.go, internal/cli/exec_writer.go, internal/cli/exec_protocol_test.go
Classify permission events dynamically (request/decision/generic) when recording session events and emitting stream-json output. Update JSON and streamjson permission payloads to include decision_reason field. Update exec protocol tests to expect permission_request and permission_decision event types and validate decision reason serialization.
TUI interactive permission prompt UI
internal/tui/model.go, internal/tui/rendering.go, internal/tui/transcript.go, internal/tui/session.go
Add pending permission state to model and focused prompt rendering in transcript view. Implement permission request message handling with user decision callback (triggered by a/d/y keys for allow/deny/always-allow). Route OnPermissionRequest callback through buffered channel to receive user decision. Update transcript row key generation to include permission action for uniqueness. Add permissionEventFromRequest helper to convert requests into events. Expand session event handling to recognize new permission event types and deserialize decisionReason from payload.
TUI model-level permission prompt tests
internal/tui/model_test.go
Test permission request synchronous state update and focused prompt rendering with expected choices and metadata. Verify decision key resolution (a, d, y) triggers exactly one decision and clears pending state. Confirm normal submit (Enter) is ignored while prompt is pending. Include helpers for constructing prompt-style permission events and requests.
TUI session-level permission integration tests
internal/tui/session_test.go
Test full permission flow in session context with refactored async message driving (buffered channel). Validate permission request and decision event sequence in persisted session, grant persistence causing later prompts to be skipped, and both generic and custom decision reasons. Add comprehensive helpers for permission-aware test setup, deterministic event scripting, and session event querying.
Stream-JSON protocol documentation
docs/STREAM_JSON_PROTOCOL.md
Document permission request/decision event sequence in Output Events example and clarify difference between headless exec (emits request + denied result if not pre-approved) and interactive surfaces (emit decision upon user action).

Sequence Diagram(s)

sequenceDiagram
  participant Agent as agent.Run
  participant ToolExec as executeToolCall
  participant SandboxDecider as sandbox.Engine
  participant UserCaller as OnPermissionRequest
  participant SessionLog as session recorder
  participant OnPermissionEvent as OnPermission

  Agent->>ToolExec: execute write_file
  ToolExec->>SandboxDecider: compute preflight decision
  SandboxDecider-->>ToolExec: decision (requires approval)
  ToolExec->>UserCaller: request permission with metadata
  alt User grants permission
    UserCaller-->>ToolExec: PermissionDecisionAllow
    ToolExec->>SessionLog: append permission_decision event
  else User always-allows
    UserCaller-->>ToolExec: PermissionDecisionAlwaysAllow
    ToolExec->>SandboxDecider: persist grant via Grant()
    ToolExec->>SessionLog: append permission_decision event
  else User denies
    UserCaller-->>ToolExec: PermissionDecisionDeny
    ToolExec->>SessionLog: append permission_decision event (denied)
    ToolExec-->>Agent: ToolResult with error
  end
  ToolExec->>OnPermissionEvent: emit with DecisionReason
  OnPermissionEvent-->>Agent: PermissionEvent recorded
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

  • Gitlawb/zero#82: Modifies core agent permission event pipeline (loop.go sandbox-driven events, types.go permission models) that this PR builds upon.
  • Gitlawb/zero#77: Introduces sandbox engine integration and grant store that this PR uses for persisting always-allow decisions.
  • Gitlawb/zero#93: Updates TUI session/transcript handling to surface sandbox permission information; overlaps with TUI permission state and session event splitting.

Suggested reviewers

  • gnanam1990
  • anandh8x
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 1.49% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: add runtime permission decisions' directly and clearly summarizes the main change: introducing a runtime permission request/decision flow for prompt-gated tools across headless and interactive contexts.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/runtime-permission-bootstrap

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (4)
internal/agent/loop.go (2)

387-396: 💤 Low value

Shallow clone of args map

cloneArgs creates a shallow copy—if args contains nested maps or slices (e.g., from JSON arrays/objects), mutations to the clone's nested values would affect the original. In practice this is unlikely since JSON-decoded tool arguments are typically primitives, but worth noting if the args structure becomes more complex.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/agent/loop.go` around lines 387 - 396, cloneArgs currently performs
a shallow copy which will allow nested maps/slices in args to remain shared;
update cloneArgs to perform a deep copy of the args map (recursively clone maps
and slices and copy primitive values) so mutations to nested structures in the
returned map do not affect the original; you can implement this by writing a
helper that inspects values and recursively clones map[string]any and []any, or
by using JSON marshal/unmarshal as a simple deep-clone fallback, ensuring
cloneArgs returns an independent structure.

266-293: 💤 Low value

Dead code in event construction

Lines 274-283 copy requestEvent into a local event and modify its Risk and ToolName fields, but neither is used—only event.Action is read for the Meta map. The Risk and ToolName assignments have no effect on the returned ToolResult.

Simplify by removing unused assignments
 func deniedPermissionResult(call ToolCall, reason string, requestEvent PermissionEvent) ToolResult {
 	reason = strings.TrimSpace(reason)
 	if reason == "" {
 		reason = requestEvent.Reason
 	}
 	if reason == "" {
 		reason = "tool requires approval before execution"
 	}
-	event := requestEvent
-	event.Action = PermissionActionDeny
-	event.PermissionGranted = false
-	event.DecisionReason = reason
-	if event.Risk.Level == "" {
-		event.Risk = sandbox.Risk{Level: sandbox.RiskMedium, Reason: reason}
-	}
-	if requestEvent.ToolName == "" {
-		event.ToolName = call.Name
-	}
 	return ToolResult{
 		ToolCallID: call.ID,
 		Name:       call.Name,
 		Status:     tools.StatusError,
 		Output:     "Error: Permission denied for " + call.Name + ": " + reason,
 		Meta: map[string]string{
-			"permission_action": string(event.Action),
+			"permission_action": string(PermissionActionDeny),
 		},
 	}
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/agent/loop.go` around lines 266 - 293, The helper
deniedPermissionResult constructs a local copy event := requestEvent and mutates
event.Risk and event.ToolName even though only event.Action is read for the Meta
map and the mutated fields are never used; remove the unused assignments
(event.Risk = ... and event.ToolName = ...) and the unnecessary copy if you only
need to set Action for the meta, ensuring the Meta still contains
permission_action using either requestEvent.Action or setting event.Action
directly before building the ToolResult so the function remains correct and
side-effect free.
internal/sandbox/engine.go (1)

107-112: ⚡ Quick win

Add context parameter for cancellation and timeout support.

Grant storage may involve I/O (disk, database). Without a context parameter, the operation cannot be cancelled if the parent context is cancelled, and no deadline/timeout can be enforced. This risks resource leaks if storage blocks.

The Evaluate method (line 36) takes a context, and the OnPermissionRequest callback (types.go:94) receives one—Grant should follow the same pattern.

🔧 Proposed fix to add context parameter
-func (engine *Engine) Grant(input GrantInput) (Grant, error) {
+func (engine *Engine) Grant(ctx context.Context, input GrantInput) (Grant, error) {
 	if engine == nil || engine.store == nil {
 		return Grant{}, errors.New("sandbox grant store is not configured")
 	}
-	return engine.store.Grant(input)
+	return engine.store.Grant(ctx, input)
 }

Note: This assumes GrantStore.Grant will also be updated to accept a context. If the store interface doesn't yet support context, update it as well for consistency.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/sandbox/engine.go` around lines 107 - 112, The Grant method lacks a
context parameter—update Engine.Grant to accept a context.Context (e.g., func
(engine *Engine) Grant(ctx context.Context, input GrantInput) (Grant, error)),
update any call sites, and propagate that context into the underlying store call
by changing the store interface method Grant to accept a context
(GrantStore.Grant(ctx context.Context, input GrantInput) (Grant, error)) and
calling engine.store.Grant(ctx, input); also mirror the pattern used by Evaluate
and the OnPermissionRequest callback (types.go:94) so cancellations/timeouts are
honored end-to-end.
internal/tui/model.go (1)

524-552: 💤 Low value

Session logging skips permission request when custom handler provided.

If onPermissionRequest is not nil (line 526), the early return at line 527 bypasses the sessionEvents append at lines 539-542, so the session won't record an EventPermissionRequest. The OnPermission callback (line 609) will still log the permission event, but session replay won't show the request phase—only the decision outcome.

This is acceptable for custom handlers (testing or embedding scenarios), but means session event streams differ based on whether a custom handler is active. If session replay fidelity is critical, consider always logging the request before delegating, or document this behavior clearly.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/tui/model.go` around lines 524 - 552, The permission request is not
being appended to sessionEvents when a custom handler (onPermissionRequest) is
present; change the flow in the OnPermissionRequest wrapper so the
pendingSessionEvent for sessions.EventPermissionRequest is recorded
unconditionally before delegating to onPermissionRequest or performing the
default UI flow. Specifically, ensure the sessionEvents append of
pendingSessionEvent{Type: sessions.EventPermissionRequest, Payload: request}
occurs before the onPermissionRequest != nil check (or duplicate the append into
both branches) and keep existing references to runID, m.sendPermissionRequest,
decisionCh, and the permission decision handling intact.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@internal/agent/loop.go`:
- Around line 387-396: cloneArgs currently performs a shallow copy which will
allow nested maps/slices in args to remain shared; update cloneArgs to perform a
deep copy of the args map (recursively clone maps and slices and copy primitive
values) so mutations to nested structures in the returned map do not affect the
original; you can implement this by writing a helper that inspects values and
recursively clones map[string]any and []any, or by using JSON marshal/unmarshal
as a simple deep-clone fallback, ensuring cloneArgs returns an independent
structure.
- Around line 266-293: The helper deniedPermissionResult constructs a local copy
event := requestEvent and mutates event.Risk and event.ToolName even though only
event.Action is read for the Meta map and the mutated fields are never used;
remove the unused assignments (event.Risk = ... and event.ToolName = ...) and
the unnecessary copy if you only need to set Action for the meta, ensuring the
Meta still contains permission_action using either requestEvent.Action or
setting event.Action directly before building the ToolResult so the function
remains correct and side-effect free.

In `@internal/sandbox/engine.go`:
- Around line 107-112: The Grant method lacks a context parameter—update
Engine.Grant to accept a context.Context (e.g., func (engine *Engine) Grant(ctx
context.Context, input GrantInput) (Grant, error)), update any call sites, and
propagate that context into the underlying store call by changing the store
interface method Grant to accept a context (GrantStore.Grant(ctx
context.Context, input GrantInput) (Grant, error)) and calling
engine.store.Grant(ctx, input); also mirror the pattern used by Evaluate and the
OnPermissionRequest callback (types.go:94) so cancellations/timeouts are honored
end-to-end.

In `@internal/tui/model.go`:
- Around line 524-552: The permission request is not being appended to
sessionEvents when a custom handler (onPermissionRequest) is present; change the
flow in the OnPermissionRequest wrapper so the pendingSessionEvent for
sessions.EventPermissionRequest is recorded unconditionally before delegating to
onPermissionRequest or performing the default UI flow. Specifically, ensure the
sessionEvents append of pendingSessionEvent{Type:
sessions.EventPermissionRequest, Payload: request} occurs before the
onPermissionRequest != nil check (or duplicate the append into both branches)
and keep existing references to runID, m.sendPermissionRequest, decisionCh, and
the permission decision handling intact.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 5988602b-0165-4cd9-b9d9-699c1d62e196

📥 Commits

Reviewing files that changed from the base of the PR and between 90b3cf1 and 28493d3.

📒 Files selected for processing (18)
  • docs/STREAM_JSON_PROTOCOL.md
  • internal/agent/loop.go
  • internal/agent/loop_test.go
  • internal/agent/types.go
  • internal/cli/exec.go
  • internal/cli/exec_protocol_test.go
  • internal/cli/exec_writer.go
  • internal/sandbox/engine.go
  • internal/sessions/replay.go
  • internal/sessions/store.go
  • internal/streamjson/streamjson.go
  • internal/streamjson/streamjson_test.go
  • internal/tui/model.go
  • internal/tui/model_test.go
  • internal/tui/rendering.go
  • internal/tui/session.go
  • internal/tui/session_test.go
  • internal/tui/transcript.go

@anandh8x anandh8x left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's good

  • Clean separation of PermissionRequest vs PermissionEvent vs PermissionDecision. The request is the prompt context (args, risk, violation, side effect); the event is the post-decision record (action, decision reason, persisted grant); the decision is the responder's reply (action + reason). Three distinct types with three distinct roles — exactly what the protocol and the session store need.

  • OnPermissionRequest is a well-typed callback seam. Signature func(context.Context, PermissionRequest) (PermissionDecision, error) is right: context for cancellation, request for context, decision for the result, error for responder failure. The TUI returns synchronously, the headless exec returns a deny-by-default, tests inject a fake. Clean.

  • Synchronous TUI decision via buffered channel. The TUI's OnPermissionRequest wrapper creates a size-1 channel, sends a permissionRequestMsg to the runtime sink, and blocks on the channel until either a decision arrives or the context is canceled. The buffer size of 1 prevents the channel send from blocking if the agent runtime has given up. case <-ctx.Done(): returns a deny with the cancellation error. The pattern is correct and leak-free (the wrapper owns the channel lifetime).

  • Default deny when sink is unavailable. if m.runtimeMessageSink == nil { return ...deny... } ensures the agent runtime never blocks forever. The TUI always has a sink in practice, but the safety net is real.

  • pendingPermission is cleared on both cancelRun and agentResponseMsg. No prompt state leaks across runs.

  • KeyEnter is blocked when pending, and all other keys route through handlePermissionKey. The prompt is modal — only a/d/y (case-insensitive) are valid; everything else is a no-op. The TestPermissionPromptBlocksNormalSubmit test locks the Enter-blocking behavior.

  • Dedup key update allows both request and decision rows for the same ToolCallID. Old key was kind:ToolCallID; new is kind:ToolCallID:Action. Without this, the decision row would be silently dropped. The change is correct because a prompt and an allow are different events.

  • Stream JSON protocol gets explicit event types. permission_request and permission_decision are distinct from the generic permission (which is preserved for backward compatibility). Protocol clients can branch on the type. The docs example shows both new event shapes with permission: "prompt" and action: "allow".

  • Headless behavior is documented. The protocol doc explicitly states "Headless exec has no interactive permission responder. If a prompt-gated tool is not pre-approved, Zero may emit permission_request followed by a denied tool_result". Critical for automation consumers who would otherwise think the run is hung.

  • Session events mirror the protocol. EventPermissionRequest and EventPermissionDecision join the existing EventPermission. The TUI's tuiPermissionEventType and the CLI's sessionPermissionEventType apply the same Action→EventType mapping. transcriptRowsFromSessionEvents hydrate handles all three types.

  • shouldRequestPermission correctly gates the prompt. No request is made if the tool is not PermissionPrompt, or already granted (unsafe mode / grant match), or the sandbox preflight already decided (allow/deny). Only when the sandbox is silent AND the tool is a prompt tool AND the user hasn't granted it does the TUI get asked.

  • normalizePermissionDecisionAction defaults unknown actions to deny. Safe default; the three valid actions are explicitly allowed.

  • deniedPermissionResult returns a StatusError tool result with a clear message. The LLM sees "Error: Permission denied for write_file: " on the next turn and can adapt.

  • OnPermission is called with DecisionReason populated on the final event. The TUI's renderPermissionRow shows the reason ("denied in TUI" / "approved in TUI" / "persistently approved in TUI"). Good.

  • persistPermissionGrant defaults MaxAutonomy to Medium if options.Autonomy == "". Reasonable default. The grant is bound to the current autonomy ceiling, which is correct semantics.

  • Engine.Grant(input) is a thin wrapper over store.Grant(input). Keeps the engine as the only entry point for grant operations. Tested transitively by TestRunPersistsAlwaysAllowPermissionDecision.

  • TestPermissionPromptAlwaysPersistsGrantAndSkipsLaterPrompt is the strongest test. Full flow: prompt → y → grant persisted → tool runs → second turn → grant matches → no prompt → tool runs. The session events assert only the first run requested permission (EventPermissionRequest count = 1) but both recorded decisions (EventPermissionDecision count = 2). This is the user's mental model of "always allow" and the test locks the contract.

  • receiveRuntimeMessage / receiveFinalMessage test helpers use time.After(5 * time.Second). Prevents test deadlock on broken wiring. Defensive test pattern.

  • Callback chains are preserved. OnPermissionRequest and OnPermission both wrap the previous option callback (if onPermissionRequest != nil { onPermissionRequest(...) } / if onPermission != nil { onPermission(event) }), so future test seams can observe the same events without being silently swallowed.

  • TUI prompt rendering uses theme colors. Amber for "permission required", muted for details, text for choice labels. renderFocusedPermissionPrompt reuses the existing borderedBlock helper.

Observations (non-blocking)

  1. Duplicated Action→EventType mapping in three places. sessionPermissionEventType (CLI exec), tuiPermissionEventType (TUI), and streamJSONPermissionEventType (CLI stream JSON writer) all do the same mapping. A shared helper in agent (e.g., agent.EventTypeForPermissionAction(action)) and a parallel one for stream JSON would deduplicate. Not blocking.

  2. Headless exec has no --non-interactive-permissions=allow|deny flag. The TUI's "always allow" path is unavailable in headless mode — prompt tools just get denied. Automation users would benefit from a CLI flag that pre-approves prompt tools (with the same grant persistence) or denies them explicitly. A follow-up slice, not this one.

  3. renderFocusedPermissionPrompt does not show the tool call args. The TUI shows the reason and side effect, but not the path/content for write_file or the command for bash. request.Args is now available (added in this PR) but not rendered. A future TUI improvement could surface a detail block with the args for triage.

  4. renderFocusedPermissionPrompt is shown only when m.pending is true. Defensive invariant: pendingPermission != nil => pending == true. The current code maintains this (the wrapper sets both), but a future refactor that toggles pending without clearing pendingPermission could break it.

  5. Esc/Ctrl+C cancel the prompt silently. The TUI's existing cancel button works (clears pendingPermission), but the user doesn't see a confirmation that the decision was dropped. A small "permission request canceled" line in the transcript would help trust.

  6. cloneArgs is a shallow copy. A nested map or slice in the args would be shared. For the current tool set (read_file, write_file, bash) the args are flat JSON values, so shallow copy is fine. A future tool with nested objects would need deep copy.

  7. deniedPermissionResult and emitDeniedPermission share the "set action to deny, set permissionGranted to false, set decision reason" pattern. They could share a helper. Minor.

  8. PermissionRequest.GrantMatched and Grant fields are present but unused in the current request path. The preflight decision either grants (skipping the request) or doesn't (request is built without grant context). The fields are there for completeness — a doc comment on the field would explain.

  9. No test for Engine.Grant directly. Covered transitively by TestRunPersistsAlwaysAllowPermissionDecision. A focused unit test for Engine.Grant would assert the nil-engine and nil-store error paths.

  10. No test for OnPermissionRequest returning an error. The loop converts an error into a deny, but no test exercises that path. A test that injects a callback returning (PermissionDecision{}, errors.New("test")) and asserts the deny is emitted would lock the safety net.

  11. Protocol naming is slightly asymmetric. The permission_request example shows permission: "prompt" (the type) and the permission_decision example shows action: "allow" (the action). One field is the type, the other is the action. A doc note explaining the asymmetry would help consumers parsing the JSON.

  12. TestPermissionPromptBlocksNormalSubmit could also assert the input is preserved. m.input still contains "second prompt" after the blocked Enter — confirming the user's typed text isn't lost. Minor tightening.

  13. m.cancelRun clears pendingPermission without calling the decide callback. The pending decision is silently dropped. The agent runtime sees a context cancellation via the channel's ctx.Done() case. Consistent and correct, but worth a doc comment on cancelRun to explain the contract.

  14. No benchmark for the channel-based decision flow. A microbenchmark would confirm the channel is fast enough for typical use. Not a concern in practice.

  15. OnPermissionRequest blocks the agent loop. That's the design, but a slow responder would block the entire agent run. The TUI's channel pattern is correct; the ctx.Done() case handles cancellation. Worth noting for future async-responder work.

Approving — the slice is well-architected. The new types are clean, the callback seam is well-typed, the synchronous TUI decision via channel is correct, the dedup key update allows both request and decision rows, the stream JSON protocol gets explicit event types, the session events mirror the protocol, and the headless behavior is documented. The tests cover the full flow including the "always allow" path with grant persistence. The follow-ups are minor (duplicated event-type helpers, args display in TUI prompt, missing --non-interactive-permissions flag, error path tests).

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.

2 participants