# O5 Target-Host Validation-Session Runbook

**Design of record:** `docs/p1-2-o5-restricted-identity-design.md` (D-O5-1 through D-O5-6).
**Scripts:** `deploy/o5/provision-identities.ps1`, `deploy/o5/provision-jobroot.ps1`,
`deploy/o5/provision-acls.ps1`, `deploy/o5/provision-egress.ps1`,
`scripts/run-o5-acceptance.ps1`.
**Fixtures:** `tests/fixtures/o5/` (regenerate with `scripts/generate-o5-fixtures.ps1` if
missing or if `manifest.json` and the files on disk disagree).
**Evidence request version:** `sibyla-o5-acceptance/1.0` (stamped automatically by
`Save-O5EvidenceFile` in `deploy/o5/O5Common.psm1`).
**Target:** AWS Windows Server 2025 Datacenter production deployment host.
**Operator:** Miguel. Administrator may prepare fixtures beforehand; Administrator
execution of a probe satisfies nothing (F0 rule, carried into D-O5-6). Every probe that
matters must run as `svc-sibyla-pdfx` or `svc-sibyla-claude`, launched by the scripts'
secondary-logon path, not by the operator's own elevated session.

This is the **only** session that closes O5. Nothing in this document is simulated
locally; every step below either runs a script already committed on
`feature/p1-2-o5-prep` or is an explicit manual action the scripts cannot perform
(secret registration, the reboot itself, recording the closure statement).

Estimated active session time: **40–50 minutes** of scripted/operator work, plus the
host's own reboot wall-clock time in step 8 (typically a few minutes on this instance
class) — under an hour end to end on a clean run. Do not improvise substitute steps if
something fails: stop, record the failing check, and remediate per the design before
continuing (a failed item stops the session — D-O5-6 closing rule).

## 0. Prerequisites

Confirm all of these before starting; none of them are scripted here because they are
host-specific or would require live secret-store/production access this preparation
pass is not authorized to take:

- Logged on to the target host with an account able to create local users, set NTFS
  ACLs, and manage Windows Firewall rules (i.e., local Administrator), for
  provisioning only — not for running any probe.
- `Microsoft.PowerShell.SecretManagement` installed and a vault registered and
  unlocked for this session. `deploy/o5/O5Common.psm1`'s `Get-O5SecretCredential`
  refuses to run if no `Get-Secret` command is available and refuses to accept a
  plaintext/parameter-supplied password.
