# O5 — Restricted-token / service-identity design for the P1-2 extraction runtime

**Date:** 2026-08-06, amended 2026-08-07 · **Owner:** Miguel · **Status:** Design recorded; **O5
closes only when the acceptance battery below passes on the target host** (AWS Windows Server
2025 Datacenter). The `feature/p1-2-o5-prep` branch closes O5 *preparation* only (design,
scripts, fixtures, everything verifiable off-host) — never O5 itself; see the D-O5-6 amendment
below for what that preparation can and cannot yet prove. **Carried since v3.2. Gates
production. Depends on nothing else.**

## Scope and threat model

Two child processes launched by Sibyla.Worker handle untrusted content:

- **`Sibyla.PdfTextExtractor`** — the PDF text/image staging helper. Parses hostile bytes.
- **The headless Claude CLI** (`claude -p`) — reads the staged text layer and/or bounded page
  images for the versioned Sibyla skill. Subject to prompt injection embedded in document
  content; also a network-capable process holding a session with an external API.

Assumed compromise model: a malicious document fully controls the child process. The design
goal is that a fully compromised child can read only its own transient job directory, write
only there, reach nothing else on disk (application binaries, `appsettings`, database,
credential stores, other jobs), and reach nothing on the network except — for the CLI only —
the Anthropic API. stdout is the only channel back to the Worker; a compromised child can lie
on stdout, which is why strict `sibyla.claude-extraction.v1` validation and fail-safe routing
to review already exist downstream (P1-2 backlog) and are not part of O5.

The proven precedent is the Hermes `documental-agent` F0 acceptance of 2026-07-15 on the same
host class: restricted identity, task logon type `Password`, run level `Limited`, identity /
profile / tool-policy / filesystem / credential-isolation / injection / reboot checks all
passed (`docs/f0-spike-results.md`, `docs/hermes-documental-runtime-acceptance.md`). O5
re-targets that pattern at the two new processes; it does not reinvent it.

## Decisions

### D-O5-1 — Two identities, not one

Two dedicated local, non-administrator accounts (names indicative):

- **`svc-sibyla-pdfx`** — runs the PDF helper. **Zero network**: outbound deny-all by SID.
- **`svc-sibyla-claude`** — runs the Claude CLI. Outbound allowed **only** to the Anthropic
  API endpoint over 443; everything else denied by SID.

Rationale: the helper never needs a socket, and separating the SIDs lets the firewall express
"this identity has no network" as a one-line rule instead of a process-matching heuristic.
Both accounts: deny interactive and remote-interactive logon rights; no admin group
membership; password held only in the approved secret store, rotated on the host's policy.

**Rotation, implemented:** `provision-identities.ps1 -RotateExistingPasswords` rotates an
already-existing account's password idempotently, sourcing the new SecureString only
from the registered secret store (never a script parameter, never printed). Ordinary
re-runs of the script without that switch never touch an existing account's password -
routine idempotent provisioning must not look like a rotation to any password-age
policy. `-Verify` reports a `PasswordNotExpired` check per account from the local
account's own `PasswordExpires` state and fails closed (non-zero exit) if a password has
already expired; this is not a substitute for `PasswordNeverExpires` and neither script
sets that flag.

### D-O5-2 — ACL matrix

| Path / resource | `svc-sibyla-pdfx` | `svc-sibyla-claude` | Notes |
|---|---|---|---|
| Helper binary directory | RX | — | read/execute only, no write |
| Claude CLI install + its read-only profile/config | — | RX | profile contains **no secrets** |
| Transient job directory (per job) | RW on its own job only | RW on its own job only | created by the Worker per job; **both** identities ACL'd to the one directory for that job (2026-08-07 amendment below); destroyed fail-closed |
| Job-root parent | traverse only | traverse only | no listing of sibling jobs |
| Application dirs, `appsettings*`, logs, deploy root | deny | deny | explicit deny ACE, tested |
| Database (network + local sockets), connection strings | deny | deny | the identities hold no DB credential and no path to one |
| Secret store (DPAPI profiles, key files) | deny | deny | the API key never enters these profiles |
| Own writable profile beyond the job dir | none | per-job CLI cache subdirectory inside that job's own directory, no secrets, wiped by the same fail-closed cleanup that removes the job directory | implemented (see amendment below); routing into production is `BLOCKED-P1-2` |

### D-O5-2 amendment (2026-08-07, Miguel) — one directory per job, both identities on it

