Skip to content

Add a Codex project hook approval flow to Agent Mode - #678

Open
mplibunao wants to merge 6 commits into
repoprompt:mainfrom
mplibunao:feature/665-hook-approval-flow
Open

Add a Codex project hook approval flow to Agent Mode#678
mplibunao wants to merge 6 commits into
repoprompt:mainfrom
mplibunao:feature/665-hook-approval-flow

Conversation

@mplibunao

@mplibunao mplibunao commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Fixes #665.

What this does

Codex 0.145.0 only runs repo-level .codex PreToolUse hooks when the effective CODEX_HOME config carries a [hooks.state] trusted_hash approval entry for each hook; otherwise the hooks are silently skipped. Because CE binds its bundled Codex app server to an app-private CODEX_HOME, hook approvals users made in ~/.codex never carry over, so repo guardrail hooks (for example, hooks that block terraform destroy) silently stopped running for CE Agent Mode sessions — fail-open, with no signal.

This PR adds a fail-closed approval lifecycle. After a thread binds but before the first turn/start, the coordinator lists project hooks for the execution cwd. Any untrusted or modified project hook blocks the first turn behind a review interaction, surfaced both as a SwiftUI card and over the agent_run MCP contract. Approval writes the trusted_hash entries through config/batchWrite into CE's private home, re-verifies with a second hooks/list, and only then releases the pending turn. Live threads pick up the trust write without rebinding (verified against the pinned runtime).

Design highlights

  • Lazy, per-controller-binding gate covering every dispatch route that can start a first turn: direct send, fallback idle pump, terminal-successor fallback, and managed-auth replay. Restore/reconnect/goal/compact bindings don't trigger review until a turn is actually sent.
  • Serialized trust writes. config/batchWrite is read-modify-write per process with no cross-process merge (verified empirically: concurrent writers clobber, last writer wins), so CE serializes all trust writes behind an app-global mutex, held until server-side settlement with cancellation shielding.
  • Fail closed everywhere. Discovery, parse, write, and verification failures block the turn with retry/continue options; unknown trust statuses fail decode; verification runs a second hooks/list after every write. Inventory drift to zero during review resolves as a distinct resolved_externally audit, never as a silent bypass.
  • Explicit continuation, never a default. Continuing without hooks is a deliberate user action, recorded in the transcript with a counts-only audit. A discovery-failure bypass reports its skipped count as unknown rather than zero.
  • Opt-in strict mode. By default, the review card lets a human explicitly choose "Continue Without Hooks" — the session runs unguarded, and the transcript records that choice. Checking Settings → Agent Mode → "Require Codex project-hook approval" removes that option: sessions stay blocked until the displayed hooks are approved (or become trusted externally, e.g. via the codex TUI). An "In this workspace" picker — App default (labeled with the live global value) / Always require approval / Don't require approval — lets a single workspace keep its own value instead of the global one, e.g. global off for scratch repos, strict on for an infrastructure repo whose hooks guard terraform destroy. The setting is human-only — the app_settings MCP surface rejects writes to it.
  • Headless parity. MCP hook_approval interaction with phase-specific options, hook_keys selective approval, an exact top-level argument allowlist, and atomic snapshot decoding so wait/poll round trips can't silently drop the interaction. Full hook metadata (source path, command, hash) is intentionally present in the pending interaction — it's required for informed approval — while diagnostics, stale-ID errors, transcripts, and audits stay counts-only.
  • Schema gate. hooks/list and config/batchWrite shapes are pinned in the app-server contract fixture (floor 0.145.0) so runtime drift is caught by make dev-codex-schema-check.