- Two strong, unique passwords generated and stored in that vault under the secret
  names the scripts expect by default (override with `-PdfxSecretName`/
  `-ClaudeSecretName` on every script below if different names are used):
  - `o5-svc-sibyla-pdfx-password`
  - `o5-svc-sibyla-claude-password`

  Example (adjust to the registered vault's actual syntax):
  ```powershell
  Set-Secret -Name 'o5-svc-sibyla-pdfx-password'   -SecureStringSecret (Read-Host -AsSecureString)
  Set-Secret -Name 'o5-svc-sibyla-claude-password' -SecureStringSecret (Read-Host -AsSecureString)
  ```
  Type the passwords at the interactive prompt only; never pass them as a script
  argument or leave them in shell history. Store the credential's account name in
  either bare form (`svc-sibyla-pdfx`) or `MACHINE\account`-qualified form
  (`<hostname>\svc-sibyla-pdfx`) if the vault stores a `PSCredential` rather than a bare
  `SecureString` - never a UPN (`user@domain`) form. `Resolve-O5LocalAccountIdentity`
  (`deploy/o5/O5Common.psm1`), used by every restricted-process launch path, rejects
  anything else deterministically rather than guessing at a Domain/UserName split.
- The repository branch `feature/p1-2-o5-prep` present on the host (or copied over)
  with `deploy/o5/`, `scripts/run-o5-acceptance.ps1`, `scripts/generate-o5-fixtures.ps1`,
  and `tests/fixtures/o5/` intact.
- The real, host-specific paths for: the `Sibyla.PdfTextExtractor` deployment
  directory; the Claude CLI install directory; a job-root parent (a new,
  ACL-restrictable directory such as `D:\Apps\Sibyla\O5\Jobs` — illustrative, use the
  host's actual layout); and the deny-list paths (API/Web/Worker install directories,
  `appsettings*` locations, log directories, the deploy root, and the secret-store
  profile directories per D-O5-2). None of these are invented here; the operator
  supplies them from the actual deployment, matching `docs/deployment/iis-and-worker.md`.
- If item 4 is to close fully in this session (not just its fixture-staging portion),
  the already-published `Sibyla.PdfTextExtractor.exe` and the installed headless
  Claude CLI executable must be present on the host at known paths. Both exist
  independently of the P1-2 Worker extraction handler (which this prep does **not**
  build), so this is safe to exercise now.
- The Windows Firewall `NetSecurity` module available (`Get-Command
  New-NetFirewallRule`). Confirm whether this host's Windows Firewall build supports
  `Add-NetFirewallDynamicKeywordAddress` — step 5 needs the answer, not an assumption.
- A short list of unrelated, plain-443 control hostnames for the egress control-host
  check (D-O5-6 item 3) that the operator is comfortable naming for this session; none
  is pre-recorded in the repository.
- A canary value for the key-hygiene scan. The scripts default to
  `O5-CANARY-KEYHYGIENE-not-a-real-secret`; keep the default unless a reason exists to
  change it.

All commands below run from the repository root in an elevated PowerShell session,
unless the command line says otherwise.

## 1. Provision the two restricted identities (D-O5-1)

```powershell
.\deploy\o5\provision-identities.ps1 -Confirm:$false
.\deploy\o5\provision-identities.ps1 -Verify
```

Expected: `-Verify` prints `PASS` for `AccountExistsLocally`, `NotLocalAdministrator`,
`Enabled`, `SeDenyInteractiveLogonRight`, `SeDenyRemoteInteractiveLogonRight`, and
`PasswordNotExpired` for both `svc-sibyla-pdfx` and `svc-sibyla-claude`, and exits `0`.
Re-running the provisioning command a second time makes no further changes
(idempotency) - in particular, it does not touch either account's password.

### 1a. Rotate existing account passwords (D-O5-1, when required by host policy)

Only needed when `-Verify`'s `PasswordNotExpired` check fails, or the host's password
rotation policy otherwise requires it. Update the secret store first, then rotate -
never the other way around, so the script never reads a stale credential:

```powershell
Set-Secret -Name 'o5-svc-sibyla-pdfx-password'   -SecureStringSecret (Read-Host -AsSecureString)
Set-Secret -Name 'o5-svc-sibyla-claude-password' -SecureStringSecret (Read-Host -AsSecureString)

.\deploy\o5\provision-identities.ps1 -RotateExistingPasswords -Confirm:$false
.\deploy\o5\provision-identities.ps1 -Verify
```

Type the new passwords at the interactive prompt only, exactly as in step 0; never pass
them as a script argument. Expected: the rotation command makes no account-creation or
group-membership changes (both accounts already exist) and only calls `Set-LocalUser`
for the password; the following `-Verify` prints `PASS` for `PasswordNotExpired` for
both accounts. `-RotateExistingPasswords -WhatIf` previews with no change and no
secret-store read.

## 2. Create the job-root parent (D-O5-2, per-job template)

Pass **both** real restricted identities to `-ProbeOwnerIdentityReferences` — per the
2026-08-07 design amendment, one transient directory exists per logical job and both
`svc-sibyla-pdfx` and `svc-sibyla-claude` receive Modify/RW on it, so the template proof
is incomplete with only one identity.

```powershell
.\deploy\o5\provision-jobroot.ps1 -JobRootParent 'D:\Apps\Sibyla\O5\Jobs' -Confirm:$false
.\deploy\o5\provision-jobroot.ps1 -Verify -JobRootParent 'D:\Apps\Sibyla\O5\Jobs' `
    -ProbeOwnerIdentityReferences @("$env:COMPUTERNAME\svc-sibyla-pdfx", "$env:COMPUTERNAME\svc-sibyla-claude")
```

Expected: `-Verify` prints `PASS` for `JobRootParentExists`, one `ProbeJobDirectoryOwnerRW`
row **per identity passed** (both must show `PASS`), `ProbeJobDirectoryNonOwnerDenied`
(proving a third, uninvolved identity gets nothing), and `ProbeJobDirectoryCleanedUp`,
and exits `0`.

## 3. Apply the ACL matrix (D-O5-2)

```powershell
.\deploy\o5\provision-acls.ps1 -Confirm:$false `
    -HelperBinaryDirectory '<Sibyla.PdfTextExtractor deployment directory>' `
    -ClaudeCliInstallDirectory '<Claude CLI install directory>' `
    -JobRootParent 'D:\Apps\Sibyla\O5\Jobs' `
    -DenyPaths @('<API install dir>', '<Web install dir>', '<Worker install dir>', '<appsettings parent(s)>', '<log directory>', '<deploy root>', '<secret-store profile dir>')

.\deploy\o5\provision-acls.ps1 -Verify `
    -HelperBinaryDirectory '<Sibyla.PdfTextExtractor deployment directory>' `
    -ClaudeCliInstallDirectory '<Claude CLI install directory>' `
    -JobRootParent 'D:\Apps\Sibyla\O5\Jobs' `
    -DenyPaths @('<same paths as above>')
```

Expected: `-Verify` prints a `PASS` row for every path/identity pair in the D-O5-2
matrix table (helper directory RX for pdfx / None for claude; CLI directory RX for
claude / None for pdfx; job-root parent Traverse for both; every deny path Deny for
both) and exits `0`. Any `FAIL` row stops the session — remediate the specific ACL
before continuing, do not proceed with a known-bad matrix.

Note: `-ProtectFromInheritance` strips any pre-existing broad inherited ACE on these
paths (e.g. a `BUILTIN\Users`/`Authenticated Users` Read/RX grant carried from a parent
directory's default ACL) rather than converting it to an explicit rule — if one of
these directories already had a broad inherited grant before this step, it is gone
after provisioning, by design, and does not need separate remediation. It also strips
any pre-existing broad **explicit** `Everyone`/`BUILTIN\Users`/`Authenticated Users`
allow ACE on these paths, even if the path was already protected before this step ran
— required `SYSTEM`/`Administrators` grants are never touched.

There is no separate step to provision a writable Claude CLI cache directory: per the
2026-08-07 D-O5-2 amendment, that cache is created per job, inside each job directory,
by the acceptance harness at run time (step 6) — see that step's note on
`CLAUDE_CONFIG_DIR` routing.

## 4. Egress mechanism decision point (D-O5-3 / D-O5-6 item 3)

Try the FQDN mechanism first.

```powershell
.\deploy\o5\provision-egress.ps1 -Confirm:$false -Mechanism FqdnRule
.\deploy\o5\provision-egress.ps1 -Verify -Mechanism FqdnRule
```

If `-Verify` passes (both deny-all rules present, the Anthropic allow rule present,
the dynamic-keyword address object present), run the real egress probe to confirm
enforcement, not just rule presence:

```powershell
.\scripts\run-o5-acceptance.ps1 -JobRootParent 'D:\Apps\Sibyla\O5\Jobs' `
    -EvidenceDirectory 'C:\SibylaApps\O5\evidence' `
    -ControlHosts @('<control host 1>', '<control host 2>') `
    -Confirm:$false
```

(This also runs items 1, 2, 5, and 7 — that is expected here; re-run the full battery
again in step 6 once every deny path and both executables are wired in. Running it now
is what actually proves the egress mechanism, not just that the firewall rule object
exists.) Check the `D-O5-6 item 3` rows in the emitted evidence file: `PASS` on
`AnthropicApiReachability` for `svc-sibyla-claude`, `FAIL` (i.e., blocked, which is the
`PASS` condition — read the row, not just the color) for `svc-sibyla-pdfx`'s
`AnthropicApiReachability`, and `PASS` on every `ControlHostBlocked` row for both
identities.

**If `FqdnRule` fails** (module unsupported, dynamic-keyword object won't resolve, or
the egress probe shows the Anthropic endpoint unreachable for `svc-sibyla-claude`),
fall back:

```powershell
.\deploy\o5\provision-egress.ps1 -Confirm:$false -Mechanism LoopbackProxy
```

This requires a loopback-only forward proxy process already installed and configured
to read `C:\SibylaApps\O5\loopback-proxy-allowlist.json` (the script writes that
policy file and the firewall restriction to the proxy port only; it does not install
or start a proxy binary — the design leaves that implementation choice to this
session). Start the proxy, then:

```powershell
.\deploy\o5\provision-egress.ps1 -Verify -Mechanism LoopbackProxy
```

Re-run the same `run-o5-acceptance.ps1` egress check above and confirm the same
`PASS` pattern. **Record which mechanism passed** — that is the one that ships to
production; remove the other mechanism's allow rule and config now, so only one allow
path exists for `svc-sibyla-claude` in the final firewall state. Never leave both
mechanisms active.

Exact removal commands — preview first, then remove, then prove absence. Substitute
the losing mechanism below (if `FqdnRule` won, remove `LoopbackProxy`, and vice versa):

```powershell
# Preview only, no changes:
.\deploy\o5\provision-egress.ps1 -Remove -Mechanism <the losing mechanism> -WhatIf

# Remove for real:
.\deploy\o5\provision-egress.ps1 -Remove -Mechanism <the losing mechanism> -Confirm:$false

# Prove it is actually gone, SID-scoping included:
.\deploy\o5\provision-egress.ps1 -Verify -Mechanism <the losing mechanism> -ExpectAbsent
```

Expected: `-Remove -WhatIf` reports what would be removed and changes nothing;
`-Remove -Confirm:$false` exits `0`; `-Verify -ExpectAbsent` prints `PASS` for the
losing mechanism's allow rule (and, for `FqdnRule`, its dynamic-keyword address object;
for `LoopbackProxy`, its policy config file) being absent, and exits `0`. The shared
SID-keyed deny-all rules for both identities are never touched by `-Remove` — only the
one allow path is removed. Finish by re-running
`.\deploy\o5\provision-egress.ps1 -Verify -Mechanism <the winning mechanism>` (without
`-ExpectAbsent`) to confirm the winning mechanism's rules are still present and
correctly SID-scoped after the removal.

