@ouro.bot/cli 0.1.0-alpha.886 → 0.1.0-alpha.888

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/changelog.json +27 -0
  2. package/deploy/unraid/README.txt +7 -5
  3. package/deploy/unraid/sanctuary-butler-upgrade.mjs +87 -30
  4. package/deploy/unraid/sanctuary-replay-gate.mjs +473 -74
  5. package/deploy/unraid/sanctuary-unit16-host-broker.mjs +1 -0
  6. package/deploy/unraid/sanctuary.ouro/bundle-meta.json +1 -1
  7. package/deploy/unraid/sanctuary.xml +2 -1
  8. package/dist/a2a/client.js +16 -4
  9. package/dist/a2a/delegated-command-grants.js +93 -0
  10. package/dist/a2a/delegated-command.js +57 -11
  11. package/dist/a2a/escalation-grants.js +37 -75
  12. package/dist/a2a/identity.js +54 -0
  13. package/dist/a2a/operator-trust.js +382 -0
  14. package/dist/a2a/outbox-wire.js +2 -2
  15. package/dist/a2a/replay-grant-holders.js +33 -0
  16. package/dist/a2a/server.js +12 -7
  17. package/dist/a2a/trust-commands.js +203 -0
  18. package/dist/a2a/trusted-files.js +113 -26
  19. package/dist/heart/core.js +60 -33
  20. package/dist/heart/daemon/cli-exec.js +38 -37
  21. package/dist/heart/daemon/cli-parse.js +45 -5
  22. package/dist/heart/daemon/container-spec-auditor.js +3 -0
  23. package/dist/heart/daemon/sanctuary-acceptance-harness.js +1 -1
  24. package/dist/heart/daemon/sanctuary-acceptance-scenarios.js +1 -1
  25. package/dist/heart/daemon/sanctuary-authority-root-lifecycle.js +4 -47
  26. package/dist/heart/failure-reports.js +5 -5
  27. package/dist/mind/prompt.js +31 -1
  28. package/dist/repertoire/guardrails.js +89 -7
  29. package/dist/repertoire/house-sweep.js +4 -1
  30. package/dist/senses/a2a-entry.js +2 -1
  31. package/dist/senses/private-runtime.js +11 -4
  32. package/dist/senses/shared-turn.js +5 -1
  33. package/dist/senses/telegram.js +5 -3
  34. package/npm-shrinkwrap.json +2 -2
  35. package/package.json +1 -1
