> ## Documentation Index
> Fetch the complete documentation index at: https://docs.blobhub.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Job Session Object

> The per-thread session object the worker drives: metadata, ownership, settings

A **Job Session Object** is the unit of work a `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](/worker/session-agent-harness/thread-handoff).

## Wire metadata shape

Inside the thread envelope (`value.thread.metadata`), the worker reads and writes these blocks:

```yaml theme={null}
workspace:
  work_folder: /absolute/path/to/repo          # required
agent:
  type: claude_code | codex                     # required
  executable: claude                            # optional
  model: claude-sonnet-4-6                       # optional
  effort: low | medium | high | <passthrough>   # optional
  permissions: autonomous | approval | guarded  # optional (default: approval)
instance:
  state: pending | active | completed | failed  # the control channel
artifacts:                                       # optional; an absent block publishes nothing
  prefix: projects/<pid>/items/<iid>/runs/<n>/artifacts/
  source: .tmp/blobhub/out/<pid>/<iid>/<n>
policy:                                          # optional; required when permissions resolves to "guarded"
  name: standard
  default: allow
  rules:
    - { id: confine_writes, effect: write, outside: work_folder, action: deny }
```

The envelope is created and updated via
[`upload_session_object`](/blob-types/workflow/operations/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 of `claude_code` or `codex`.
* Everything else under `agent.*` — **optional**. Omitted fields fall through the settings tiers below.
* `artifacts` — **optional**. Absent means the run publishes nothing. When present, both `prefix` and
  `source` are required; a block missing either still publishes nothing, but warns
  (`artifacts_block_incomplete`, naming the missing field) rather than staying silent. See
  [Artifacts](#artifacts) below.
* `policy` — **optional, except when `agent.permissions` resolves to `guarded`**, where it becomes
  required: a `guarded` thread with no `policy` block fails activation with `POLICY_MISSING`. See
  [Policy and guarded permissions](#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.

| Field            | Writer                          |
| ---------------- | ------------------------------- |
| `workspace.*`    | user only                       |
| `agent.*`        | user only                       |
| `artifacts.*`    | user only                       |
| `policy.*`       | user only                       |
| `instance.state` | user **and** worker (see below) |

`instance.state` is the **control channel**. The user drives the thread with it:

* set `pending` to hand the thread to the worker,
* set `completed` to stop the worker cleanly,
* reset `failed → pending` to retry a failed thread.

The worker writes `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**:

1. the thread's own `agent.*` on this envelope,
2. the worker's `agents.<type>.*` in `config.yaml`,
3. the codebase default.

Only `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](#policy-and-guarded-permissions) below, and
[Configuration](/worker/configuration) for where the worker config tier is set.

## Artifacts

An optional `artifacts` 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.

```yaml theme={null}
artifacts:
  prefix: projects/<pid>/items/<iid>/runs/<n>/artifacts/   # alias prefix to publish under
  source: .tmp/blobhub/out/<pid>/<iid>/<n>                 # work_folder-relative folder to read
```

| Field    | Meaning                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   |
| -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `prefix` | Session-object alias prefix each published file is written under (`<prefix><relative path>`).                                                                                                                                                                                                                                                                                                                                                                                                                                             |
| `source` | Folder to read, **relative to `workspace.work_folder`**. Must resolve inside `work_folder`, checked **twice**: at activation (an escaping value there turns publishing off for the thread's whole current activation, and posts no item at all) and again, live, at every publish (if the agent moves or re-links the outbox out of the workspace mid-turn, that turn's `artifacts_published` item is still posted, carrying `metadata.error`). See [Thread Items](/worker/session-agent-harness/thread-items) for both triggers in full. |

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](/worker/session-agent-harness/filesystem) 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](/worker/session-agent-harness/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](#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:

1. **The worker's own `policy.rules`**, set once in `config.yaml` (see
   [Configuration → policy](/worker/configuration)) — evaluated first, always.
2. **This thread's own `policy` block** — evaluated second, and required for any `guarded` run: a `guarded`
   thread with no `policy` block fails activation with `POLICY_MISSING` rather than running ungated under a
   mode that promises enforcement.

```yaml theme={null}
policy:
  alias: standard               # informational only — which rule palette this body came from
  default: allow                # allow | ask | deny — applied when no rule matches
  rules:
    - { id: destructive_git, effect: exec, command: ["git push -f*"], action: deny }
    - { id: publish,         effect: exec, command: ["git push*"],    action: ask  }