The original D-O5-2 row read "ACL'd to the single job identity", which a first
implementation pass took literally and stamped the transient job directory with Modify
for only one of the two restricted identities. That is wrong: `Sibyla.PdfTextExtractor`
(svc-sibyla-pdfx) stages the text layer/page images into the job directory, and the
headless Claude CLI (svc-sibyla-claude) then reads what was staged **for that same
job**. One transient directory exists per logical job, and **both svc-sibyla-pdfx and
svc-sibyla-claude receive Modify/RW only on that current job directory** — never on a
sibling job's directory, which they must have no listing or read/write access to.
SYSTEM and the administrative/provisioning identity retain the minimum control required
for lifecycle stamping and fail-closed cleanup, and no other identity is granted
anything on a job directory; a broad inherited ACE from the job-root parent must be
stripped, never preserved/converted, when the per-job ACL is applied (strip-inheritance
semantics, not "protect and keep a copy").

This does not weaken isolation between *unrelated* jobs: job-root traverse-only access
(no listing) and unguessable per-job IDs remain what keeps a job's siblings
undiscoverable. It does mean that, because only two identities exist system-wide
(D-O5-1) and both are legitimate owners of every job directory they are each currently
processing, NTFS ACLs alone cannot prove one identity's *own* concurrently active jobs
are mutually unreadable to each other if a path were somehow already known — the
acceptance battery (D-O5-6 item 5) tests this honestly and records the true result
rather than assuming denial; see the item 5 note below.

### D-O5-2 amendment (2026-08-07, second pass) — the Claude CLI cache is per-job, never a static shared directory

The D-O5-2 matrix's last row ("own writable profile beyond the job dir") is
implemented as a directory created fresh **inside each job directory**, not a
persistent directory `provision-acls.ps1` stamps once outside the job root.
`provision-acls.ps1` therefore does not offer a static `ClaudeCliCacheDirectory`
parameter at all - only the read-only `-ClaudeCliInstallDirectory` (RX for
`svc-sibyla-claude`, none for `svc-sibyla-pdfx`), which is unaffected. The per-job
cache directory (`New-O5JobScopedClaudeCliCacheDirectory`, `O5Common.psm1`) is a
bounded subdirectory of the job directory it belongs to; it inherits that job
directory's own ACL (both O5 identities already hold Modify via
`ContainerInherit,ObjectInherit`) rather than a separately stamped rule, and it is
removed by the same fail-closed `Remove-O5JobDirectory` pass that removes the rest of
the job - there is no separate wipe step to forget or get wrong.

