Skip to content

feat: write-capable Codex subagents with isolation, policy, verification, and landing (2.0.0) - #2

Merged
aksOps merged 9 commits into
mainfrom
feat/write-isolation
Jul 26, 2026
Merged

feat: write-capable Codex subagents with isolation, policy, verification, and landing (2.0.0)#2
aksOps merged 9 commits into
mainfrom
feat/write-isolation

Conversation

@aksOps

@aksOps aksOps commented Jul 25, 2026

Copy link
Copy Markdown
Owner

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.mjs ran write-capable tasks with the user's checkout as the turn cwd and sandbox: "workspace-write"Codex edited the live working tree directly. approvalPolicy was 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 declare capability and, 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 on codex/<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, plus setRequestHandler on 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-wide grantRoot are 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 with spawnSync and shell disabled, under the allowlisted environment from lib/env-isolation.mjs 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.

Permission inheritance (lib/permission-mode.mjs, scripts/permission-capture-hook.mjs). A PreToolUse hook records the host permission mode just before a companion command runs — the mode can be toggled mid-session, so a SessionStart capture would go stale. Missing, malformed, or stale records fall back to the restrictive default. plan refuses write agents; acceptEdits / auto / dontAsk / bypassPermissions authorize 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. Local git 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 becomes stale and 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)

  • --write requires .codex-plugin/policy.json. Without one every write capability is denied.
  • Write work no longer edits the active checkout. It produces a branch you review with /codex:diff and apply with /codex:land, or that lands automatically in an auto-land permission mode.
  • Marketplace identity is aksops-codex; existing installs must re-register.

FORK_SCOPE.md amended 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 from db52e28.

Verification

CI is greenrun 30172030937, the first successful run this repository has ever had.

node --test tests/*.test.mjs   # 195 tests, 195 pass, 0 cancelled  (Node 22, matching CI)
npm run build                  # tsc clean
npm run check-version          # all metadata matches 2.0.0

Locally the two env vars CODEX_COMPANION_SESSION_ID and CLAUDE_PLUGIN_DATA must 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:

  1. The deadline test handed terminateProcessTree an 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.
  2. The real cause was Node-version-specific: the deadline timer is deliberately 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.
  3. withDeadline also left the losing side of its race permanently pending, since clearing a timer never settles the promise it would have settled. Now resolved in finally.

Release gates

# Gate Status
1 Fresh install without a source checkout Proxy passespackaging.test.mjs proves no escaping imports, no third-party deps, hooks and commands reference only shipped scripts. A real marketplace install was not performed.
2 Existing auth and provider config keep working Met — no credential handling added; env-isolation is deliberately not wired into the app-server spawn, where Codex owns auth.
3 Live model discovery and delegation Not met — everything was exercised against the fake Codex fixture. Needs a live session.
4 Read-only and write invocations through the plugin surface Partial — end-to-end against the fixture, not against real Codex.
5 Write work cannot touch the checkout or escape its worktree Met — tests assert the checkout is byte-identical after a write run; out-of-glob changes refused; symlink escapes fail closed.
6 Restart, cancellation, concurrency, replay, stale state Metownership.test.mjs, replay refusal, concurrency refusal, existing cancel tests.
7 Verification failure prevents completion and merge Metverification.test.mjs, landing.test.mjs.
8 Authorization cannot be forged by model-generated input Met with a stated caveat — approvals are policy-decided with no model involvement; landing needs an explicit user command or a harness-supplied permission mode. The permission record is a file a shell-capable model could write, which is exactly why the mode only relaxes interactivity: isolation, globs, approvals, and verification are identical in every mode, including a forged one.
9 Independent code, security, and manual workflow review Not met — this PR is that request.

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

@aksOps aksOps changed the title feat: isolated write-capable Codex work under a fail-closed policy feat: write-capable Codex subagents with isolation, policy, verification, and landing Jul 25, 2026
@aksOps aksOps changed the title feat: write-capable Codex subagents with isolation, policy, verification, and landing feat: write-capable Codex subagents with isolation, policy, verification, and landing (2.0.0) Jul 25, 2026
aksOps added 9 commits July 26, 2026 04:29
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
aksOps force-pushed the feat/write-isolation branch from ab9e540 to a8b351c Compare July 26, 2026 04:30
@aksOps
aksOps marked this pull request as ready for review July 26, 2026 05:16
@aksOps
aksOps merged commit 3221454 into main Jul 26, 2026
1 check passed
@aksOps
aksOps deleted the feat/write-isolation branch July 26, 2026 05:18
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.

1 participant