Notes for reviewers

  • The MCP approval surface uses the same authority model as the existing approval/question interactions: the agent_run caller (the user's orchestrator) resolves interactions. The gated session itself cannot self-approve — its first turn has not started while the gate is pending.
  • Project trust still gates hook discovery entirely (codex behavior): repos not trusted in the private home list no hooks, so the hook gate composes with — and does not replace — project trust handling.
  • One codex upstream quirk documented during verification: a schema-invalid hook declaration (flat command on the matcher table) is silently ignored by hooks/list with zero errors, indistinguishable from a hook-less repo. The gate cannot catch that class; candidate for an upstream codex report.
  • FileSystemContentLoadingConcurrencyTests.testCancelledQueuedContentReadWorkerPermitWaitRecordsCancellationWithoutAcquisitionOrLeak failed under full-suite load in two separate full-suite runs on this branch and passed focused (24/24) with zero content change both times; the subsystem is untouched here. Flagging as a pre-existing load-sensitive flake.

Testing

  • New/expanded unit coverage across the five commits: controller trust operations (25), gate core + coverage batch (20), FIFO gate ordering (21), card state, MCP lifecycle (11), MCP diagnostics/privacy, settings persistence + human-only rejection, plus empty-inventory compatibility across existing suites. Contract ledger verified.
  • Full root suite, dev-lint, dev-format-check, both product builds, dev-codex-schema-check, and the repo pr-ready preflight lane green.
  • Live end-to-end canary on the debug build: the gate blocked the first turn on a scratch repo with an untrusted PreToolUse hook; trust_all over MCP wrote the hooks.state entries and the deny hook then blocked bash -c true on the same live thread with its stderr reason — no rebind. Changing the hook declaration re-triggered review as modified; continue_without_hooks resumed the turn with the hook skipped and a counts-only transcript notice. On a real guarded infrastructure repo, both project hooks were trusted via the flow and terraform apply -help was then blocked by that repo's own guardrail gate while echo passed through the same chain — the original Agent Mode silently skips untrusted Codex project hooks in isolated CODEX_HOME — security hooks fail open #665 bypass is closed with no over-blocking.

Screenshots

Screenshot 2026-07-29 at 11 43 31 AM Screenshot 2026-07-29 at 11 43 43 AM Screenshot 2026-07-29 at 11 44 00 AM Screenshot 2026-07-29 at 9 58 17 AM Screenshot 2026-07-29 at 10 02 22 AM Screenshot 2026-07-29 at 10 35 38 AM

Attached in the first comment: the settings card (default state, open picker, and global-on + workspace opt-out) and Agent Mode transcripts showing block-after-approval and the explicit-bypass notice.

Release note

Agent Mode may now pause before its first Codex turn to ask you to approve repository-declared Codex hooks (.codex config). Approvals persist per hook version; an opt-in strict mode (Settings → Agent Mode) requires approval and removes the continue-without-hooks option.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Fph5yrP8bV9yu3BjD2xh32

mplibunao and others added 5 commits July 28, 2026 18:33
…ract

Adopts the two methods the Agent Mode hook-approval flow (repoprompt#665) will call:
hooks/list with its consumed per-cwd entry paths and an exhaustive
trustStatus enum, and config/batchWrite with the upsert merge strategy
pinned so upstream enum drift fails the gate instead of breaking trust
writes at runtime. The generated ConfigEdit.value schema is unconstrained,
so the fixture declares the closest projection and the schema-gate doc
records that limitation plus post-write hooks/list as the semantic success
authority for trust writes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fph5yrP8bV9yu3BjD2xh32
Typed hooks/list decoding (exhaustive trustStatus, UTF-8 byte identity for
hook keys and hashes, fail-closed on unknown values, malformed records, and
per-cwd discovery errors) and a CodexHookTrustService owning the
list/preflight/write/verify trust transaction for repoprompt#665. The controller
keeps two serialization boundaries: a per-controller operation mutex and an
app-global trust-write mutex held until the config/batchWrite settles
server-side, because concurrent app-server processes do read-modify-write
with last-writer-wins on the shared config. Nothing calls the new
operations yet; the Agent Mode gate arrives separately.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fph5yrP8bV9yu3BjD2xh32
Add a lazy per-controller-binding gate in CodexAgentModeCoordinator that
discovers project PreToolUse hooks before the first startUserTurn on every
dispatch route (direct send, fallback idle pump, terminal successor,
managed-auth replay), suspends the turn behind a hook review request, and
resumes only after the decision settles. Approvals write trusted_hash
entries through the serialized trust service; inventory drift to zero
resolves as a non-bypass external-resolution audit.

Strict mode (approve-all-or-nothing) persists as a global setting with
per-workspace overrides, resolved live at decision time; the app_settings
MCP surface rejects writes to these human-only keys.

Review requests carry a stable identity derived from tab, attempt, CWD,
and sorted hook key/hash pairs so phase and error updates preserve the
interaction while inventory changes replace it. Transcript notices for
approval outcomes exclude hook keys, commands, paths, and hashes.

Part of the repoprompt#665 hook-approval flow.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fph5yrP8bV9yu3BjD2xh32
Add CodexHookReviewCard as the highest-precedence interaction card, with
a review layout (per-hook metadata, selective approval, approve-all,
continue) and a distinct discovery-failure layout (cwd error text, retry,
continue). Selection is keyed by the stable interaction ID so phase and
error updates preserve it while inventory drift resets it. The card
observes global settings so strict mode hides continue-without-hooks
live, and coordinator rejections surface visibly instead of being
swallowed.

Expose the same interaction headlessly: a hook_approval MCP interaction
kind with phase-specific options, a hook_keys answers field for
selective approval, an exact top-level argument allowlist, and atomic
nested interaction/hook_gate snapshot decoding so wait/poll round trips
never silently drop or normalize sections. The counts-only hook_gate
audit includes resolved_externally, and a discovery-failure bypass
reports its skipped count as unknown rather than zero.

Phase capabilities (trust, bypass, retry, resolving) are centralized on
the review phase type and drive the coordinator, UI, and MCP option
projection from one matrix.

Part of the repoprompt#665 hook-approval flow.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fph5yrP8bV9yu3BjD2xh32
Replace the "Override for this workspace" checkbox with a three-state
picker — App default (labeled with the live global value), Always
require approval, Don't require approval — so the workspace scope of the
setting is explicit instead of implied by an override flag. The global
checkbox now reads and writes only the global value.

Part of the repoprompt#665 hook-approval flow.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fph5yrP8bV9yu3BjD2xh32

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4a7dcf0848

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Reject approve-selected in strict mode unless it covers every unresolved
hook — a partial selection was completing the gate with the remainder
skipped, contradicting the setting's contract. Approving all keys stays
valid and non-strict partial approval is unchanged.

After the post-write verification pass, treat any unexpected unresolved
project hook in the verified inventory as drift: publish a refreshed
review with a new interaction identity instead of completing the gate,
so a hook added or modified during the trust write is never silently
skipped. Hooks deliberately skipped by a non-strict partial approval are
not drift.

Addresses PR repoprompt#678 review feedback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fph5yrP8bV9yu3BjD2xh32

@baron baron 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.

Blocking on two correctness/availability issues in the new security gate:

  1. [P1] Re-check strict mode after the approval write suspends

    In CodexAgentModeCoordinator.swift:943-1046, a partial approveSelected is checked against strict mode before awaiting trustHooksForCurrentWorkspace. If strict mode is enabled while that request is suspended, the post-write path completes and memoizes the gate without re-reading the setting, leaving the intentionally skipped hooks unresolved.

    Before completeCodexHookReview, re-read the effective strict-mode value. If it is enabled and verified.unresolvedProjectHooks is nonempty, refresh the review from the verified inventory rather than resuming the first turn.

  2. [P1] Add bounded recovery for an indefinitely unsettled trust write

    CodexHookTrustService.swift:35-61,126-137 deliberately sends config/batchWrite without a timeout, while CodexNativeSessionController.swift:852-905 holds both the controller-local mutex and the app-global trust-write mutex until that request settles. If the app server neither responds nor disconnects, one request blocks hook approval for every workspace indefinitely, and cancel/retry cannot recover.

    Preserve the no-late-write invariant, but add a settlement deadline that terminates the owning app-server process and awaits its exit before releasing serialization. Reconnect and use hooks/list as semantic authority. Please also add fault-injection coverage and settlement/lock-wait diagnostics.

The final commit does correctly address the earlier strict partial-selection and post-write inventory-drift findings. All hosted checks are green at head 5f7daf2b2b88a0ccf6acb5e6a30b1790efab6fef, but the two paths above should be resolved before merge.

morluto commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

Audit disposition — liveness/security-administration blocker (2026-08-14)

The approval model is strong in several respects, but config/batchWrite is awaited with no request timeout while both the controller-local operation mutex and the app-global trust-write mutex are held. If the app server stops responding, one operation can permanently wedge hook approval writes for every session in the process. Cancellation shielding does not retire work that never settles.

Please add a bounded settlement deadline, generation fencing, and a recoverable poisoned-lane/retirement policy before merging. Discovery and parsing should also conservatively distinguish an actually empty hook inventory from an incomplete or malformed inventory. Then rerun the live trust/modified/bypass scenarios.

morluto commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

Deep-review assessment — 2026-08-14

Disposition: liveness/security blocker. The fail-closed first-turn gate, correlated inventory review, post-write verification, strict-mode policy, and headless parity are strong. The trust-write path holds both the controller-local mutex and the process-global trust-write mutex while issuing config/batchWrite with no request timeout. If the app server never settles, one request can permanently wedge all future trust writes in the process; cancellation shielding still waits for the stuck work.

Use a bounded request and settlement deadline, generation-fence late completion, and define recovery for a timed-out/poisoned lane before releasing the locks. Also ensure malformed, oversized, or partially decoded hooks/list data cannot collapse to an apparently empty trusted inventory. These must be fixed before merging a security gate.

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.

Agent Mode silently skips untrusted Codex project hooks in isolated CODEX_HOME — security hooks fail open

3 participants