session_agent_harness section drives — a thread-typed
session object whose metadata describes a workspace, an agent, and a control-channel state. The user prepares
it; the worker watches the control channel, takes over when it goes pending, and drives one agent in the
workspace. This page covers the static shape and ownership of that object; the lifecycle dynamics live in
Handoff.
Wire metadata shape
Inside the thread envelope (value.thread.metadata), the worker reads and writes these blocks:
upload_session_object on the thread’s alias.
Required vs optional
workspace.work_folder— required. An absolute path to the repository on the worker host.agent.type— required. One ofclaude_codeorcodex.- Everything else under
agent.*— optional. Omitted fields fall through the settings tiers below. artifacts— optional. Absent means the run publishes nothing. When present, bothprefixandsourceare required; a block missing either still publishes nothing, but warns (artifacts_block_incomplete, naming the missing field) rather than staying silent. See Artifacts below.policy— optional, except whenagent.permissionsresolves toguarded, where it becomes required: aguardedthread with nopolicyblock fails activation withPOLICY_MISSING. See Policy and guarded permissions below.
Field ownership and the control channel
The protocol relies on a strict ownership split; each side never modifies fields the other owns.instance.state is the control channel. The user drives the thread with it:
- set
pendingto hand the thread to the worker, - set
completedto stop the worker cleanly, - reset
failed → pendingto retry a failed thread.
instance.state only to the values active and failed. It never writes completed
(that is user-set), and it never writes a resume pointer or an error block to the wire. Failure detail lives
locally and surfaces server-side as a thread_failed item in the worker activity thread — not on this
envelope.
Agent settings resolution
Each agent setting resolves through three tiers, with the most specific winning per field:- the thread’s own
agent.*on this envelope, - the worker’s
agents.<type>.*inconfig.yaml, - the codebase default.
permissions has a non-empty codebase default (approval); executable falls back to claude for
claude_code and codex for codex. permissions controls the approval gate: autonomous runs without a
gate; approval routes every sensitive action to an interactive prompt a human answers from the web UI,
denying it if no human is reachable; guarded routes sensitive actions to a rule set first, falling back to
that same prompt only when a rule — or the absence of one — says ask. See
Policy and guarded permissions below, and
Configuration for where the worker config tier is set.
Artifacts
An optionalartifacts block turns on a per-turn publish step: once a turn’s work completes, and just
before its turn_end item is posted, the worker walks a local output folder and uploads every file in it as
one session object per file, under an alias prefix you choose.
Both fields are required whenever the block is present — there is no default for either, so “not
configured” never silently becomes “publish something.” An absent
artifacts block is the ordinary
feature-off case and stays silent; a block that’s present but missing one field still publishes nothing,
but warns (artifacts_block_incomplete, naming the missing field), because that shape means someone tried
to turn publishing on and got it wrong.
What gets published. There is no diff, manifest, or file-change detection: the folder’s contents at
publish time are the artifact set, by definition. The worker walks source recursively (files only,
symlinks not followed, its own outbox .gitignore excluded, sorted for determinism); each file’s path
relative to source becomes the alias suffix, so docs/plan.md publishes to <prefix>docs/plan.md. A
file is skipped, not failed, when it is
not valid UTF-8 (binary), exceeds the object size budget (too_large — 5 MiB minus 64 KiB of envelope
headroom), the constructed alias is invalid (invalid_alias), it can’t be opened (unreadable), or the
upload itself fails after retries (upload_failed).
The outbox lives inside work_folder, and the worker creates it (with parent directories) the moment a
thread carrying an artifacts block activates, so it exists before the agent’s first turn looks for it. It
is also self-ignoring — the worker writes a .gitignore containing a single * into it. See
Filesystem Layout → The local outbox for the full physical
picture and why the outbox cannot live outside work_folder.
Reporting. One artifacts_published item is posted on every turn of an activation whose artifacts
block passed the activation-time check above — including a turn where the outbox was empty, so “nothing to
publish” is visible rather than indistinguishable from the feature being off. That qualifier matters: when
source fails the activation-time check instead, no item is posted on any turn of that activation — see
the source row above and Thread Items for both of source’s
escape triggers and what each one produces. This item is an audit record, not the discovery mechanism —
find published artifacts by listing the session with prefix=<artifacts.prefix>, exactly as any other
client would.
Neither artifacts nor its outcome is mirrored into the local thread.yaml: the worker re-reads the block
from the envelope on every activation and every recovery, so a thread that was publishing before a worker
restart keeps publishing afterward.
Policy and guarded permissions
agent.permissions: guarded (see Agent settings resolution above) routes a
thread’s sensitive tool calls to a rule set instead of a human, falling back to the same interactive prompt
approval uses whenever a rule — or the absence of one — says ask. The rule set comes from two
places, evaluated in a fixed order:
- The worker’s own
policy.rules, set once inconfig.yaml(see Configuration → policy) — evaluated first, always. - This thread’s own
policyblock — evaluated second, and required for anyguardedrun: aguardedthread with nopolicyblock fails activation withPOLICY_MISSINGrather than running ungated under a mode that promises enforcement.
Evaluation order — first match wins: the worker’s rules, in order, then this thread’s rules, in order,
then this thread’s
default. A machine owner’s deny can therefore never be relaxed by anything a session
supplies, while a thread can still be stricter than the worker’s own baseline. A malformed policy — an
effect / action outside the vocabulary above, or an invalid default — fails activation with
POLICY_INVALID rather than silently matching nothing; so does a policy block that is present but the
wrong shape (an object expected where something else was sent), regardless of permissions — this one
check runs on every activation, whether or not the thread is guarded.
What a rule can see, per harness
Theeffect vocabulary (write | exec | read | network) is portable, but the two harnesses do not
see the same calls, and a rule author has to know it before writing a confinement rule:
Anything unrecognised — every
mcp__* tool, and codex’s own permission-mode approval request — maps to no
effect and falls through to default. That is deliberate: inventing an effect for a call its author never
considered would make a rule fire on something it was never written against.
What actually gates a codex file change is whether the sandbox refuses the write — not a path string.
Upstream codex-rs (openai/codex @ 5825699) generates item/fileChange/requestApproval from two
different triggers, both ending in the same request this worker’s gate answers:
- Admission-time.
assess_patch_safety(core/src/safety.rs) checks the patch’s target against the sandbox’s writable roots before anything runs; a target outside them escalates immediately, without the write being attempted at all. - Runtime denial.
apply_patchuniquely retries outside the sandbox when the sandbox refuses a write it is already attempting (wants_no_sandbox_approval,runtimes/apply_patch.rs) — which also escalates into the same approval request, even for a target admission had already cleared as in-workspace. The case named upstream is a path that is a hard link out of the writable roots: the string looks insidework_folder, and isn’t.
write + outside: work_folder rule actually does there, without changing how it
matches. It still can’t inspect a path — the wire payload carries none, only thread/turn/item ids, a
timestamp, and a reason — so it still matches every request the gate receives (see
PolicyEvaluator._leaves_workspace, which treats an unresolvable path as outside by design). But because
the gate now only receives requests for writes the sandbox has already refused, that blanket match is
substantially correct on a sandboxed platform: action: deny denies approximately the out-of-workspace
writes an operator means to stop, not ordinary writes the agent makes inside work_folder. It is coarse —
no rule can allowlist one refused destination while denying another, since there’s nothing to distinguish
them by — but it is not “the agent cannot write at all.”
Two things complicate that picture, and both matter more than the mechanism above:
.git/,.agents/, and.codex/are read-only carve-outs inside the project root. They’re excluded from the sandbox’s writable roots even though they sit insidework_folder, so a patch targeting any of them is refused and escalates exactly like one leaving the workspace entirely — and this rule denies it. Concretely: an agent installing a git hook, or writing to.agents/, is denied even though the target is “inside the workspace” in every everyday sense. Don’t describe this rule as never seeing an in-workspace write — that’s not accurate.- On a platform with no sandbox backend (Windows, or any OS upstream codex doesn’t implement one for) every patch escalates, regardless of location — so there, this rule really does deny every file change.
write rule only covers codex’s patch tool.
apply_patch is codex’s one structured file-edit mechanism, and the only thing that maps to the write
effect. A shell command that edits a file — sed -i, a redirect, mv, anything run via the shell —
surfaces as item/commandExecution/requestApproval instead, which maps to exec, not write
(policy/effects.py), and the same split exists on claude_code: its Bash tool maps to exec,
separately from Write/Edit/MultiEdit.
What that means differs sharply by harness. On codex, an out-of-workspace shell write is still stopped
— by the sandbox, which returns an error to the model directly rather than letting the write through. That
refusal itself generates no write-specific approval request (this is exactly where shell and patch
diverge: patch retries and escalates on a sandbox refusal, per the runtime-denial trigger above; shell does
not) — the command as a whole can still surface its own item/commandExecution/requestApproval for reasons
unrelated to where it writes, and an exec rule does see and match that. The write itself is not
unconfined; what’s true is narrower: no policy rule is ever consulted about where the write lands, so
an exec rule’s command globs govern which commands may run, not where a command’s writes end up.
On claude_code there is no sandbox at all
(see “This is a guardrail, not a sandbox” below), so a Bash-executed write has no backstop of any
kind — not the sandbox codex has, and not a write rule, since Bash never maps to write. An exec
rule is the only lever there is for a shell command on claude_code, and it can only match the command
text, never its destination.
The worker warns at activation (codex_write_confinement_path_blind, naming the rule) whenever a
guarded codex thread’s policy carries a write + outside: work_folder rule, on every platform — read
this section rather than either deleting the rule or trusting it to be the whole story.
So what do you do about this?
- Deleting the rule is not a fix — it is worse. With no
writerule left to match, a codex file change falls through to the thread policy’sdefaultinstead —allowin every example on this page. Removing this rule because it “isn’t doing anything useful” restores no confinement and admits every out-of-workspace write it was denying, in the same move. Whatever this rule is currently doing to your codex thread, deleting it is not neutral. - The two mechanisms are complementary, not alternatives. Under both
approvalandguarded, the worker runs codex withsandbox_mode: "workspace-write"(codeagents/adapters/codex.py::_settings_kwargs), entirely independent of anything inpolicy.rules; onlyautonomousturns it off (sandbox_mode: "danger-full-access"). On codex, location confinement comes from that sandbox, rooted atwork_folder— awrite+outside: work_folderpolicy rule doesn’t add location confinement on top of it; it gates the escalations the sandbox already produces (deny them, or route them to a human withask). - An
execrule does not add location confinement, on either harness. On codex you don’t need it for shell writes — the sandbox already confines them, invisibly to policy. Onclaude_codenothing can give you that guarantee for a shell-executed write;commandglobs are text matches, not a location check — write them to block commands you recognize as dangerous, not to confine where a command is allowed to write.
This is a guardrail, not a sandbox
Stated plainly: apolicy stops mistakes, not a determined agent. command rules are string/glob
matches against a command line — eval, a shell variable, or a script file defeats any pattern list just as
easily as it would defeat a human skimming the same command. Real confinement is the operating system’s job,
and of the two harnesses only codex has one underneath (its workspace-write sandbox); claude_code has
none, so on claude_code the policy genuinely is the only gate there is.
The worker has exactly one workspace shape today — workspace.work_folder, a path on the worker’s own
host — so the blast radius of a guarded run, reduced by whatever its rules happen to catch, remains
the machine the worker runs on. Choose guarded because it removes a human from a loop your rules
genuinely cover, not because it makes an agent safe to run unsupervised on a sensitive workspace.
Enforcement is audited, not assumed
Aguarded turn posts a policy_applied item before it runs (the rule set in force, tagged by origin) and
a policy_denied item for every call a rule refused — see
Thread Items. policy_applied doubles as an enforcement
acknowledgement: a guarded session with no policy_applied item was run by a worker version too old to
enforce policy at all, and should not be trusted as gated.
Wire ↔ local mapping
The worker mirrors the envelope into a localthread.yaml on the worker host, but with a different field
layout. The two must not be conflated:
The local
thread.yaml keeps the agent type under workspace.agent_type and the state under agent.state,
and it additionally holds two local-only fields that never appear on the wire:
agent.agent_session_id— the SDK resume pointer used to reattach the agent after a restart.agent.error— the{code, message}detail of a local failure.
agent_session_id and the error block are local-only, a reader inspecting the wire envelope will
never see them there; thread failures surface to other clients as a thread_failed activity item, and the
local detail is what the worker uses to recover. artifacts and policy have no local counterpart at all
— unlike agent.*, neither is ever mirrored into thread.yaml; both are re-read from the envelope on every
activation and recovery. See Filesystem Layout for the full
local layout and Worker Session Object for the activity
thread.
See also
- Handoff
- Thread Items
- Filesystem Layout
- Worker Session Object
- Configuration — the worker-side
policyceiling and rule reference upload_session_object