```

| Field     | Meaning                                                                                                                                                                                 |
| --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `alias`   | Informational — which rule palette entry this body was resolved from. Not interpreted by the worker.                                                                                    |
| `default` | `allow` \| `ask` \| `deny`. Applied when no rule — worker or thread — matches a call.                                                                                                   |
| `rules`   | Same shape as the worker config's own `policy.rules`: `{ id, effect, outside, command, action }`. See [Configuration → policy](/worker/configuration) for the field-by-field reference. |

**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

The `effect` 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:

| Harness       | Native call                                  | Maps to   | Path/command taken from                              |
| ------------- | -------------------------------------------- | --------- | ---------------------------------------------------- |
| `claude_code` | `Write`, `Edit`, `MultiEdit`, `NotebookEdit` | `write`   | `file_path`                                          |
| `claude_code` | `Bash`, `BashOutput`, `KillShell`            | `exec`    | `command`                                            |
| `claude_code` | `Read`, `Glob`, `Grep`, `NotebookRead`       | `read`    | `file_path` / `path`                                 |
| `claude_code` | `WebFetch`, `WebSearch`                      | `network` | —                                                    |
| `codex`       | command-execution approval                   | `exec`    | `command`                                            |
| `codex`       | file-change approval                         | `write`   | none — the real protocol carries no path (see below) |

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:

1. **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.
2. **Runtime denial.** `apply_patch` uniquely 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 inside
   `work_folder`, and isn't.

So on a normal, sandboxed deployment — codex has a sandbox backend on macOS and Linux — the gate receives
exactly the writes codex's own sandbox refused, by either route. That is a stronger claim than "outside the
workspace": it also catches disguised escapes, like the hard-link case, that a simple path-string check
would have scored as safe. **This is a point in the rule's favor, not against it.**

That changes what a `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 inside `work_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.

**A separate, narrower point worth planning around: a `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.

|                                             | `claude_code`                                                                     | `codex`                                                                                                                                                    |
| ------------------------------------------- | --------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `write` + `outside: work_folder`            | matches — confines writes as intended                                             | matches writes the sandbox refused — substantially correct on a sandboxed platform (macOS/Linux); matches every patch where no sandbox exists              |
| Shell-executed writes (`sed -i`, `>`, `mv`) | **no confinement of any kind** — no sandbox, and `write` rules don't match `Bash` | confined by the sandbox directly — no *write-specific* approval request is generated for it (the command itself may still trigger its own `exec` approval) |
| `exec` command globs                        | matches                                                                           | matches                                                                                                                                                    |
| Underlying OS sandbox                       | none — the policy is the only gate                                                | `workspace-write`, rooted at `work_folder`; absent on platforms with no sandbox backend                                                                    |

**So what do you do about this?**

* **Deleting the rule is not a fix — it is worse.** With no `write` rule left to match, a codex file
  change falls through to the thread policy's `default` instead — `allow` in 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 `approval` and `guarded`, the
  worker runs codex with `sandbox_mode: "workspace-write"` (`codeagents/adapters/codex.py::_settings_kwargs`),
  entirely independent of anything in `policy.rules`; only `autonomous` turns it off
  (`sandbox_mode: "danger-full-access"`). On codex, **location confinement comes from that sandbox**,
  rooted at `work_folder` — a `write` + `outside: work_folder` policy 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 with `ask`).
* **An `exec` rule 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. On `claude_code` nothing can give
  you that guarantee for a shell-executed write; `command` globs 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:** a `policy` 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

A `guarded` 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](/worker/session-agent-harness/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 local `thread.yaml` on the worker host, but with a **different** field
layout. The two must not be conflated:

| Wire (envelope)  | Local (`thread.yaml`)  |
| ---------------- | ---------------------- |
| `agent.type`     | `workspace.agent_type` |
| `instance.state` | `agent.state`          |

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.

Because `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](/worker/session-agent-harness/filesystem) for the full
local layout and [Worker Session Object](/worker/session-agent-harness/worker-object) for the activity
thread.

## See also

* [Handoff](/worker/session-agent-harness/thread-handoff)
* [Thread Items](/worker/session-agent-harness/thread-items)
* [Filesystem Layout](/worker/session-agent-harness/filesystem)
* [Worker Session Object](/worker/session-agent-harness/worker-object)
* [Configuration](/worker/configuration) — the worker-side `policy` ceiling and rule reference
* [`upload_session_object`](/blob-types/workflow/operations/upload-session-object)