package/changelog.json CHANGED
@@ -1,6 +1,33 @@
1
1
  {
2
2
  "_note": "This changelog is maintained as part of the PR/version-bump workflow. Agent-curated, not auto-generated. Agents read this file directly via read_file to understand what changed between versions.",
3
3
  "versions": [
4
+ {
5
+ "version": "0.1.0-alpha.888",
6
+ "changes": [
7
+ "Answer gates (source grounding and brevity) now hold a plain-text reply as well as a settle answer, so a reply with no settle call can no longer name an unsourced cast or ignore a brevity request."
8
+ ]
9
+ },
10
+ {
11
+ "version": "0.1.0-alpha.887",
12
+ "changes": [
13
+ "Delegated commands and escalation now come only from root-owned grant files in /etc/ouro/trust/<agent> (or OURO_OPERATOR_TRUST_DIR/<agent>, resolved through symlinks), checked up the whole directory chain and pinned to the sender's DID; grant writes are locked, fsynced and backed up, and the Butler's grants moved there with an upgrade check that refuses to silently drop an escalation grant",
14
+ "New 'ouro a2a delegated-commands grant|revoke|list' operator command and a DID-pinned 'escalation grant', both refusing this agent's own DID and a DID another holder already has, and both revoking by id when the friend record is gone; refusals say nothing ran and what to do next, and peers that are not active family only ever see no_grant",
15
+ "The replay gate now grants delegated commands only for the length of a window and revokes them on close, a replay grant without an expiry is refused, a forged delegation banner is neutralised on every turn (including Unicode look-alikes), and the friends store is protected from the model's file tools and any shell command that is not read-only",
16
+ "Telegram messages are now shielded from forged delegated-command banners at ingress",
17
+ "The replay gate refuses planted symlinks and wrong file types, using no-follow descriptors",
18
+ "provision --rotate-replay-identities mints new replay seeds and rewrites the replay grants as root",
19
+ "Revoke never writes the friend record as root and prints the docker exec follow-up",
20
+ "Permanent replay grants are refused from a root-owned replay list inside the trust directory",
21
+ "Stale trust-dir lock takeover is serialized, and the replay gate takes the same lock",
22
+ "The friends and psyche guards refuse writes whose globs or variables could reach protected stores",
23
+ "Admitted delegated turns carry a runtime marker, and banner text in a message grants nothing",
24
+ "A DID the agent publishes in its bundle is advisory: grant and escalation commands always run the twin check and name where the DID came from",
25
+ "Replay identity seeds and the provisioning record now live in a root-owned host folder outside the bundle; root never takes over a folder the agent made, refuses one swapped in mid-check, and takes replay friend ids and DIDs only from root-owned state",
26
+ "The verified-delegated-command phrase is shielded in every tool result, including punctuated and look-alike spellings, and delegate and principal names are kept to one plain line in the prompt",
27
+ "An untrusted or malformed replay list denies no-expiry grants, a stale trust-dir lock is reclaimed only when its writer's pid is gone, and rotation demotes old replay friends to stranger, clears their DID pin and keeps going when a record is missing",
28
+ "The replay gate reads agent-writable files without following symlinks and never echoes file contents in parse errors"
29
+ ]
30
+ },
4
31
  {
5
32
  "version": "0.1.0-alpha.886",
6
33
  "changes": [
@@ -1,14 +1,16 @@
1
1
  Mendelow Cloud Butler operator runbook
2
2
 
3
- The production container is ouro-butler. It runs as UID/GID 10001, publishes no ports, uses host networking only so its loopback-only Unraid GraphQL client can reach 127.0.0.1, mounts the runtime and sanctuary.ouro bundle read-write, the bundle psyche folder read-only over it so the Butler can neither edit nor replace its psyche, plus the privileged event spool read-only, and uses restart policy unless-stopped. The canonical-gateway image appends /run/ouro-authority -> /run/ouro-authority, readonly, rprivate, and the psyche mount (<bundle>/psyche -> /home/ouro/AgentBundles/sanctuary.ouro/psyche, readonly, rprivate). The pinned alpha.816 predecessor has only the first three mounts. Root token, issuer, package and authority state are never mounted into the resident.
3
+ The production container is ouro-butler. It runs as UID/GID 10001, publishes no ports, uses host networking only so its loopback-only Unraid GraphQL client can reach 127.0.0.1, mounts the runtime and sanctuary.ouro bundle read-write, the bundle psyche folder read-only over it so the Butler can neither edit nor replace its psyche, plus the privileged event spool read-only, and uses restart policy unless-stopped. The canonical-gateway image appends /run/ouro-authority -> /run/ouro-authority, readonly, rprivate, and the psyche mount (<bundle>/psyche -> /home/ouro/AgentBundles/sanctuary.ouro/psyche, readonly, rprivate) and the operator trust mount (/mnt/user/appdata/ouro-butler/trust/sanctuary -> /etc/ouro/trust/sanctuary, readonly, rprivate), a root-owned directory outside the bundle that holds the grants the Butler honours. The pinned alpha.816 predecessor has only the first three mounts. Root token, issuer, package and authority state are never mounted into the resident.
4
4
 
5
5
  Root authority lifecycle
6
6
 
7
7
  The existing DockerMan transaction owns authority-install, authority-activate, authority-retire and authority-restore. A complete exact-image package, including dist and production dependencies, must remain at its package-relative paths; extracting only the old five deployment assets cannot execute this lifecycle. The reviewed root-only inputs are /mnt/user/appdata/ouro-authority/incoming-package, package-manifest.json, request.json and incoming-token. They are not a new resident credential policy. The human rotates the existing bot token directly into root custody; the root owner proves the previous token returns 401 and the fresh token belongs to the same numeric bot before continuing.
8
8
 
9
- First rollout of the psyche mount: the live container before this release has four mounts, but the canonical-gateway contract now requires five (the psyche folder read-only over the bundle). assert_update_source audits the live source container against its image's contract, so on that first update it fails with "effective mounts do not match the selected Sanctuary mount contract". The failure is expected and changes nothing. Recreate the live resident once by hand with the five-mount create (the canonical-gateway line in create_sanctuary_container, including the psyche mount), leave it stopped, then rerun the update. Psyche files stay owned by uid 10001 at mode 0600 because the resident's boot check requires exactly that; the read-only mount, not ownership, keeps the resident from editing or replacing them.
9
+ First rollout of the psyche mount: the live container before this release has four mounts, but the canonical-gateway contract now requires six (the psyche folder read-only over the bundle and the operator trust directory). assert_update_source audits the live source container against its image's contract, so on that first update it fails with "effective mounts do not match the selected Sanctuary mount contract". The failure is expected and changes nothing. Recreate the live resident once by hand with the six-mount create (the canonical-gateway line in create_sanctuary_container, including the psyche and trust mounts), leave it stopped, then rerun the update. Psyche files stay owned by uid 10001 at mode 0600 because the resident's boot check requires exactly that; the read-only mount, not ownership, keeps the resident from editing or replacing them.
10
10
 
11
- During installation the resident is stopped, the root gateway is installed and proven, tokenless readiness runs, and authority-activate starts the exact five-mount target. On failure, authority-retire must finish before restoring bundle/config/mounts or starting the exact three-mount predecessor; authority-restore proves that rollback before the outer template journal is removed. An uncertain or interrupted handoff leaves autostart disabled and preserves both journals and root inputs for the same reviewed recovery, never a raw old-template start. Retired keys, policy, history and Jellyfin remain untouched. Re-enabling authority requires a new human token rotation and issuer epoch, not reuse of retired authority.
11
+ Operator trust directory. Delegated-command and escalation grants live on the host in /mnt/user/appdata/ouro-butler/trust/sanctuary, owned by root:root, mode 0755, with every file in it root-owned and mode 0644, and the directory is outside the bundle so the upgrade's recursive chown cannot reach it. The container sees it read-only at /etc/ouro/trust/sanctuary; the Butler honours a grant only when the file and every directory above it up to / are real, root-owned and closed to group and other writes. Create it once as root before the first create: `install -d -o root -g root -m 0755 /mnt/user/appdata/ouro-butler /mnt/user/appdata/ouro-butler/trust /mnt/user/appdata/ouro-butler/trust/sanctuary` (this also makes /mnt/user/appdata/ouro-butler itself root-owned 0755, which the replay gate needs for its host folder below; the agent and runtime folders inside it keep their own owners). Manage grants on the host as root with a one-off container that mounts the directory read-write (the resident's mount is read-only): `docker run --rm --user 0:0 --entrypoint node --pull=never --network none -v /mnt/user/appdata/ouro-butler/trust/sanctuary:/etc/ouro/trust/sanctuary -v /mnt/user/appdata/ouro-butler/agent/sanctuary.ouro:/home/ouro/AgentBundles/sanctuary.ouro:ro <butler image> /opt/ouro/dist/heart/daemon/ouro-entry.js a2a delegated-commands grant --agent sanctuary --friend <friend id> --did <did:key> [--expires <ISO date>] [--source <text>]`, and likewise `a2a delegated-commands revoke|list` and `a2a escalation grant --friend <id> --did <did:key>|revoke|list`. Setting OURO_OPERATOR_TRUST_DIR replaces the trust root (/etc/ouro/trust) and keeps the per-agent folder, so the files sit in <dir>/<agent>/. Grant commands take an exclusive .lock file in the directory (the lock records the writer's pid and host; one older than two minutes is taken over only when that pid is no longer running on the same host, and one written on another host only after an hour; remove a stuck one by hand) and flush the directory after each rename. A one-off container cannot read the agent's own DID, so grant then compares --did with every friend record and refuses a DID a second record also carries, and prints a note saying so; revoke works by friend id even when the friend record is gone. Revoke never writes the friend record (it runs as root); when the record still carries a legacy delegationGrant it prints a REQUIRED FOLLOW-UP docker exec -u 10001 line that clears the legacy delegationGrant field as the resident user (friend records are never written as root). The replay gate grants its replay principal and escalation peer with an expiry equal to the replay window and removes them when it closes; the replay gate keeps a root-owned list of its replay peers in the trust directory (replay-identities.json), the Butler refuses any grant without an expiry for a friend on that list (the grant's source text and the bundle's own replay registry decide nothing), and the grant commands refuse a no-expiry grant, and any escalation grant, for a listed friend. Replay seeds and the provisioning record live outside the bundle, in the root-owned host folder /mnt/user/appdata/ouro-butler/replay-client (the gate's --client-dir overrides it), created by provision as root 0700 under a parent that is root-owned and closed to group and other writes (by default only that parent, /mnt/user/appdata/ouro-butler, is checked, because Unraid's shared /mnt/user/appdata is usually nobody:users 0777); the gate refuses a folder, a parent or a seed that root did not make, and never takes over one the Butler's user could have written. Only root-owned state may supply replay friend ids and DIDs: that folder and the trust directory's replay-identities.json. Nothing in the bundle's state/replay-client or state/replay does, and a provision.json found in the bundle is ignored (its friend ids are only printed, as unverified). A run copies the seeds into a root-only directory it makes inside the container with mktemp -d, drives the CLI as root from there, and deletes it when the window closes. An existing state/replay in the bundle that root does not own is moved aside as replay.untrusted-<time> and made fresh. One-time step on the upgrade that moves the replay seeds out of the bundle: run `node sanctuary-replay-gate.mjs provision --rotate-replay-identities` as root. Earlier replay seeds were written by the Butler's user and must be treated as known to it, so this mints three new seeds (root-owned 0600, in the host folder above), moves any old seed in that folder aside as *.retired-<time>, removes the retired peers' trusted grants, marks their friend records revoked, demotes them to stranger and drops their DID pin and A2A identity (the last two as the resident user, never as root), onboards the new peers and rewrites state/replay/identities.json from the root-owned list; the old friend ids stay in that registry so they stay blocked from owner-policy writes. The peers it retires are the ones the root-owned record and replay list name. The earlier bundle copy of provision.json is not trusted, so the first rotation prints the friend ids it names as unverified; for each one you have checked is a replay peer, add `--retire-friend <id>` (repeatable). Always pass the DID the holder really signs with (`ouro a2a identity --json` on the holder's side); the Butler refuses a message signed by any other DID. First rollout of the trust mount: the live container has five mounts, so the first update fails the six-mount audit exactly as the psyche rollout did; recreate the resident once by hand with the six-mount create and rerun the update.
12
+
13
+ During installation the resident is stopped, the root gateway is installed and proven, tokenless readiness runs, and authority-activate starts the exact six-mount target. On failure, authority-retire must finish before restoring bundle/config/mounts or starting the exact three-mount predecessor; authority-restore proves that rollback before the outer template journal is removed. An uncertain or interrupted handoff leaves autostart disabled and preserves both journals and root inputs for the same reviewed recovery, never a raw old-template start. Retired keys, policy, history and Jellyfin remain untouched. Re-enabling authority requires a new human token rotation and issuer epoch, not reuse of retired authority.
12
14
 
13
15
  On installed cold boot, the original unless-stopped and DockerMan autostart remain unchanged: /boot/config/custom/ouro-authority/start.sh --boot waits for the array and responsive Docker, then starts the fenced root gateway. Docker may already have started the tokenless resident; it fails closed until the gateway is ready. There is no pre-Docker gate, whole-array gate, resident direct poller or resident token fallback.
14
16
 
@@ -328,7 +330,7 @@ Effective-spec audit helper:
328
330
  test "$(sanctuary_image_mount_contract "$CREATE_IMAGE_ID")" = "$CREATE_MOUNT_CONTRACT" || return 1
329
331
  test "$(docker image inspect --format '{{.Id}}' "$CREATE_IMAGE_REFERENCE")" = "$CREATE_IMAGE_ID" || return 1
330
332
  case "$CREATE_MOUNT_CONTRACT" in
331
- canonical-gateway) set -- --mount "type=bind,src=/run/ouro-authority,dst=/run/ouro-authority,readonly" --mount "type=bind,src=/mnt/user/appdata/ouro-butler/agent/sanctuary.ouro/psyche,dst=/home/ouro/AgentBundles/sanctuary.ouro/psyche,readonly" ;;
333
+ canonical-gateway) set -- --mount "type=bind,src=/run/ouro-authority,dst=/run/ouro-authority,readonly" --mount "type=bind,src=/mnt/user/appdata/ouro-butler/agent/sanctuary.ouro/psyche,dst=/home/ouro/AgentBundles/sanctuary.ouro/psyche,readonly" --mount "type=bind,src=/mnt/user/appdata/ouro-butler/trust/sanctuary,dst=/etc/ouro/trust/sanctuary,readonly" ;;
332
334
  canonical-pre-gateway) set -- ;;
