feat: write-capable Codex subagents with isolation, policy, verification, and landing (2.0.0) - #2
Merged
Merged
Conversation
Write-capable runs previously executed with the user's checkout as the turn cwd and sandbox "workspace-write", so Codex edited the live working tree directly. This moves that work behind two new fork-specific modules. lib/policy.mjs loads .codex-plugin/policy.json and fails closed: a missing, unparseable, schema-invalid, or unknown-version policy denies every write capability while read-only agents keep working. Agents declare a capability and, for write agents, the repository-relative globs they may modify. Verification entries are argv arrays, never shell strings. lib/worktree.mjs gives each write job a git worktree under the plugin state directory on branch codex/<jobId>, with realpath containment checks so symlink escapes fail closed. Changes are collected after the turn, refused when they fall outside the agent's globs, and otherwise committed to the job branch so a later step can render and cherry-pick them. The task command gains --agent, and executeTaskRun resolves capability before starting a thread. Read-only runs are unchanged and still execute against the checkout without creating a worktree. FORK_SCOPE section 'Execution and Security' is amended: effect limits come from policy and isolation rather than the host permission mode, which prepares the permission-mode inheritance that follows without weakening isolation. Claude-Session: https://claude.ai/code/session_01Tgk3dveLrvFhpBMA5fFVLS
The client refused every server-initiated request with -32601, which is why approvalPolicy was pinned to "never": approval prompts could not be delivered, so requesting them would have stalled a turn. Adds setRequestHandler alongside the existing setNotificationHandler. With no handler installed the client still refuses every server request, so current behavior is unchanged. A handler that resolves undefined also falls back to the refusal, and one that throws returns an error response rather than leaving the server waiting. This is the transport groundwork for policy-decided approvals; no caller sets a handler yet. Claude-Session: https://claude.ai/code/session_01Tgk3dveLrvFhpBMA5fFVLS
A write turn runs with sandbox "workspace-write" and the job worktree as its cwd, so ordinary edits inside the worktree never raise an approval request. An approval request therefore means Codex is asking to step outside those limits, which is exactly what must be refused. lib/approvals.mjs answers those requests from the repository policy alone. File change approvals and session-wide write grants are always declined. Command approvals are declined unless the command runs inside the job worktree and its argv matches a prefix the policy lists, so a policy can allow `npm test` without allowing `npm publish`. Permission-profile requests and every unrelated server request keep the historical protocol-level refusal. Both the legacy applyPatchApproval/execCommandApproval shapes and the newer item/* shapes are handled. Write turns now start with approvalPolicy "on-request" instead of "never", and each decision is recorded on the job payload. lib/limits.mjs adds the policy-derived bounds: a concurrency check that refuses a new write job past maxConcurrentJobs, tail-preserving output truncation, and a wall-clock deadline helper that terminates the process tree. Concurrency is enforced before a worktree is created. lib/env-isolation.mjs builds an allowlisted environment for processes the plugin spawns. Credential-shaped names are dropped even when a policy lists them, so a policy mistake cannot hand an API key to a spawned command. It is not wired into the app-server spawn path: Codex owns authentication there, and the module lands with the verification runner that spawns commands the plugin fully controls. Claude-Session: https://claude.ai/code/session_01Tgk3dveLrvFhpBMA5fFVLS
Completes the write path: a job now produces a verified diff, the user can see it, and it reaches a branch only through one audited entry point. lib/verification.mjs runs the policy's declared checks inside the job worktree with spawnSync and shell disabled, using the allowlisted environment from env-isolation so a check never sees a credential. A result is an exit code, not a model's claim about one. A failing required check sets the job to verification-failed, which runTrackedJob now supports through an optional completionStatus from the runner. Permission-mode inheritance keeps autonomous sessions moving without weakening isolation. A PreToolUse hook records the host permission mode just before a companion command runs, because the mode can be toggled mid-session and a SessionStart capture would go stale. lib/permission-mode.mjs reads that record and rejects a missing, malformed, or stale one in favor of the restrictive default. Plan mode refuses write agents outright. The module states plainly that a model with shell access can write that file, so it is a convenience signal rather than a trust boundary; nothing protecting the checkout depends on it. lib/landing.mjs is the only path from a job branch to the user's branch, shared by both the automatic and explicit routes so they cannot drift. It requires a completed job, passing required checks, no refused changes, a clean tree, and authorization from either an explicit /codex:land or an auto-land permission mode with landing.allowAutoLand. It fetches and cherry-picks locally, writes an audit record naming what authorized it, and never pushes. Adds the four capability-typed subagents and their commands, plus /codex:diff and /codex:land. Each agent forwards a fixed --agent so capability comes from the policy file rather than prompt text. The result-handling skill now forbids running the printed git commands on the user's behalf. Claude-Session: https://claude.ai/code/session_01Tgk3dveLrvFhpBMA5fFVLS
Job records outlive the process that created them, so a killed or crashed worker left a job claiming to be running forever, holding a concurrency slot and misreporting status. lib/job-ownership.mjs records who owns a running job and until when, using two independent liveness signals so neither alone can strand a job: the owning pid, checked only when the job belongs to this host, and a lease deadline, which is the only usable signal for a job owned by another host. Progress events renew the lease, since progress is proof of life. Reconciliation runs at the start of task and status and transitions abandoned jobs to "stale", which frees their concurrency slot and reports them honestly. tests/packaging.test.mjs closes the fresh-install gate: no plugin module imports outside plugins/codex, no runtime code depends on a third-party package, every hook and command references a script the plugin actually ships, the manifest and marketplace entry agree, and nothing reaches into the archived Fleet prototype. FORK_SCOPE "Upstream Relationship" is rewritten to match reality. The repository left OpenAI's fork network and has no upstream remote, so upstream changes are adopted by deliberate porting rather than by merging a tracked branch. The reason to keep fork-specific logic modular is now stated as making that port reviewable. Claude-Session: https://claude.ai/code/session_01Tgk3dveLrvFhpBMA5fFVLS
The write path changed shape, so this is a major version rather than a minor one. Two breaks matter to anyone already using the plugin: --write now requires an execution policy at .codex-plugin/policy.json and is denied without one, and write work no longer edits the active checkout, producing a branch to review instead. The marketplace identity also moved to aksops-codex, so an existing install has to be re-registered. The changelog spells out the breaks first, then the added surface, so an upgrade decision can be made without reading the diff. Claude-Session: https://claude.ai/code/session_01Tgk3dveLrvFhpBMA5fFVLS
CI surfaced this on its first run: six tests in tests/limits.test.mjs reported cancelledByParent, starting at the deadline test. That test handed terminateProcessTree the made-up pid 999999, and on Linux that function signals the whole process group, so the runner was being asked to terminate a group it did not own. withDeadline now takes an injectable terminate function and skips termination entirely for a pid that is absent or not positive. The test injects a spy and asserts which pid would be signalled, which is the behavior worth checking, and a new case pins that null, undefined, zero, negative, and NaN pids signal nothing. Claude-Session: https://claude.ai/code/session_01Tgk3dveLrvFhpBMA5fFVLS
The first fix removed the dangerous pid but not the cause of the cancellation. CI's second run named it directly: "Promise resolution is still pending but the event loop has already resolved". The deadline tests handed withDeadline a promise that never settles, so once the timeout rejected, the file's event loop drained with a pending resolution and the runner cancelled the remaining tests. It passed locally because unrelated concurrent work kept the loop busy. The stalled work now comes with a release handle that the test calls after asserting, so nothing is left pending. Claude-Session: https://claude.ai/code/session_01Tgk3dveLrvFhpBMA5fFVLS
Reproduced against Node 22, the version CI runs, which is why three green local runs on Node 24 never caught it. The deadline timer is deliberately unref'd so a pending deadline never keeps a process alive. That also means a test whose only outstanding work is that timer lets the event loop drain before the timer fires, and the runner reports "Promise resolution is still pending but the event loop has already resolved" and cancels the rest of the file. The stalled work now holds a referenced timer that outlives the deadline and is cleared once the assertion is done. Also settles the loser of the race inside withDeadline. Clearing a timer stops it firing but never settles the promise it would have settled, so a successful run left one pending forever. Verified with Node 22: 195 tests, 195 pass, 0 cancelled. Claude-Session: https://claude.ai/code/session_01Tgk3dveLrvFhpBMA5fFVLS
aksOps
force-pushed
the
feat/write-isolation
branch
from
July 26, 2026 04:30
ab9e540 to
a8b351c
Compare
aksOps
marked this pull request as ready for review
July 26, 2026 05:16
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Builds out what
FORK_SCOPE.md§ Execution and Security describes. Nine commits, five phases, released as 2.0.0. Read-only behavior is unchanged.The problem
codex-companion.mjsran write-capable tasks with the user's checkout as the turncwdandsandbox: "workspace-write"— Codex edited the live working tree directly.approvalPolicywas pinned to"never"because the client refused every server-initiated request with-32601. There was no policy, no writable-root restriction, no verification, no resource bound, no job ownership, and no way to review what a run produced.What is here now
Policy (
lib/policy.mjs)..codex-plugin/policy.json, loaded fail-closed: missing, unparseable, schema-invalid, or unknown-version denies every write capability while read-only agents keep working. Agents declarecapabilityand, for write agents,writableGlobs. Verification entries are argv arrays, never shell strings — closing the archived Fleet defect where verification could invoke a host shell. Validation is hand-written because the plugin ships no JSON Schema validator and a skipped validation would be indistinguishable from a permissive policy.Isolation (
lib/worktree.mjs). Each write job gets a git worktree under the plugin state directory oncodex/<jobId>. Containment uses realpath on both sides so symlink escapes fail closed. Out-of-glob changes are refused and never committed; the rest is committed to the job branch. Replaying a job id is refused.Approvals (
lib/approvals.mjs, plussetRequestHandleron the app-server client). A write turn is already sandboxed to its worktree, so ordinary in-worktree edits raise no approval request — a request means Codex is asking to step outside. File-change approvals and session-widegrantRootare always declined; commands are declined unless they run inside the worktree and match a policy-listed argv prefix. Decided from policy alone, identical in every permission mode.Verification (
lib/verification.mjs). Policy checks run inside the worktree withspawnSyncand shell disabled, under the allowlisted environment fromlib/env-isolation.mjsso a check never sees a credential. A result is an exit code, not a model's claim about one. A failing required check sets the job toverification-failed.Permission inheritance (
lib/permission-mode.mjs,scripts/permission-capture-hook.mjs). APreToolUsehook records the host permission mode just before a companion command runs — the mode can be toggled mid-session, so aSessionStartcapture would go stale. Missing, malformed, or stale records fall back to the restrictive default.planrefuses write agents;acceptEdits/auto/dontAsk/bypassPermissionsauthorize automatic landing so an autonomous session is never stalled.Landing (
lib/landing.mjs). The single path from a job branch to the user's branch, shared by the automatic and explicit routes so they cannot drift. Requires a completed job, passing required checks, no refused changes, a clean tree, and authorization. Localgit fetch+git cherry-pick, an audit record naming what authorized it, and never a push.Ownership (
lib/job-ownership.mjs). Owning pid (checked only on the owning host) plus a lease deadline for cross-host jobs, so a crashed worker's job becomesstaleand frees its concurrency slot instead of claiming to run forever.Surface. Four capability-typed subagents and commands
/codex:explore,/codex:implement,/codex:test,/codex:verify,/codex:diff,/codex:land. Each agent forwards a fixed--agent, so capability comes from the policy file, never from prompt text.Breaking changes (why 2.0.0)
--writerequires.codex-plugin/policy.json. Without one every write capability is denied./codex:diffand apply with/codex:land, or that lands automatically in an auto-land permission mode.aksops-codex; existing installs must re-register.FORK_SCOPE.mdamended twice: "bypass modes must not expand the allowed effect set" replaced by a clause separating authorization from capability, and § Upstream Relationship now describes a standalone repository seeded fromdb52e28.Verification
CI is green — run 30172030937, the first successful run this repository has ever had.
Locally the two env vars
CODEX_COMPANION_SESSION_IDandCLAUDE_PLUGIN_DATAmust be unset, or four unrelated tests fail.What CI caught that local runs did not
The last three commits are CI earning its keep on day one. Six tests reported
cancelledByParent, and the cause took two wrong guesses to reach:terminateProcessTreean invented pid, and on Linux that signals a whole process group — the runner was being asked to terminate something it did not own. Fixed by injecting the terminator and refusing non-positive pids.unref'd so a pending deadline never keeps a process alive, which also means a test whose only outstanding work is that timer lets the event loop drain first. Node 22 reports that as a pending promise resolution and cancels the file; Node 24 did not. Reproduced locally by switching to Node 22, then fixed by holding a referenced timer for the duration of the wait.withDeadlinealso left the losing side of its race permanently pending, since clearing a timer never settles the promise it would have settled. Now resolved infinally.Release gates
packaging.test.mjsproves no escaping imports, no third-party deps, hooks and commands reference only shipped scripts. A real marketplace install was not performed.env-isolationis deliberately not wired into the app-server spawn, where Codex owns auth.ownership.test.mjs, replay refusal, concurrency refusal, existing cancel tests.verification.test.mjs,landing.test.mjs.Gates 3 and 4 need a live Codex session; gate 9 needs a reviewer. Everything else is closed or has its remaining gap stated.
🤖 Generated with Claude Code
https://claude.ai/code/session_01Tgk3dveLrvFhpBMA5fFVLS