## 5. Stage or confirm the injection-battery binaries (D-O5-6 item 4)

If `Sibyla.PdfTextExtractor.exe` and the Claude CLI executable are already deployed at
known paths, note them for step 6:

```powershell
$PdfxExe   = '<path to Sibyla.PdfTextExtractor.exe>'
$ClaudeExe = '<path to the headless claude executable>'
```

If neither is available yet, item 4's actual child-process launch stays
`HOST-SESSION` in this pass (its fixture-staging, hash-verification, and
canary-confinement checks still run and still close normally) — record that
explicitly rather than treating a partial item-4 pass as a full one.

## 6. Run the full acceptance battery (D-O5-6 items 1, 2, 3, 4, 5, 7)

```powershell
.\scripts\run-o5-acceptance.ps1 -Confirm:$false `
    -JobRootParent 'D:\Apps\Sibyla\O5\Jobs' `
    -EvidenceDirectory 'C:\SibylaApps\O5\evidence' `
    -DenyPaths @('<same deny paths as step 3>') `
    -DatabaseProbeTarget '<db host>:<db port>' `
    -ControlHosts @('<control host 1>', '<control host 2>') `
    -PdfxHelperExecutablePath $PdfxExe `
    -ClaudeCliExecutablePath $ClaudeExe `
    -ConcurrencyCount 2 `
    -KeyHygieneScanPaths @('<svc-sibyla-pdfx profile/cache dir>', '<svc-sibyla-claude profile/cache dir>', 'D:\Apps\Sibyla\O5\Jobs')