333
335
  *) return 1 ;;
334
336
  esac
@@ -1671,7 +1673,7 @@ NODE
1671
1673
  TELEGRAM_READINESS_CONTRACT=$(sanctuary_image_mount_contract "$TELEGRAM_READINESS_IMAGE_ID") || return $?
1672
1674
  TELEGRAM_READINESS_CONTEXT=${2-}
1673
1675
  case "$TELEGRAM_READINESS_CONTRACT" in
1674
- canonical-gateway) TELEGRAM_READINESS_CONTEXT=${TELEGRAM_READINESS_CONTEXT:-strict}; set -- --mount "type=bind,src=/run/ouro-authority,dst=/run/ouro-authority,readonly" --mount "type=bind,src=/mnt/user/appdata/ouro-butler/agent/sanctuary.ouro/psyche,dst=/home/ouro/AgentBundles/sanctuary.ouro/psyche,readonly" ;;
1676
+ canonical-gateway) TELEGRAM_READINESS_CONTEXT=${TELEGRAM_READINESS_CONTEXT:-strict}; set -- --mount "type=bind,src=/run/ouro-authority,dst=/run/ouro-authority,readonly" --mount "type=bind,src=/mnt/user/appdata/ouro-butler/agent/sanctuary.ouro/psyche,dst=/home/ouro/AgentBundles/sanctuary.ouro/psyche,readonly" --mount "type=bind,src=/mnt/user/appdata/ouro-butler/trust/sanctuary,dst=/etc/ouro/trust/sanctuary,readonly" ;;
1675
1677
  canonical-pre-gateway) TELEGRAM_READINESS_CONTEXT=${TELEGRAM_READINESS_CONTEXT:-live-precutover}; set -- ;;