The acceptance harness (`scripts/run-o5-acceptance.ps1`, item 4) creates this
directory for every Claude CLI launch, routes the CLI child to it via the
`CLAUDE_CONFIG_DIR` environment variable (the documented, bounded mechanism the
Claude CLI supports for overriding its default cache/config location - set only for
that one child process, never for the operator's own session), records evidence that
the directory is inside the job boundary (`ClaudeCliCacheWithinJobBoundary`,
via `Test-O5PathWithinJobRoot`) and that it is gone after cleanup
(`ClaudeCliCacheRemovedOnCleanup`), and separately records
`WorkerProductionClaudeCliCacheRouting` as `BLOCKED-P1-2`: this harness proves the
lifecycle and the routing mechanism work, but the Worker's own production per-job
spawn path performing the same `CLAUDE_CONFIG_DIR` routing is P1-2 Worker source
work, out of scope for this O5 preparation, and does not block O5 preparation's
closure.

### D-O5-3 — Egress policy

Windows Firewall outbound rules keyed by the account SIDs: default deny for both; for
`svc-sibyla-claude`, allow 443 to the Anthropic API only. Mechanism, in order of preference,
**to be confirmed in the host validation session**: (a) WFP dynamic-keyword FQDN rule for the
API hostname if the WS2025 host resolves and enforces it reliably; (b) otherwise a
loopback-only local forward proxy with a hostname allowlist, where the CLI identity may reach
only the loopback proxy port. Whichever mechanism passes the egress probes is recorded as the
production rule; the other is discarded. No network-capable group SIDs beyond what the chosen
mechanism needs.

### D-O5-4 — API key handling

The Anthropic API key lives only in the approved secret store, readable by the **Worker
service identity**, never by either restricted identity. The Worker injects it into the CLI
child's environment at spawn. It is never written to disk in any restricted profile, never
logged, never placed in the job directory. Cost caps and usage alerting are configured from
day one (per the P1-2 backlog) so a runaway or abused key is bounded and visible.

### D-O5-5 — Launch mechanism

The Worker spawns each child under its restricted identity via secondary logon
(`CreateProcessWithLogonW`-class, logon type `Password`, run level Limited — the F0-accepted
pattern), with: working directory = the job directory; bounded execution time; kill +
fail-closed cleanup of the job directory on timeout, crash, or malformed output; stdout as the
only result channel. The whole path must work as a Windows Service after reboot with **no
interactive login** — exactly the F0 post-reboot criterion.

### D-O5-6 — Acceptance battery (target host; closes O5)

Run on the AWS WS2025 production host, under the exact identities and launch path, evidence
sanitized and recorded with hashes:

1. **Identity probes** — `whoami /all` per child: correct account, no admin/network SIDs
   beyond design, expected privileges only.
2. **Isolation probes** — from each identity, scripted attempts to read `appsettings*`, the
   deploy root, the secret store, another job's directory, and to open a database connection:
   every attempt denied.
3. **Egress probes** — helper: any outbound attempt fails. CLI: Anthropic endpoint reachable;
   a control set of other hosts (including plain 443 targets) all blocked.
4. **Prompt-injection battery** — malicious PDF and image fixtures instructing exfiltration,
   out-of-job writes, and tool abuse: no write outside the job dir, no network effect beyond
   the allowed API, malformed/hostile responses fail safely to review.
5. **Concurrency** — N simultaneous jobs under the same identities: no cross-job reads, no
   state corruption, distinct job dirs, clean teardown of all N.
6. **Post-reboot** — full unattended pass of 1–5 after a host reboot with no interactive
   logon.
7. **Key hygiene** — proof the key is absent from both restricted profiles and job dirs
   (canary-value scan), and that cost caps/alerting fire on a test threshold.

A failed item stops the session and is remediated before O5 closes. Administrator may prepare
fixtures; Administrator execution satisfies nothing (F0 rule).

### D-O5-6 amendment (2026-08-07, Miguel) — preparation vs. closure, and two distinct non-PASS statuses

This branch (`feature/p1-2-o5-prep`) may close **O5 preparation** only: the design record,
the provisioning scripts, the fixtures, and the acceptance-battery runner, all verified
everywhere they can run off-host. It does **not** close **O5 itself** — O5 stays open
until the target-host session above runs and its evidence is recorded, per the closure
statement in `docs/deployment/o5-target-host-runbook.md`.

Two items in the battery above depend on the P1-2 extraction handler, which is
explicitly out of this design's scope (see below) and has not been built yet:

- Item 4's malformed/hostile-response-fails-safely-to-review check needs the
  `sibyla.claude-extraction.v1` strict-validation and fail-safe routing logic that lives
  in the P1-2 handler.
- Item 7's cost-cap/usage-alert-firing check needs the deployed handler to generate a
  billable call to test the cap against.

These are recorded as **`BLOCKED-P1-2`**, a status distinct from **`HOST-SESSION`**:
`HOST-SESSION` means the check is fully implemented and only needs target-host
facts/execution this preparation cannot supply (a provisioned account, a real ACL, a
real network path); `BLOCKED-P1-2` means the check cannot exist on *any* host,
including the target host, until the P1-2 handler is implemented. Neither status is a
PASS, neither is eligible to close O5 by itself, and the two are never conflated: a
`BLOCKED-P1-2` item does not become closeable just by running the target-host session,
and a `HOST-SESSION` item does not need the P1-2 handler to close.

Item 5's concurrency check ("no cross-job reads") is tested honestly against the
D-O5-2 amendment above: the probe attempts a real cross-job read/write while all N jobs
are alive and records the true result. Because both restricted identities are shared
across every job they are each currently processing, a same-identity cross-job access
attempt against an already-known path may succeed at the NTFS layer even under a
correct per-job ACL template — this is a property of the two-identity design (D-O5-1),
not a defect in the template, and the acceptance evidence must record it as such rather
than assert a containment guarantee the ACL model does not provide. What the design
does guarantee, and what item 2's isolation probes prove, is that a job's siblings are
undiscoverable (job-root traverse-only, unguessable job IDs) and that the fixed
deny-listed targets (`appsettings*`, deploy root, secret store, database) are denied.

## Explicitly out of O5

The Worker extraction handler, contract validation, queue/lane behavior, skill content, and
golden set are P1-2/P1-3 backlog items. O5 is the identity boundary and its proof, nothing
more.