```

Read the printed `statusLines` summary and the written evidence JSON. `HOST-SESSION` and
`BLOCKED-P1-2` are distinct, non-PASS statuses with different closure meaning:

- `HOST-SESSION` means this run's own preconditions were incomplete (e.g. step 5's
  binaries were not supplied) — fix the precondition and re-run; a `HOST-SESSION` row
  must not remain in the evidence that records O5's closure.
- `BLOCKED-P1-2` means the check cannot exist until the P1-2 extraction handler is
  built: item 4's `HostileOutputRoutedToReview` row, item 4's
  `WorkerProductionClaudeCliCacheRouting` row (this harness proves the per-job
  `CLAUDE_CONFIG_DIR` cache lifecycle and routing mechanism work; the Worker's own
  production spawn path performing the same routing is P1-2 Worker source work), and
  item 7's `CostCapsAndAlertingFireOnTestThreshold` row. These are **expected** in
  every run of this battery until P1-2 ships and do not block this session or O5's
  closure; do not attempt to force them to `PASS` and do not read their presence as a
  session failure.

Every other row must show `PASS`. New this pass: item 2 also lists-and-denies
`JobRootParent` itself and attempts a write (not just a read) against every deny path
and the sibling job; item 5 attempts a real cross-job read/write while all N jobs are
alive and reports per-job cleanup individually — read the `CrossJobWhileAlive:*` rows'
`detail` text before treating a `FAIL` there as a session blocker: because both
restricted identities are shared across every job they are each currently processing
(D-O5-2 amendment), a same-identity cross-job access attempt against an already-known
path can succeed at the NTFS layer even under a correct per-job template. Record what
the evidence actually shows; do not fabricate denial. Any `FAIL` outside that
documented, honest exception stops the session. If either a `Denied:Write` or a
`CrossJobWhileAlive:Write` row unexpectedly shows an accessible write, its matching
`WriteProbeCleanupSucceeded`/`CrossJobWriteProbeCleanupSucceeded` row must still show
`PASS` — a failed cleanup is a session blocker in its own right, independent of the
write-denial result itself.

Also new this pass: for every Claude CLI launch, item 4 creates a per-job writable CLI
cache directory (`New-O5JobScopedClaudeCliCacheDirectory`) and routes the CLI child to
it via the `CLAUDE_CONFIG_DIR` environment variable, recording
`ClaudeCliCacheWithinJobBoundary` (must be `PASS`) and, after cleanup,
`ClaudeCliCacheRemovedOnCleanup` (must be `PASS`) — the cache must never survive
outside its own job's lifecycle.

## 7. Post-reboot re-run (D-O5-6 item 6)

Reboot the host, then log back on with the same elevated operator account (this is
the *operator's* session, not the restricted identities' — the criterion is that
`svc-sibyla-pdfx`/`svc-sibyla-claude` never need an interactive logon of their own,
matching the F0 post-reboot precedent). Re-run the **exact same command** from step 6
with a fresh `-EvidenceDirectory` (or let it write a new timestamped file into the
same one):

```powershell
.\scripts\run-o5-acceptance.ps1 -Confirm:$false `
    -JobRootParent 'D:\Apps\Sibyla\O5\Jobs' `
    -EvidenceDirectory 'C:\SibylaApps\O5\evidence' `
    -DenyPaths @('<same deny paths as step 3>') `
    -DatabaseProbeTarget '<db host>:<db port>' `
    -ControlHosts @('<control host 1>', '<control host 2>') `
    -PdfxHelperExecutablePath $PdfxExe `
    -ClaudeCliExecutablePath $ClaudeExe `
    -ConcurrencyCount 2 `
    -KeyHygieneScanPaths @('<svc-sibyla-pdfx profile/cache dir>', '<svc-sibyla-claude profile/cache dir>', 'D:\Apps\Sibyla\O5\Jobs')
```