1676
1678
  *) return 1 ;;
1677
1679
  esac
@@ -72,7 +72,8 @@ const MAINTENANCE = "/run/ouro-authority-maintenance"
72
72
  // The stop hook's flag: every keeper watchdog version stands down while it exists.
73
73
  const SHUTDOWN_FLAG = "/run/ouro-authority-shutdown"
74
74
  const GATE_SCRIPT = `${ROOT}/package/deploy/unraid/sanctuary-replay-gate.mjs`
75
- const GATE_PROVISION = `${BUNDLE}/state/replay-client/provision.json`
75
+ // The replay clients' seeds and provisioning record: a root-owned host folder outside the bundle, which the Butler's user cannot write.
76
+ const GATE_PROVISION = "/mnt/user/appdata/ouro-butler/replay-client/provision.json"
76
77
  // The whole gate (every case, including the eight-minute await wait) must finish well inside this; a hung gate is a failed gate.
77
78
  export const GATE_TIMEOUT_MS = 95 * 60 * 1000
78
79
  const UPGRADE_STEPS = ["stop", "switch", "resident", "migrate", "start"]
@@ -163,6 +164,26 @@ function preflight(version) {
163
164
  } catch { bad(`${f} is missing`) }
164
165
  }
165
166
 
167
+ say("delegated-command grants the Butler will still honour")
168
+ {
169
+ const friendRecords = {}
170
+ try {
171
+ for (const file of readdirSync(`${BUNDLE}/friends`).filter((name) => name.endsWith(".json"))) {
172
+ try { friendRecords[file.replace(/\.json$/u, "")] = JSON.parse(readFileSync(`${BUNDLE}/friends/${file}`, "utf8")) } catch { /* unreadable record: not a grant source */ }
173
+ }
174
+ } catch { /* no friends folder yet */ }
175
+ let trustedGrants = {}
176
+ try { trustedGrants = JSON.parse(readFileSync(`${TRUST_HOST_DIR}/delegated-command-grants.json`, "utf8")).grants ?? {} } catch { /* no trusted grants yet */ }
177
+ const warnings = legacyDelegationGrantWarnings(friendRecords, trustedGrants)
178
+ warnings.length === 0 ? ok("no friend record relies on the old delegationGrant") : warnings.forEach((line) => console.log(` WARNING: ${line}`))
179
+ }
180
+
181
+ say("escalation holders the upgrade would drop")
182
+ {
183
+ const dropped = escalationGrantProblems()
184
+ dropped.length === 0 ? ok("every escalation holder in the bundle is already in the trusted file") : dropped.forEach((line) => bad(line))
185
+ }
186
+
166
187
  say("required programs in the target image")
167
188
  try {
168
189
  const missing = REQUIRED_PROGRAMS.filter((p) => {
@@ -397,9 +418,66 @@ function enableResidentAutostart() {
397
418
  // resident's boot check requires exactly that. On a normal boot the files already match the package, so the check writes
398
419
  // nothing; if they ever drift, its repair write fails with EROFS and startup fails loudly rather than silently rewriting psyche.
399
420
  export const PSYCHE_MOUNT = `${BUNDLE}/psyche:/home/ouro/AgentBundles/sanctuary.ouro/psyche:ro`
421
+ // Operator-set grants (delegated commands, escalation) live outside the bundle, in a root-owned host directory mounted
422
+ // read-only at /etc/ouro/trust/sanctuary. The bundle's recursive chown cannot reach it, and the resident cannot write it.
423
+ export const TRUST_HOST_DIR = "/mnt/user/appdata/ouro-butler/trust/sanctuary"
424
+ export const TRUST_MOUNT = `${TRUST_HOST_DIR}:/etc/ouro/trust/sanctuary:ro`
425
+
426
+ /** What is wrong with a host trust directory's stat, or null when it is a real root-owned directory closed to group and other writes. */
427
+ export function trustDirIssue(stat) {
428
+ if (stat.isSymbolicLink()) return `${TRUST_HOST_DIR} is a symlink`
429
+ if (!stat.isDirectory()) return `${TRUST_HOST_DIR} is not a directory`
430
+ if (stat.uid !== 0) return `${TRUST_HOST_DIR} must be owned by root`
431
+ if ((stat.mode & 0o022) !== 0) return `${TRUST_HOST_DIR} is writable by group or other`
432
+ return null
433
+ }
434
+
435
+ // Creates the host trust directory root-owned 0755 when it is missing; refuses one the resident could have made.
436
+ function ensureTrustHostDir() {
437
+ if (!existsSync(TRUST_HOST_DIR)) {
438
+ sh("/bin/mkdir", ["-p", TRUST_HOST_DIR])
439
+ sh("/bin/chown", ["-h", "0:0", TRUST_HOST_DIR])
440
+ chmodSync(TRUST_HOST_DIR, 0o755)
441
+ }
442
+ const issue = trustDirIssue(lstatSync(TRUST_HOST_DIR))
443
+ if (issue) fail(`${issue}; fix it as root before the upgrade`)
444
+ }
445
+
446
+ /** What is wrong with the resident's trust mount, from `docker inspect` Mounts, or null when it is the read-only host directory. */
447
+ export function trustMountIssue(mounts) {
448
+ const mount = (Array.isArray(mounts) ? mounts : []).find((entry) => entry?.Destination === "/etc/ouro/trust/sanctuary")
449
+ if (!mount) return "the operator trust directory is not mounted in the resident, so no grant would be honoured"
450
+ if (mount.Source !== TRUST_HOST_DIR) return `the operator trust directory is mounted from ${mount.Source}, not the root-owned host directory`
451
+ return mount.RW === false ? null : "the operator trust directory is mounted read-write in the resident"
452
+ }
453
+
454
+ /** Friends whose records still carry the old delegationGrant but have no trusted grant: the record is now only a hint, so they are refused. */
455
+ export function legacyDelegationGrantWarnings(friendRecords, trustedGrants) {
456
+ return Object.entries(friendRecords)
457
+ .filter(([id, record]) => record?.delegationGrant && !(id in trustedGrants))
458
+ .map(([id, record]) => `${record.name ?? id} (${id}) has a delegationGrant on its friend record that is no longer honoured; grant it with \`ouro a2a delegated-commands grant --friend ${id} --did <did:key>\` as root`)
459
+ }
460
+
461
+ /**
462
+ * Escalation holders the old harness honoured (the bundle's state/a2a/escalation-grants.json) that the trusted file does not list. The
463
+ * upgrade never copies a grant out of the bundle, so each of these loses the escalation grant until the operator re-grants it.
464
+ */
465
+ export function legacyEscalationGrantProblems(legacyGrants, trustedGrants) {
466
+ return Object.entries(legacyGrants ?? {})
467
+ .filter(([id]) => !(id in (trustedGrants ?? {})))
468
+ .map(([id, grant]) => `escalation holder ${id} is in the bundle's state/a2a/escalation-grants.json but not in ${TRUST_HOST_DIR}/escalation-grants.json, so the upgrade would drop it; grant it as root first with \`ouro a2a escalation grant --agent sanctuary --friend ${id} --did ${typeof grant?.did === "string" ? grant.did : "<did:key>"}\` (the one-off container form is in deploy/unraid/README.txt)`)
469
+ }
470
+
471
+ /** Reads the legacy bundle file and the trusted file from the host and returns the holders the upgrade would drop. */
472
+ function escalationGrantProblems() {
473
+ const read = (file) => { try { return JSON.parse(readFileSync(file, "utf8")).grants ?? {} } catch { return {} } }
474
+ return legacyEscalationGrantProblems(read(`${BUNDLE}/state/a2a/escalation-grants.json`), read(`${TRUST_HOST_DIR}/escalation-grants.json`))
475
+ }
476
+
400
477
  function recreateResident(version) {
401
478
  say("recreate resident container (tokenless, gateway socket mounted)")
402
479
  disableResidentAutostart()
480
+ ensureTrustHostDir()
403
481
  const icon = "https://raw.githubusercontent.com/ourostack/ouroboros/main/assets/ouroboros.png"
404
482
  docker(["rm", "-f", CONTAINER], { stdio: "ignore" })
405
483
  docker(["create", "--name", CONTAINER, "--network", "host", "--restart", "unless-stopped", "--user", "10001:10001",
@@ -411,6 +489,7 @@ function recreateResident(version) {
411
489
  "-v", "/boot/config/custom/ouro-events/spool:/run/ouro-events:ro",
412
490
  "-v", "/run/ouro-authority:/run/ouro-authority:ro",
413
491
  "-v", PSYCHE_MOUNT,
492
+ "-v", TRUST_MOUNT,
414
493
  image(version)])
415
494
  const ref = docker(["inspect", CONTAINER, "--format", "{{.Config.Image}}"]).trim()
416
495
  if (ref !== image(version)) fail(`recreated resident is ${ref}, expected ${image(version)}`)
@@ -488,33 +567,6 @@ export function psycheMountIssue(mounts) {
488
567
  return mount.RW === false ? null : "psyche is mounted read-write in the resident"
489
568
  }
490
569
 
491
- // The same recursive chown would also hand state/a2a and the escalation grant file to the resident. The Butler honours
492
- // a grant only when the file and its directory are root-owned and writable by no one else, so a prompt-injected model
493
- // running as uid 10001 cannot mint one. Put them back; the subdirectories the resident writes stay with it, created
494
- // first so it never needs to mkdir inside the root-owned directory.
495
- export function restoreEscalationGrantRoot(bundle = BUNDLE, { lstat = lstatSync, chmod = chmodSync, run = sh, log = warn, realpath = realpathSync } = {}) {
496
- // lstat, never stat or exists: a link the resident planted must not carry the chown or chmod to its target.
497
- const dir = trustedStateChild(bundle, "a2a", { lstat, realpath, log })
498
- if (!dir) return
499
- const refuse = (target, reason) => log(`upgrade restore REFUSED ${target}: ${reason}; no chown or chmod was applied through it`)
500
- for (const sub of ["tasks", "pins", "seen"]) {
501
- const target = `${dir}/${sub}`
502
- let existing = null
503
- try { existing = lstat(target) } catch { /* absent: created below */ }
504
- if (existing && !existing.isDirectory()) { refuse(target, existing.isSymbolicLink() ? "symlink" : "not a directory"); continue }
505
- run("/bin/mkdir", ["-p", target])
506
- run("/bin/chown", ["-R", "10001:10001", target])
507
- }
508
- run("/bin/chown", ["-h", "0:0", dir])
509
- chmod(dir, 0o755)
510
- const grants = `${dir}/escalation-grants.json`
511
- let grantStat = null
512
- try { grantStat = lstat(grants) } catch { /* no grant written yet */ }
513
- if (!grantStat) return
514
- if (!grantStat.isFile()) { refuse(grants, grantStat.isSymbolicLink() ? "symlink" : "not a regular file"); return }
515
- run("/bin/chown", ["-h", "0:0", grants])
516
- chmod(grants, 0o644)
517
- }
518
570
  function migrateBundle(version, rollbackImage) {
519
571
  say(`migrate agent bundle to ${version}`)
520
572
  const pkgBundle = `${ROOT}/incoming-package/deploy/unraid/sanctuary.ouro`
@@ -528,7 +580,6 @@ function migrateBundle(version, rollbackImage) {
528
580
  sh("/bin/chown", ["-R", "10001:10001", BUNDLE])
529
581
  restoreReplayRoot()
530
582
  restorePsycheRoot()
531
- restoreEscalationGrantRoot()
532
583
  ok("bundle committed (ownership restored to resident)")
533
584
  }
534
585
 
@@ -1040,7 +1091,7 @@ async function upgrade(version, rehearse, { noGate = false, plant } = {}) {
1040
1091
  if (rehearse && !UPGRADE_STEPS.includes(rehearse)) fail(`--rehearse takes one of: ${UPGRADE_STEPS.join(", ")}`)
1041
1092
  const gated = !rehearse && !noGate
1042
1093
  if (noGate && !rehearse) console.log("\n!! --no-gate: this upgrade will NOT be replay-gated or auto-rolled-back on a behavioural regression")
1043
- if (gated && !existsSync(GATE_PROVISION)) fail(`replay peers are not provisioned (${GATE_PROVISION}); run \`sanctuary-replay-gate.mjs provision\` first, or pass --no-gate`)
1094
+ if (gated && !existsSync(GATE_PROVISION)) fail(`replay peers are not provisioned (${GATE_PROVISION}); run \`sanctuary-replay-gate.mjs provision\` first (once, on the upgrade that moves the replay seeds out of the bundle into that root-owned folder, run \`provision --rotate-replay-identities\` instead so no earlier seed survives; any seed or provision.json under state/replay-client in the bundle was writable by the Butler and is ignored), or pass --no-gate`)
1044
1095
  if (!existsSync(`${ROOT}/active.json`) || !existsSync(`${ROOT}/activation.json`)) fail("no installed authority; use prepare + install")
1045
1096
  if (existsSync(JOURNAL)) fail("a template transaction is pending; resolve it first")
1046
1097
  let id = imageId(version)
@@ -1144,6 +1195,12 @@ function verify(withGate = false) {
1144
1195
  existsSync(POLICY) ? ok(`steward policy ${sha12(POLICY)}`) : bad("steward policy missing")
1145
1196
  const psycheMount = JSON.parse(docker(["inspect", CONTAINER, "--format", "{{json .Mounts}}"]) || "[]")
1146
1197
  psycheMountIssue(psycheMount) === null ? ok("psyche is mounted read-only in the resident") : bad(psycheMountIssue(psycheMount))
1198
+ const trustMount = JSON.parse(docker(["inspect", CONTAINER, "--format", "{{json .Mounts}}"]) || "[]")
1199
+ trustMountIssue(trustMount) === null ? ok("the operator trust directory is mounted read-only in the resident") : bad(trustMountIssue(trustMount))
1200
+ const trustIssue = existsSync(TRUST_HOST_DIR) ? trustDirIssue(lstatSync(TRUST_HOST_DIR)) : `${TRUST_HOST_DIR} is missing`
1201
+ trustIssue === null ? ok("the host trust directory is root-owned and closed to group and other writes") : bad(trustIssue)
1202
+ const droppedHolders = escalationGrantProblems()
1203
+ droppedHolders.length === 0 ? ok("every escalation holder is in the trusted file") : droppedHolders.forEach((line) => bad(line))
1147
1204
  const psycheDir = `${BUNDLE}/psyche`
1148
1205
  if (existsSync(psycheDir)) {
1149
1206
  const psycheIssues = psycheProblems(psycheDir)