Expected: identical pass pattern to step 6, with no interactive logon performed as
either restricted identity at any point (Windows Service equivalence — D-O5-5).

## 8. Save evidence and record closure

Keep every timestamped `o5-acceptance-<RunId>.json` file written to
`C:\SibylaApps\O5\evidence` (steps 4, 6, and 7) — do not overwrite or delete them; they
are the sanitized proof record. Confirm none contains a raw secret (they cannot by
construction — `New-O5EvidenceRecord`/`Find-O5CanaryLeak` never emit the canary or key
value itself, only hit locations and hashes), but a quick visual check before filing is
still good practice.

Only once **every** battery item (1, 2, 3, 4, 5, 7) shows `PASS` in both the step-6 run
and the step-7 post-reboot run — with the sole documented exception of the three
`BLOCKED-P1-2` rows (item 4's `HostileOutputRoutedToReview` and
`WorkerProductionClaudeCliCacheRouting`, item 7's
`CostCapsAndAlertingFireOnTestThreshold`), which remain `BLOCKED-P1-2` until the P1-2
handler exists and do not block this closure — does O5 close. Record in
`docs/archive/PROJECT-STATE.md` (historical; superseded by `docs/STATE.md` — that edit
belongs to the closing session):

> O5 closed. Target-host acceptance battery (D-O5-6 items 1–7) passed on
> `<hostname>` on `<date>`, including a post-reboot re-run with no interactive logon.
> Egress mechanism: `<FqdnRule|LoopbackProxy>`; the losing mechanism was removed and its
> absence verified (`provision-egress.ps1 -Verify -ExpectAbsent`). Item 4's
> `HostileOutputRoutedToReview` and `WorkerProductionClaudeCliCacheRouting`, and item
> 7's `CostCapsAndAlertingFireOnTestThreshold`, remain `BLOCKED-P1-2`, pending the P1-2
> extraction handler, and do not block this closure. Evidence:
> `C:\SibylaApps\O5\evidence\o5-acceptance-<RunId>.json`
> (pre-reboot) and `o5-acceptance-<RunId>.json` (post-reboot).

If any item fails at any point (outside the documented `BLOCKED-P1-2` exception above,
and outside the documented same-identity `CrossJobWhileAlive` exception in step 6), do
not write a closure statement. Record the failing check and its evidence file,
remediate per the design (`docs/p1-2-o5-restricted-identity-design.md`), and re-run
from the failed step.

## Rollback

Provisioning is idempotent and additive; there is no scripted rollback for identities
or ACLs. Egress has a scripted, idempotent removal path
(`provision-egress.ps1 -Remove -Mechanism <FqdnRule|LoopbackProxy>`, step 4 above) —
prefer it over manual rule deletion even outside the normal decision point. To remove
the full O5 footprint from a host: delete the two local accounts, run `-Remove` for
both mechanisms (or `Get-NetFirewallRule -DisplayName 'O5-*' | Remove-NetFirewallRule`
for anything scripted removal missed), remove the explicit ACEs added to the
helper/CLI/deny-path directories (or restore those directories' ACLs from backup), and
delete the job-root parent and `C:\SibylaApps\O5\` config/evidence directories. Do this
only if abandoning the O5 attempt entirely; a failed individual check does not require
a rollback, only a fix and a re-run.
