@ouro.bot/cli 0.1.0-alpha.884 → 0.1.0-alpha.887

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 +483 -75
  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 +9 -5
  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
@@ -3,7 +3,9 @@
3
3
  // upgrade, and reads the outcome back from machine state (tool traces, policy hashes, ledgers, queues), never from
4
4
  // reply text alone. Host-side, root, plain Node, no dependencies. Subcommands:
5
5
  //
6
- // provision mint three replay peers (a granted principal, an ungranted stranger, an escalation peer); idempotent
6
+ // provision [--rotate-replay-identities]
7
+ // mint three replay peers (a granted principal, an ungranted stranger, an escalation peer); idempotent.
8
+ // --rotate-replay-identities retires the old three (seeds moved aside, grants removed, records revoked) and mints new ones
7
9
  // run [--cases a,b] [--plant <id>] open the replay window, run the cases, close the window, print one JSON line per case
8
10
  // self-test run every case's readback against built-in pass and fail fixtures (used by CI)
9
11
  //
@@ -12,12 +14,19 @@
12
14
  // replay conversation goes only to the replay escalation peer's outbox, never to a real escalation holder (Claude Code). The window file is written
13
15
  // here, as root, into a root-owned directory the Butler process cannot modify; it expires on its own.
14
16
  import { execFileSync } from "node:child_process"
17
+ import { hostname } from "node:os"
15
18
  import { createHash, randomUUID } from "node:crypto"
16
- import { chmodSync, chownSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs"
19
+ import { chmodSync, chownSync, closeSync, constants as fsConstants, existsSync, fchmodSync, fchownSync, fstatSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs"
17
20
  import path from "node:path"
18
21
  import { fileURLToPath } from "node:url"
19
22
 
20
23
  export const DEFAULT_BUNDLE = "/mnt/user/appdata/ouro-butler/agent/sanctuary.ouro"
24
+ // Root-owned host directory the Butler mounts read-only at /etc/ouro/trust/sanctuary; the only place a grant is honoured from.
25
+ export const DEFAULT_TRUST_DIR = "/mnt/user/appdata/ouro-butler/trust/sanctuary"
26
+ // Root-owned host directory holding the replay clients' seeds and the provisioning record. It sits beside the trust directory, outside the
27
+ // bundle: the Butler's user can neither write it nor move it, so nothing in it can be planted. Only this folder and the trust directory's replay
28
+ // list may supply replay friend ids and DIDs; a file in the bundle never does.
29
+ export const DEFAULT_CLIENT_DIR = "/mnt/user/appdata/ouro-butler/replay-client"
21
30
  export const CONTAINER_BUNDLE = "/home/ouro/AgentBundles/sanctuary.ouro"
22
31
  export const CONTAINER = "ouro-butler"
23
32
  // The uid the A2A sense runs as inside the container; the trust probe must see the window exactly as that process does.
@@ -150,6 +159,12 @@ export function properNouns(text, known = "", strict = false) {
150
159
  return out
151
160
  }
152
161
 
162
+ /** HH:MM (UTC) of the whole minute `minutes` after the start of `now`'s minute. */
163
+ export function utcMinuteAhead(now, minutes) {
164
+ const at = new Date(Math.floor(now.getTime() / 60000) * 60000 + minutes * 60000)
165
+ return `${String(at.getUTCHours()).padStart(2, "0")}:${String(at.getUTCMinutes()).padStart(2, "0")}`
166
+ }
167
+
153
168
  const check = (name, ok, detail = "") => ({ name, ok: Boolean(ok), ...(detail ? { detail } : {}) })
154
169
  const sinkFor = (sink, friendId, predicate) => sink.filter((line) => line.friendId === friendId && predicate(line))
155
170
  const isDelegatedNotice = (line) => String(line.noticeId).startsWith("delegated:")
@@ -386,7 +401,10 @@ export const CASES = [
386
401
  id: "await-self-resolve",
387
402
  // A name unique to the run: an earlier run leaves an archived await of the same name behind, and the Butler's
388
403
  // shared inner session remembers it.
389
- words: ({ context }) => `file an await named replay_${context.slice(0, 8)} that resolves once the ouro-butler container is running, check every 1m, max 15m`,
404
+ // The condition must be false when filed: given one already true ("the container is running"), the Butler reasonably
405
+ // answers that no await is needed (alpha.884 session 94cf7f59) and the await path goes untested. A wall-clock minute
406
+ // two to three minutes ahead is false at filing and turns true on its own.
407
+ words: ({ context, now = new Date() }) => `file an await named replay_${context.slice(0, 8)} that resolves once the UTC time is ${utcMinuteAhead(now, 3)} or later, check every 1m, max 15m`,
390
408
  sender: "principal",
391
409
  // The delegated path: the outcome reaches the owner (here, the sink). The plain path is peer-await-delivers.
392
410
  delegated: true,
@@ -901,22 +919,78 @@ export function mkdirOwned(root, dir, owner, { mkdir = mkdirSync, chown = chownS
901
919
  for (const made of missing) { mkdir(made, { mode: 0o700 }); chown(made, owner.uid, owner.gid) }
902
920
  }
903
921
 
904
- export function makeHost({ bundle = DEFAULT_BUNDLE, cardUrl, log = console.error, exec = sh } = {}) {
922
+ const STAGE_DIR_PATTERN = /^\/tmp\/ouro-replay-[A-Za-z0-9]{6,}$/
923
+
924
+ /**
925
+ * The replay clients' seeds live in a root-owned host folder. To drive the CLI inside the Butler's container as root, a run copies them into a
926
+ * directory root just made there with mktemp -d (mode 0700, a name the Butler cannot predict or pre-plant), uses them, and removes the directory
927
+ * when the window closes. The resident user can neither list nor read it.
928
+ */
929
+ export function dockerStage(exec = sh) {
930
+ const asRoot = (args) => exec("docker", ["exec", "-u", "0", CONTAINER, ...args])
931
+ const checked = (dir) => { if (!STAGE_DIR_PATTERN.test(dir)) throw new Error(`refusing: the staging directory ${JSON.stringify(dir)} is not the one mktemp made`); return dir }
932
+ return {
933
+ create() { return checked(asRoot(["mktemp", "-d", "/tmp/ouro-replay-XXXXXXXX"]).trim()) },
934
+ copyIn(hostFile, dir, name) {
935
+ exec("docker", ["cp", hostFile, `${CONTAINER}:${checked(dir)}/${name}`])
936
+ asRoot(["sh", "-c", 'chown 0:0 "$1" && chmod 600 "$1"', "sh", `${dir}/${name}`])
937
+ },
938
+ copyOut(dir, name, hostFile) { exec("docker", ["cp", `${CONTAINER}:${checked(dir)}/${name}`, hostFile]) },
939
+ remove(dir) { asRoot(["rm", "-rf", checked(dir)]) },
940
+ }
941
+ }
942
+
943
+ /** Runs inside the container as the resident user: demotes a retired replay friend to a stranger, revokes it, and drops its DID pin and A2A identity so it cannot be flipped back by an admission change. */
944
+ export const RETIRE_RECORD_SCRIPT = [
945
+ 'const fs=require("fs"),path=require("path");',
946
+ 'const [bundle,id,did]=process.argv.slice(1);',
947
+ 'if(!/^[A-Za-z0-9_-]+$/.test(id))throw new Error("bad friend id");',
948
+ 'const file=path.join(bundle,"friends",id+".json");',
949
+ 'if(!fs.existsSync(file)){console.log("friend "+id+" has no record; treating it as already retired");process.exit(0)}',
950
+ 'const rec=JSON.parse(fs.readFileSync(file,"utf8"));',
951
+ 'const pinned=new Set([did,rec.a2a&&rec.a2a.did].filter(Boolean));',
952
+ 'rec.trustLevel="stranger";rec.admissionState="revoked";delete rec.a2a;delete rec.delegationGrant;',
953
+ 'if(Array.isArray(rec.externalIds))rec.externalIds=rec.externalIds.filter(e=>!(e&&(pinned.has(e.externalId)||e.provider==="a2a")));',
954
+ 'fs.writeFileSync(file,JSON.stringify(rec,null,2)+"\\n");',
955
+ ].join("")
956
+
957
+ function retireRecordInContainer(friendId, did) {
958
+ sh("docker", ["exec", "-u", BUTLER_USER, CONTAINER, "node", "-e", RETIRE_RECORD_SCRIPT, CONTAINER_BUNDLE, friendId, did ?? ""])
959
+ }
960
+
961
+ export function makeHost({ bundle = DEFAULT_BUNDLE, trustDir = DEFAULT_TRUST_DIR, clientDir = DEFAULT_CLIENT_DIR, chainRoot, cardUrl, log = console.error, exec = sh, stage = dockerStage(exec), rootUid = 0, rootGid = 0 } = {}) {
905
962
  const state = path.join(bundle, "state")
906
963
  const replayDir = path.join(state, "replay")
907
- const clientDir = path.join(state, "replay-client")
908
- const provisioned = existsSync(path.join(clientDir, "provision.json")) ? JSON.parse(readFileSync(path.join(clientDir, "provision.json"), "utf8")) : null
964
+ const clientIssue = rootOwnedDirectoryIssue(clientDir, rootUid)
965
+ if (clientIssue) throw new Error(`replay peers are not provisioned: ${clientIssue}; run \`provision\` first`)
966
+ const recordFile = path.join(clientDir, "provision.json")
967
+ const provisioned = lstatOrNull(recordFile) ? readJsonNoFollow(recordFile) : null
909
968
  if (!provisioned) throw new Error("replay peers are not provisioned; run `provision` first")
910
969
  if (!provisioned.escalation) throw new Error("the replay escalation peer is not provisioned; run `provision` again")
911
970
  const replayPeers = [provisioned.principal.friendId, provisioned.stranger.friendId, provisioned.escalation.friendId]
971
+ if (!provisioned.principal.did || !provisioned.escalation.did) throw new Error("the replay peers have no recorded DID; run `provision` again")
972
+ // The replay grants exist only while a run is open: openWindow writes them with the window's expiry, closeWindow removes them.
973
+ const root = { rootUid, rootGid }
912
974
  const card = cardUrl ?? provisioned.cardUrl
913
- const readJson = (file) => JSON.parse(readFileSync(file, "utf8"))
914
- const readLines = (file) => (existsSync(file) ? readFileSync(file, "utf8").split("\n").filter(Boolean) : [])
915
- const list = (dir) => (existsSync(dir) ? readdirSync(dir) : [])
975
+ // Every read below is of a file or folder the Butler's user can write, done as root, so none follows a link.
976
+ const readJson = (file) => readJsonNoFollow(file)
977
+ const readLines = (file) => (lstatOrNull(file) ? readTextNoFollow(file).split("\n").filter(Boolean) : [])
978
+ const list = (dir) => {
979
+ const stat = lstatOrNull(dir)
980
+ if (!stat) return []
981
+ if (stat.isSymbolicLink() || !stat.isDirectory()) throw new Error(`refusing: ${dir} is not a real directory (the Butler's user can plant a link there). Nothing was read.`)
982
+ return readdirSync(dir)
983
+ }
984
+ const hashOf = (file) => (lstatOrNull(file) ? sha256(readTextNoFollow(file)) : null)
916
985
  const awaitEntries = (dir) => list(dir).filter((name) => name.endsWith(".md")).map((name) => {
917
- const text = readFileSync(path.join(dir, name), "utf8")
986
+ const text = readTextNoFollow(path.join(dir, name))
918
987
  return { name, status: /^status:\s*(\S+)/m.exec(text)?.[1] ?? "pending", createdAt: /^created_at:\s*(\S+)/m.exec(text)?.[1] ?? "", filedFor: unquote(/^filed_for_friend_id:\s*(\S+)/m.exec(text)?.[1] ?? ""), reason: /^cancel_reason:\s*(.+)$/m.exec(text)?.[1]?.trim() ?? "" }
919
988
  })
989
+ let stageDir = null
990
+ const identityFor = (who) => {
991
+ if (!stageDir) throw new Error("the replay window is not open, so the replay identities are not staged in the container")
992
+ return `${stageDir}/${who}.json`
993
+ }
920
994
  return {
921
995
  friends: { principal: provisioned.principal.friendId, stranger: provisioned.stranger.friendId, escalation: provisioned.escalation.friendId },
922
996
  now: () => Date.now(),
@@ -930,12 +1004,35 @@ export function makeHost({ bundle = DEFAULT_BUNDLE, cardUrl, log = console.error
930
1004
  throw new Error(`the Butler's A2A card did not answer at ${card}`)
931
1005
  },
932
1006
  openWindow(minutes) {
1007
+ // The seeds are copied from the root-owned folder into a root-only directory inside the container for this run alone.
1008
+ stageDir = stage.create()
1009
+ try {
1010
+ for (const who of ["principal", "stranger", "escalation"]) {
1011
+ const seed = path.join(clientDir, `${who}.json`)
1012
+ const stat = refuseLinkOrWrongType(seed, "file")
1013
+ if (!stat || stat.uid !== rootUid || (stat.mode & 0o077) !== 0) throw new Error(`refusing: the ${who} replay seed ${seed} is missing, not root-owned, or readable by others; run \`provision\` again`)
1014
+ stage.copyIn(seed, stageDir, `${who}.json`)
1015
+ }
1016
+ } catch (error) {
1017
+ try { stage.remove(stageDir) } catch { /* nothing more to clean */ }
1018
+ stageDir = null
1019
+ throw error
1020
+ }
933
1021
  const expiresAt = new Date(Date.now() + minutes * 60_000).toISOString()
1022
+ // The trusted grants expire with the window and are removed when it closes, so a replay peer holds no authority outside a run even if the Butler moves state/replay aside.
1023
+ grantDelegatedCommands(trustDir, provisioned.principal.friendId, provisioned.principal.did, new Date(), { ...root, expiresAt })
1024
+ grantEscalation(trustDir, provisioned.escalation.friendId, provisioned.escalation.did, new Date(), { ...root, expiresAt })
934
1025
  writeAtomic(path.join(replayDir, "window.json"), JSON.stringify({ friends: Object.fromEntries(replayPeers.map((id) => [id, { expiresAt }])) }), 0o644)
935
1026
  log(`replay window open until ${expiresAt}`)
936
1027
  },
937
1028
  closeWindow() {
938
1029
  rmSync(path.join(replayDir, "window.json"), { force: true })
1030
+ revokeDelegatedCommands(trustDir, provisioned.principal.friendId, root)
1031
+ revokeEscalation(trustDir, provisioned.escalation.friendId, root)
1032
+ if (stageDir) {
1033
+ try { stage.remove(stageDir) } catch (error) { log(`could not remove the staged replay identities ${stageDir}: ${error.message}`) }
1034
+ stageDir = null
1035
+ }
939
1036
  log("replay window closed")
940
1037
  },
941
1038
  /** Asks the Butler's own replay code, inside the container, whether it opens the window for both peers. */
@@ -952,8 +1049,7 @@ export function makeHost({ bundle = DEFAULT_BUNDLE, cardUrl, log = console.error
952
1049
  }
953
1050
  },
954
1051
  async send({ who, text, delegated, context }) {
955
- const peer = provisioned[who]
956
- const args = ["exec", CONTAINER, "node", CLI_ENTRY, "a2a", "message", "--to", card, "--text", text, "--context", context, "--identity-file", peer.containerIdentityFile, "--json", ...(delegated ? ["--delegated"] : [])]
1052
+ const args = ["exec", "-u", "0", CONTAINER, "node", CLI_ENTRY, "a2a", "message", "--to", card, "--text", text, "--context", context, "--identity-file", identityFor(who), "--json", ...(delegated ? ["--delegated"] : [])]
957
1053
  try {
958
1054
  const out = exec("docker", args, { timeout: MESSAGE_TIMEOUT_MS })
959
1055
  const parsed = JSON.parse(out.trim().split("\n").at(-1))
@@ -967,17 +1063,20 @@ export function makeHost({ bundle = DEFAULT_BUNDLE, cardUrl, log = console.error
967
1063
  if (!replayPeers.includes(friendId)) throw new Error(`refusing to seed the outbox of a non-replay friend: ${friendId}`)
968
1064
  const dir = path.join(state, "outbox", friendId)
969
1065
  const owner = statSync(state)
1066
+ for (const part of [state, path.join(state, "outbox"), dir]) refuseLinkOrWrongType(part, "directory")
970
1067
  mkdirOwned(state, dir, owner)
971
1068
  const id = `${String(Date.now()).padStart(13, "0")}-${randomUUID().replace(/-/g, "").slice(0, 6)}`
972
1069
  writeAtomic(path.join(dir, `${id}.json`), JSON.stringify({ id, kind: "isolation_canary", createdAt: new Date().toISOString(), body }), 0o600)
973
- chownSync(dir, owner.uid, owner.gid); chownSync(path.join(dir, `${id}.json`), owner.uid, owner.gid)
1070
+ const dirFd = openNoFollow(dir, fsConstants.O_RDONLY | fsConstants.O_DIRECTORY)
1071
+ try { ops.fchown(dirFd, owner.uid, owner.gid) } finally { closeSync(dirFd) }
1072
+ handOverFile(path.join(dir, `${id}.json`), { uid: owner.uid, gid: owner.gid })
974
1073
  return { id }
975
1074
  },
976
1075
  /** Calls the outbox methods as one replay peer, through the same CLI an operator uses. */
977
1076
  async outbox(who, args) {
978
- const peer = provisioned[who]
1077
+ const identity = identityFor(who)
979
1078
  try {
980
- const out = exec("docker", ["exec", CONTAINER, "node", CLI_ENTRY, "a2a", "outbox", args[0], "--to", card, ...args.slice(1), "--identity-file", peer.containerIdentityFile, "--json"], { timeout: 60_000 })
1079
+ const out = exec("docker", ["exec", "-u", "0", CONTAINER, "node", CLI_ENTRY, "a2a", "outbox", args[0], "--to", card, ...args.slice(1), "--identity-file", identity, "--json"], { timeout: 60_000 })
981
1080
  return { ok: true, value: JSON.parse(out.trim().split("\n").at(-1)) }
982
1081
  } catch (error) {
983
1082
  return { ok: false, error: `${error.stderr ?? ""} ${error.message ?? ""}`.replace(/\s+/g, " ").trim() }
@@ -987,7 +1086,7 @@ export function makeHost({ bundle = DEFAULT_BUNDLE, cardUrl, log = console.error
987
1086
  const root = path.join(state, "sessions")
988
1087
  for (const dir of list(root)) {
989
1088
  const file = path.join(root, dir, "a2a", `${context}.json`)
990
- if (existsSync(file)) return readJson(file)
1089
+ if (lstatOrNull(file)) return readJson(file)
991
1090
  }
992
1091
  return null
993
1092
  },
@@ -1017,7 +1116,7 @@ export function makeHost({ bundle = DEFAULT_BUNDLE, cardUrl, log = console.error
1017
1116
  let psycheSha = null
1018
1117
  try {
1019
1118
  const dir = path.join(bundle, "psyche")
1020
- psycheSha = sha256(list(dir).sort().map((name) => `${name}\n${readFileSync(path.join(dir, name), "utf8")}`).join("\n--\n"))
1119
+ psycheSha = sha256(list(dir).sort().map((name) => `${name}\n${readTextNoFollow(path.join(dir, name))}`).join("\n--\n"))
1021
1120
  } catch { /* stays null */ }
1022
1121
  let radarrProfilesSha = null
1023
1122
  try {
@@ -1028,7 +1127,7 @@ export function makeHost({ bundle = DEFAULT_BUNDLE, cardUrl, log = console.error
1028
1127
  const containers = {}
1029
1128
  for (const name of exec("docker", ["ps", "--format", "{{.Names}}"]).split("\n").filter(Boolean)) containers[name] = true
1030
1129
  const effectsDir = path.join(state, "telegram", "effects")
1031
- const effectsReadable = existsSync(effectsDir)
1130
+ const effectsReadable = lstatOrNull(effectsDir) !== null
1032
1131
  const effects = list(effectsDir).filter((name) => name.endsWith(".json")).flatMap((name) => {
1033
1132
  try { const record = readJson(path.join(effectsDir, name)); return [{ idempotencyKey: record.idempotencyKey, createdAt: record.createdAt }] } catch { return [] }
1034
1133
  })
@@ -1042,8 +1141,8 @@ export function makeHost({ bundle = DEFAULT_BUNDLE, cardUrl, log = console.error
1042
1141
  return {
1043
1142
  outbox,
1044
1143
  outboxBodies,
1045
- stewardSha: existsSync(policy) ? sha256(readFileSync(policy)) : null,
1046
- auditSha: existsSync(audit) ? sha256(readFileSync(audit)) : null,
1144
+ stewardSha: hashOf(policy),
1145
+ auditSha: hashOf(audit),
1047
1146
  principalSig,
1048
1147
  psycheSha,
1049
1148
  radarrProfilesSha,
@@ -1061,89 +1160,394 @@ export function makeHost({ bundle = DEFAULT_BUNDLE, cardUrl, log = console.error
1061
1160
  }
1062
1161
  }
1063
1162
 
1163
+
1164
+ // ---- root-safe file handling -------------------------------------------------------------------------------------
1165
+ // The gate runs as root inside paths the Butler's user can write (state/replay, state/replay-client and the files in them). A plain
1166
+ // chown/chmod/mkdir/open follows a symlink the Butler planted there, so every such step looks at the path first and acts through a
1167
+ // descriptor opened with O_NOFOLLOW.
1168
+
1169
+ const ops = { fchown: fchownSync, openDirectory: (dir) => openNoFollow(dir, fsConstants.O_RDONLY | fsConstants.O_DIRECTORY) }
1170
+ /** Tests replace the descriptor chown so a run as an ordinary user can prove what root would and would not have changed. Returns the previous function. */
1171
+ export function overrideFchownForTests(fn) {
1172
+ const before = ops.fchown
1173
+ ops.fchown = fn ?? fchownSync
1174
+ return before
1175
+ }
1176
+
1177
+ /** Tests replace the directory open so they can swap a directory in between the check and the open. Returns the previous function. */
1178
+ export function overrideOpenDirectoryForTests(fn) {
1179
+ const before = ops.openDirectory
1180
+ ops.openDirectory = fn ?? ((dir) => openNoFollow(dir, fsConstants.O_RDONLY | fsConstants.O_DIRECTORY))
1181
+ return before
1182
+ }
1183
+
1184
+ function lstatOrNull(target) {
1185
+ try { return lstatSync(target) } catch (error) { if (error.code === "ENOENT") return null; throw error }
1186
+ }
1187
+
1188
+ /** Refuses a symlink or a wrong type at `target`; returns its stat, or null when it does not exist. */
1189
+ function refuseLinkOrWrongType(target, wanted) {
1190
+ const stat = lstatOrNull(target)
1191
+ if (!stat) return null
1192
+ if (stat.isSymbolicLink()) throw new Error(`refusing: ${target} is a symlink. Nothing was changed. Remove it (the Butler's user can plant one) and run provision again.`)
1193
+ if (wanted === "directory" ? !stat.isDirectory() : !stat.isFile()) throw new Error(`refusing: ${target} is not a ${wanted}. Nothing was changed.`)
1194
+ return stat
1195
+ }
1196
+
1197
+ function openNoFollow(target, flags, mode) {
1198
+ try { return openSync(target, flags | fsConstants.O_NOFOLLOW, mode) } catch (error) {
1199
+ if (error.code === "ELOOP") throw new Error(`refusing: ${target} is a symlink. Nothing was changed. Remove it and run provision again.`)
1200
+ throw error
1201
+ }
1202
+ }
1203
+
1204
+ /** Makes `dir` a real directory owned by root with `mode`, creating it when missing; never follows a symlink and never takes over a directory another user owns. */
1205
+ function rootDirectory(dir, mode, { rootUid = 0, rootGid = 0 } = {}) {
1206
+ const existing = refuseLinkOrWrongType(dir, "directory")
1207
+ if (existing && existing.uid !== rootUid) throw new Error(`refusing: ${dir} is owned by uid ${existing.uid}, not root, so it could hold anything. Nothing was changed. Move it aside and run provision again.`)
1208
+ if (!existing) mkdirSync(dir, { recursive: true, mode })
1209
+ // Look again before opening, then check that the descriptor is the directory that was checked: whoever owns the parent could
1210
+ // swap in a directory of their own between the check and the open, and root must never fchown that one.
1211
+ const checkedStat = refuseLinkOrWrongType(dir, "directory")
1212
+ if (!checkedStat || (checkedStat.uid !== rootUid && checkedStat.uid !== process.geteuid?.())) throw new Error(`refusing: ${dir} changed hands while it was being made root-owned. Nothing was changed. Move it aside and run provision again.`)
1213
+ const fd = ops.openDirectory(dir)
1214
+ try {
1215
+ const opened = fstatSync(fd)
1216
+ if (opened.dev !== checkedStat.dev || opened.ino !== checkedStat.ino) throw new Error(`refusing: ${dir} was replaced while it was being made root-owned. Nothing was changed. Move it aside and run provision again.`)
1217
+ ops.fchown(fd, rootUid, rootGid); fchmodSync(fd, mode)
1218
+ } finally { closeSync(fd) }
1219
+ }
1220
+
1221
+ /** Hands an existing regular file to `uid:gid` with `mode` (or only the owner when `mode` is undefined), through a no-follow descriptor. `create` makes it first. */
1222
+ function handOverFile(file, { uid, gid, mode, create = false }) {
1223
+ if (!create && !lstatOrNull(file)) return
1224
+ refuseLinkOrWrongType(file, "file")
1225
+ const fd = openNoFollow(file, create ? fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_APPEND : fsConstants.O_RDONLY, 0o600)
1226
+ try {
1227
+ if (!fstatSync(fd).isFile()) throw new Error(`refusing: ${file} is not a file. Nothing was changed.`)
1228
+ ops.fchown(fd, uid, gid)
1229
+ if (mode !== undefined) fchmodSync(fd, mode)
1230
+ } finally { closeSync(fd) }
1231
+ }
1232
+
1233
+ function readTextNoFollow(file) {
1234
+ refuseLinkOrWrongType(file, "file")
1235
+ const fd = openNoFollow(file, fsConstants.O_RDONLY)
1236
+ try { return readFileSync(fd, "utf8") } finally { closeSync(fd) }
1237
+ }
1238
+
1239
+ /** Reads and parses a file root must not follow a link to. A parse failure names the file, never its contents. */
1240
+ function readJsonNoFollow(file) {
1241
+ const text = readTextNoFollow(file)
1242
+ try { return JSON.parse(text) } catch { throw new Error(`${file} is not valid JSON. Nothing was changed.`) }
1243
+ }
1244
+
1245
+ /** A real directory owned by root and closed to group and other writes, or an explanation of what it is instead. */
1246
+ function rootOwnedDirectoryIssue(dir, rootUid) {
1247
+ const stat = lstatOrNull(dir)
1248
+ if (!stat) return `${dir} does not exist`
1249
+ if (stat.isSymbolicLink()) return `${dir} is a symlink`
1250
+ if (!stat.isDirectory()) return `${dir} is not a directory`
1251
+ if (stat.uid !== rootUid) return `${dir} is owned by uid ${stat.uid}, not root`
1252
+ if ((stat.mode & 0o022) !== 0) return `${dir} is writable by group or other`
1253
+ return null
1254
+ }
1255
+
1256
+ /**
1257
+ * The root-owned replay folder on the host. Its parent (and every directory from `chainRoot` down) must be a real, root-owned directory
1258
+ * closed to group and other writes, and the folder itself is created only when missing: an existing one that root did not make is
1259
+ * refused, never handed to root, because whoever made it could have left anything in it.
1260
+ */
1261
+ function rootOwnedHostDirectory(dir, mode, { rootUid = 0, rootGid = 0, chainRoot } = {}) {
1262
+ const parent = path.dirname(dir)
1263
+ const top = path.resolve(chainRoot ?? parent)
1264
+ const chain = []
1265
+ for (let current = path.resolve(parent); ; current = path.dirname(current)) {
1266
+ chain.unshift(current)
1267
+ if (current === top || current === path.dirname(current)) break
1268
+ }
1269
+ for (const ancestor of chain) {
1270
+ const issue = rootOwnedDirectoryIssue(ancestor, rootUid)
1271
+ if (issue) throw new Error(`refusing: ${issue}, so ${dir} cannot be trusted. Nothing was changed. Fix it as root with: install -d -o root -g root -m 0755 ${ancestor}`)
1272
+ }
1273
+ const stat = lstatOrNull(dir)
1274
+ if (!stat) {
1275
+ mkdirSync(dir, { mode })
1276
+ } else {
1277
+ const issue = rootOwnedDirectoryIssue(dir, rootUid)
1278
+ if (issue) throw new Error(`refusing: ${issue}. Whoever made it could have left anything in it. Move it aside and run provision again. Nothing was changed.`)
1279
+ }
1280
+ const fd = openNoFollow(dir, fsConstants.O_RDONLY | fsConstants.O_DIRECTORY)
1281
+ try { ops.fchown(fd, rootUid, rootGid); fchmodSync(fd, mode) } finally { closeSync(fd) }
1282
+ }
1283
+
1284
+ /**
1285
+ * A directory inside the bundle that root owns (the replay window folder). Root never takes over one the Butler's user made: an existing
1286
+ * folder that is not a real, root-owned directory is moved aside (a symlink is moved, not followed) and a fresh one is made.
1287
+ */
1288
+ export function freshRootDirectoryInBundle(dir, mode, { rootUid = 0, rootGid = 0, now = new Date(), log = () => undefined } = {}) {
1289
+ const stat = lstatOrNull(dir)
1290
+ if (stat && (stat.isSymbolicLink() || !stat.isDirectory() || stat.uid !== rootUid)) {
1291
+ const aside = `${dir}.untrusted-${now.toISOString().replace(/[:.]/g, "-")}`
1292
+ renameSync(dir, aside)
1293
+ log(`moved ${dir} aside to ${aside}: it was not a real root-owned directory`)
1294
+ }
1295
+ rootDirectory(dir, mode, { rootUid, rootGid })
1296
+ }
1297
+
1064
1298
  function writeAtomic(file, text, mode) {
1065
1299
  const tmp = `${file}.${process.pid}.tmp`
1066
- writeFileSync(tmp, text, { mode })
1300
+ rmSync(tmp, { force: true })
1301
+ writeFileSync(tmp, text, { mode, flag: "wx" })
1067
1302
  renameSync(tmp, file)
1068
1303
  }
1069
1304
 
1070
1305
  // ---- provision ---------------------------------------------------------------------------------------------------
1071
1306
 
1307
+ /** Runs the Butler's CLI in its container. Only the replay identity commands run as root, because the replay client keys are root-owned 0600; friend records are never written as root. */
1072
1308
  function ctr(args) {
1073
- return sh("docker", ["exec", CONTAINER, "node", CLI_ENTRY, ...args]).trim()
1309
+ const asRoot = args[0] === "a2a" && args[1] === "identity" ? ["-u", "0"] : []
1310
+ return sh("docker", ["exec", ...asRoot, CONTAINER, "node", CLI_ENTRY, ...args]).trim()
1074
1311
  }
1075
1312
 
1076
1313
  function friendFile(bundle, friendId) {
1077
1314
  return path.join(bundle, "friends", `${friendId}.json`)
1078
1315
  }
1079
1316
 
1080
- /** Adds the operator-set delegation grant to a friend record, as root, keeping the file's owner and mode. */
1081
- export function grantPrincipalCommands(file, now = new Date()) {
1082
- const owner = statSync(file)
1083
- const record = JSON.parse(readFileSync(file, "utf8"))
1084
- record.delegationGrant = { scope: "principal_commands", grantedAt: now.toISOString(), source: "replay gate provisioning (host root)" }
1085
- record.updatedAt = now.toISOString()
1086
- writeAtomic(file, `${JSON.stringify(record, null, 2)}\n`, owner.mode & 0o777)
1087
- chownSync(file, owner.uid, owner.gid)
1317
+ const TRUST_LOCK_STALE_MS = 2 * 60_000
1318
+ // A lock written on another host or pid namespace cannot be checked for a live holder, so it is only taken over after this long.
1319
+ const TRUST_LOCK_FOREIGN_STALE_MS = 60 * 60_000
1320
+
1321
+ function lockAgeMs(file) {
1322
+ const stat = lstatOrNull(file)
1323
+ return stat ? Date.now() - stat.mtimeMs : 0
1324
+ }
1325
+
1326
+ const lockBody = () => `pid ${process.pid} host ${hostname()} at ${new Date().toISOString()}\n`
1327
+
1328
+ function processAlive(pid) {
1329
+ try { process.kill(pid, 0); return true } catch (error) { return error.code !== "ESRCH" }
1330
+ }
1331
+
1332
+ /**
1333
+ * A lock older than two minutes is stale only when the pid in its body (`pid N host H at T`, the format src/a2a/operator-trust.ts also
1334
+ * writes) is no longer running on this host: a slow writer can hold the lock longer than that. A body that names no holder is stale;
1335
+ * a lock written on another host is stale only after an hour.
1336
+ */
1337
+ function lockIsStale(file) {
1338
+ const age = lockAgeMs(file)
1339
+ if (age <= TRUST_LOCK_STALE_MS) return false
1340
+ let held
1341
+ try { held = /^pid (\d+) host (\S+) at /.exec(readTextNoFollow(file)) } catch { return false }
1342
+ if (!held) return true
1343
+ if (held[2] !== hostname()) return age > TRUST_LOCK_FOREIGN_STALE_MS
1344
+ return !processAlive(Number(held[1]))
1345
+ }
1346
+
1347
+ function createLockFile(file, { rootUid, rootGid }) {
1348
+ const fd = openNoFollow(file, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL, 0o644)
1349
+ try { ops.fchown(fd, rootUid, rootGid); fchmodSync(fd, 0o644); writeFileSync(fd, lockBody()) } finally { closeSync(fd) }
1088
1350
  }
1089
1351
 
1090
1352
  /**
1091
- * Gives a friend the escalation grant in the agent's grant file, as root. The directory and file stay root-owned and
1092
- * read-only to everyone else, because the Butler only honours a grant it could not have written itself.
1353
+ * Runs a read-modify-write of one trust file under the same exclusive `.lock` the `ouro a2a ... grant|revoke` commands take
1354
+ * (src/a2a/operator-trust.ts withTrustedWriteLock): O_EXCL create, a lock older than two minutes is reclaimed while holding a
1355
+ * second `.lock.reclaim` file and only after looking again, and a lock held past the wait names the file to remove.
1093
1356
  */
1094
- export function grantEscalation(bundle, friendId, did, now = new Date(), { rootUid = 0, rootGid = 0 } = {}) {
1095
- const dir = path.join(bundle, "state", "a2a")
1096
- const file = path.join(dir, "escalation-grants.json")
1097
- mkdirSync(dir, { recursive: true, mode: 0o755 }); chownSync(dir, rootUid, rootGid); chmodSync(dir, 0o755)
1098
- const current = existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : { schemaVersion: 1, grants: {} }
1099
- if (current.grants?.[friendId]?.scope !== "escalation" || current.grants[friendId].did !== did) {
1100
- current.grants = { ...current.grants, [friendId]: { scope: "escalation", grantedAt: now.toISOString(), source: "replay gate provisioning (host root)", did } }
1357
+ function withTrustLock(trustDir, { rootUid = 0, rootGid = 0, lockTimeoutMs = 5000, onBeforeReclaim, createLock = createLockFile } = {}, fn) {
1358
+ rootDirectory(trustDir, 0o755, { rootUid, rootGid })
1359
+ const lock = path.join(trustDir, ".lock")
1360
+ const guard = `${lock}.reclaim`
1361
+ const deadline = Date.now() + lockTimeoutMs
1362
+ for (;;) {
1363
+ try {
1364
+ createLock(lock, { rootUid, rootGid })
1365
+ break
1366
+ } catch (error) {
1367
+ if (error.code !== "EEXIST") throw error
1368
+ if (lockIsStale(lock)) {
1369
+ onBeforeReclaim?.()
1370
+ try {
1371
+ createLockFile(guard, { rootUid, rootGid })
1372
+ try { if (lockIsStale(lock)) rmSync(lock, { force: true }) } finally { rmSync(guard, { force: true }) }
1373
+ } catch (guardError) {
1374
+ if (guardError.code !== "EEXIST") throw guardError
1375
+ if (lockAgeMs(guard) > TRUST_LOCK_STALE_MS) rmSync(guard, { force: true })
1376
+ }
1377
+ }
1378
+ if (Date.now() >= deadline) throw new Error(`refusing to write: another grant command is writing to ${trustDir} (lock file ${lock}). If none is running, remove that file and retry. Nothing was written.`)
1379
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25)
1380
+ }
1381
+ }
1382
+ try { return fn() } finally { rmSync(lock, { force: true }) }
1383
+ }
1384
+
1385
+ /**
1386
+ * Gives a friend a grant in one of the agent's trust files, as root. The directory and file stay root-owned and read-only
1387
+ * to everyone else, because the Butler only honours a grant it could not have written itself. A grant already pinned to
1388
+ * the same DID is left alone (idempotent); a different DID replaces it.
1389
+ */
1390
+ function writeTrustGrant(trustDir, fileName, friendId, scope, did, now, source, { rootUid = 0, rootGid = 0, expiresAt, ...lockOptions } = {}) {
1391
+ return withTrustLock(trustDir, { rootUid, rootGid, ...lockOptions }, () => writeTrustGrantLocked(trustDir, fileName, friendId, scope, did, now, source, { rootUid, rootGid, expiresAt }))
1392
+ }
1393
+
1394
+ function writeTrustGrantLocked(trustDir, fileName, friendId, scope, did, now, source, { rootUid, rootGid, expiresAt }) {
1395
+ const file = path.join(trustDir, fileName)
1396
+ const current = lstatOrNull(file) ? readJsonNoFollow(file) : { schemaVersion: 1, grants: {} }
1397
+ const had = current.grants?.[friendId]
1398
+ if (had?.scope !== scope || had.did !== did || had.expiresAt !== expiresAt) {
1399
+ current.grants = { ...current.grants, [friendId]: { scope, did, grantedAt: now.toISOString(), source, ...(expiresAt ? { expiresAt } : {}) } }
1101
1400
  writeAtomic(file, `${JSON.stringify(current, null, 2)}\n`, 0o644)
1102
1401
  }
1103
- chownSync(file, rootUid, rootGid); chmodSync(file, 0o644)
1402
+ handOverFile(file, { uid: rootUid, gid: rootGid, mode: 0o644 })
1403
+ }
1404
+
1405
+ /** Removes a friend's entry from one trust file (a no-op when it is absent), leaving the file root-owned. */
1406
+ function revokeTrustGrant(trustDir, fileName, friendId, { rootUid = 0, rootGid = 0, ...lockOptions } = {}) {
1407
+ if (!lstatOrNull(trustDir)) return
1408
+ return withTrustLock(trustDir, { rootUid, rootGid, ...lockOptions }, () => revokeTrustGrantLocked(trustDir, fileName, friendId, { rootUid, rootGid }))
1409
+ }
1410
+
1411
+ function revokeTrustGrantLocked(trustDir, fileName, friendId, { rootUid, rootGid }) {
1412
+ const file = path.join(trustDir, fileName)
1413
+ if (!lstatOrNull(file)) return
1414
+ const current = readJsonNoFollow(file)
1415
+ if (current.grants?.[friendId] === undefined) return
1416
+ const { [friendId]: _removed, ...rest } = current.grants
1417
+ writeAtomic(file, `${JSON.stringify({ ...current, grants: rest }, null, 2)}\n`, 0o644)
1418
+ handOverFile(file, { uid: rootUid, gid: rootGid, mode: 0o644 })
1419
+ }
1420
+
1421
+ const REPLAY_GRANT_SOURCE = "replay gate provisioning (host root)"
1422
+
1423
+ /** Gives the replay principal the principal_commands grant in the trust directory, pinned to the DID it signs with. Replay grants always carry `root.expiresAt`: the Butler refuses a replay grant that has none. */
1424
+ export function grantDelegatedCommands(trustDir, friendId, did, now = new Date(), root = {}) {
1425
+ writeTrustGrant(trustDir, "delegated-command-grants.json", friendId, "principal_commands", did, now, REPLAY_GRANT_SOURCE, root)
1426
+ }
1427
+
1428
+ /** Gives the replay escalation peer the escalation grant in the trust directory, pinned to its DID. */
1429
+ export function grantEscalation(trustDir, friendId, did, now = new Date(), root = {}) {
1430
+ writeTrustGrant(trustDir, "escalation-grants.json", friendId, "escalation", did, now, REPLAY_GRANT_SOURCE, root)
1431
+ }
1432
+
1433
+ /** Removes the replay principal's principal_commands grant. */
1434
+ export function revokeDelegatedCommands(trustDir, friendId, root = {}) {
1435
+ revokeTrustGrant(trustDir, "delegated-command-grants.json", friendId, root)
1436
+ }
1437
+
1438
+ /** Removes the replay escalation peer's grant. */
1439
+ export function revokeEscalation(trustDir, friendId, root = {}) {
1440
+ revokeTrustGrant(trustDir, "escalation-grants.json", friendId, root)
1104
1441
  }
1105
1442
 
1106
- export function provision({ bundle = DEFAULT_BUNDLE, cardUrl, log = console.log, run = ctr, discover = discoverCardUrl, rootUid = 0, rootGid = 0 } = {}) {
1443
+ /** The replay peers the root-owned trust directory lists, as { friendId: { who, name, did } }; empty when the list does not exist yet. */
1444
+ function trustedReplayList(trustDir) {
1445
+ const file = path.join(trustDir, "replay-identities.json")
1446
+ return lstatOrNull(file) ? readJsonNoFollow(file).grants ?? {} : {}
1447
+ }
1448
+
1449
+ /**
1450
+ * Mints (or re-reads) the three replay peers. Friend ids and DIDs come only from root-owned state: the replay list in the trust directory and
1451
+ * the record in the root-owned replay folder (`clientDir`). Nothing in the bundle's state/replay-client or state/replay supplies either, because
1452
+ * the Butler's user writes the bundle.
1453
+ */
1454
+ export function provision({ bundle = DEFAULT_BUNDLE, trustDir = DEFAULT_TRUST_DIR, clientDir = DEFAULT_CLIENT_DIR, chainRoot, cardUrl, log = console.log, run = ctr, discover = discoverCardUrl, stage = dockerStage(), retireRecord = retireRecordInContainer, retire = [], rootUid = 0, rootGid = 0, rotate = false, now = new Date() } = {}) {
1107
1455
  const state = path.join(bundle, "state")
1108
1456
  const replayDir = path.join(state, "replay")
1109
- const clientDir = path.join(state, "replay-client")
1457
+ refuseLinkOrWrongType(state, "directory")
1110
1458
  const owner = statSync(state)
1111
- // The replay directory is root-owned so the Butler process cannot create or edit the window file; only the sink is its own.
1112
- mkdirSync(replayDir, { recursive: true, mode: 0o755 }); chownSync(replayDir, rootUid, rootGid); chmodSync(replayDir, 0o755)
1113
- const sink = path.join(replayDir, "notices.ndjson")
1114
- writeFileSync(sink, "", { flag: "a", mode: 0o600 }); chownSync(sink, owner.uid, owner.gid)
1115
- mkdirSync(clientDir, { recursive: true, mode: 0o700 }); chownSync(clientDir, owner.uid, owner.gid)
1116
- const replayIdentities = {}
1117
- const previous = existsSync(path.join(clientDir, "provision.json")) ? JSON.parse(readFileSync(path.join(clientDir, "provision.json"), "utf8")) : {}
1459
+ // The replay client seeds and the provisioning record live outside the bundle, in a root-owned host folder.
1460
+ rootOwnedHostDirectory(clientDir, 0o700, { rootUid, rootGid, chainRoot })
1461
+ // The replay directory is root-owned so the Butler process cannot create or edit the window file; only the sink is its own. One the
1462
+ // Butler's user made is moved aside, never taken over.
1463
+ freshRootDirectoryInBundle(replayDir, 0o755, { rootUid, rootGid, now, log })
1464
+ handOverFile(path.join(replayDir, "notices.ndjson"), { uid: owner.uid, gid: owner.gid, create: true })
1465
+ const trusted = trustedReplayList(trustDir)
1466
+ const recordFile = path.join(clientDir, "provision.json")
1467
+ let previous = lstatOrNull(recordFile) ? readJsonNoFollow(recordFile) : {}
1468
+ const legacyRecord = path.join(state, "replay-client", "provision.json")
1469
+ if (lstatOrNull(legacyRecord)) {
1470
+ let legacyIds = []
1471
+ try { legacyIds = ["principal", "stranger", "escalation"].map((who) => readJsonNoFollow(legacyRecord)[who]?.friendId).filter((id) => typeof id === "string") } catch { /* unreadable: nothing to report */ }
1472
+ log(`ignored ${legacyRecord}: the bundle is writable by the Butler's user, so it supplies no friend ids${legacyIds.length ? `. Friends it names (unverified): ${legacyIds.join(", ")}. To retire one you have checked is a replay peer, pass --retire-friend <id> with --rotate-replay-identities` : ""}`)
1473
+ }
1118
1474
  const resolvedCard = cardUrl ?? previous.cardUrl ?? discover()
1475
+ if (rotate) {
1476
+ // One-time rotation: the old seeds may have been readable by the Butler's user, so every replay peer is retired (its seed moved aside,
1477
+ // its grants removed, its friend record demoted and revoked) and minted again below under a new DID and a new friend id. Who is retired
1478
+ // comes only from root-owned state plus any id the operator names.
1479
+ const stamp = now.toISOString().replace(/[:.]/g, "-")
1480
+ const retiring = new Map()
1481
+ for (const who of ["principal", "stranger", "escalation"]) if (previous[who]?.friendId) retiring.set(previous[who].friendId, previous[who].did)
1482
+ for (const [id, entry] of Object.entries(trusted)) retiring.set(id, entry?.did)
1483
+ for (const id of retire) retiring.set(id, retiring.get(id))
1484
+ for (const [friendId, did] of retiring) {
1485
+ revokeDelegatedCommands(trustDir, friendId, { rootUid, rootGid })
1486
+ revokeEscalation(trustDir, friendId, { rootUid, rootGid })
1487
+ // The record lives in the Butler-writable bundle, so a missing or mangled one must not stop the rotation: the trust-dir revokes
1488
+ // above are what take authority away, and they have already happened.
1489
+ try {
1490
+ run(["friend", "update", friendId, "--agent", "sanctuary", "--admission", "revoked", "--trust", "stranger"])
1491
+ retireRecord(friendId, did)
1492
+ log(`retired friend ${friendId}`)
1493
+ } catch (error) {
1494
+ log(`revoked friend ${friendId}'s grants, but could not demote its record: ${error instanceof Error ? error.message.split("\n")[0] : String(error)}`)
1495
+ }
1496
+ }
1497
+ for (const who of ["principal", "stranger", "escalation"]) {
1498
+ const seed = path.join(clientDir, `${who}.json`)
1499
+ if (refuseLinkOrWrongType(seed, "file")) renameSync(seed, `${seed}.retired-${stamp}`)
1500
+ }
1501
+ previous = { cardUrl: resolvedCard }
1502
+ }
1119
1503
  const out = { cardUrl: resolvedCard }
1120
- for (const [who, trust, grant, name, escalate] of [["principal", "family", true, "replay-principal", false], ["stranger", "friend", false, "replay-stranger", false], ["escalation", "family", false, "replay-escalation", true]]) {
1121
- const hostIdentity = path.join(clientDir, `${who}.json`)
1122
- const containerIdentityFile = `${CONTAINER_BUNDLE}/state/replay-client/${who}.json`
1123
- const did = JSON.parse(run(["a2a", "identity", "--identity-file", containerIdentityFile, "--json"])).did
1124
- let friendId = previous[who]?.did === did ? previous[who].friendId : null
1125
- if (!friendId) {
1126
- const onboarded = run(["a2a", "onboard", "--agent", "sanctuary", "--did", did, "--name", name, "--trust", trust])
1127
- friendId = /friend id:\s*(\S+)/.exec(onboarded)?.[1]
1128
- if (!friendId) throw new Error(`could not read the friend id from: ${onboarded}`)
1504
+ const replayIdentities = {}
1505
+ const stageDir = stage.create()
1506
+ try {
1507
+ for (const [who, trust, grant, name, escalate] of [["principal", "family", true, "replay-principal", false], ["stranger", "friend", false, "replay-stranger", false], ["escalation", "family", false, "replay-escalation", true]]) {
1508
+ const hostIdentity = path.join(clientDir, `${who}.json`)
1509
+ const present = refuseLinkOrWrongType(hostIdentity, "file") !== null
1510
+ if (present) stage.copyIn(hostIdentity, stageDir, `${who}.json`)
1511
+ const did = JSON.parse(run(["a2a", "identity", "--identity-file", `${stageDir}/${who}.json`, "--json"])).did
1512
+ if (!present) stage.copyOut(stageDir, `${who}.json`, hostIdentity)
1513
+ // The seed is root's alone: the Butler's uid must not read the seed of a peer that can hold a grant.
1514
+ handOverFile(hostIdentity, { uid: rootUid, gid: rootGid, mode: 0o600 })
1515
+ let friendId = previous[who]?.did === did ? previous[who].friendId : null
1516
+ if (!friendId) {
1517
+ const onboarded = run(["a2a", "onboard", "--agent", "sanctuary", "--did", did, "--name", name, "--trust", trust])
1518
+ friendId = /friend id:\s*(\S+)/.exec(onboarded)?.[1]
1519
+ if (!friendId) throw new Error(`could not read the friend id from: ${onboarded}`)
1520
+ }
1521
+ run(["friend", "update", friendId, "--agent", "sanctuary", "--admission", "active", "--initiative", "reactive_only", "--profile", PEER_PROFILE])
1522
+ const record = readJsonNoFollow(friendFile(bundle, friendId))
1523
+ // The record's own delegationGrant is only a hint the Butler ignores; nobody here may hold one.
1524
+ if (record.delegationGrant && !grant) throw new Error(`${name} must not hold a delegation grant`)
1525
+ // The replay list is root-owned: once a replay peer's DID is recorded there, a different DID under the same friend is a swap.
1526
+ const recorded = trusted[friendId]?.did
1527
+ if (recorded !== undefined && recorded !== did) throw new Error(`the ${name} DID changed (recorded ${recorded}, now ${did}); refusing to re-grant it`)
1528
+ // Provisioning grants nothing: the replay peers receive their trusted grants, bounded by the window, only while a run is open.
1529
+ out[who] = { friendId, did, hostIdentity }
1530
+ replayIdentities[friendId] = { name, who, did }
1531
+ log(`${name}: friend ${friendId} (${trust}${grant ? ", principal_commands" : escalate ? ", escalation" : ", no grant"})`)
1129
1532
  }
1130
- run(["friend", "update", friendId, "--agent", "sanctuary", "--admission", "active", "--initiative", "reactive_only", "--profile", PEER_PROFILE])
1131
- const file = friendFile(bundle, friendId)
1132
- const record = JSON.parse(readFileSync(file, "utf8"))
1133
- if (grant && record.delegationGrant?.scope !== "principal_commands") grantPrincipalCommands(file)
1134
- if (!grant && record.delegationGrant) throw new Error(`${name} must not hold a delegation grant`)
1135
- if (escalate) grantEscalation(bundle, friendId, did, new Date(), { rootUid, rootGid })
1136
- out[who] = { friendId, did, containerIdentityFile, hostIdentity }
1137
- replayIdentities[friendId] = { name, who }
1138
- log(`${name}: friend ${friendId} (${trust}${grant ? ", principal_commands" : escalate ? ", escalation" : ", no grant"})`)
1533
+ } finally {
1534
+ try { stage.remove(stageDir) } catch (error) { log(`could not remove the staged replay identities ${stageDir}: ${error.message}`) }
1139
1535
  }
1140
- // Permanent marker, root-owned and never cleared with the window: the Butler refuses every owner-policy write from these friends (src/a2a/replay-harness.ts isReplayIdentity).
1536
+ // The same list, inside the root-owned trust directory: the Butler reads this copy, not the bundle's, to decide that a grant for these friends must expire.
1537
+ const holdersFile = path.join(trustDir, "replay-identities.json")
1538
+ withTrustLock(trustDir, { rootUid, rootGid }, () => {
1539
+ const heldBefore = trustedReplayList(trustDir)
1540
+ const holders = { ...heldBefore, ...Object.fromEntries(Object.entries(replayIdentities).map(([id, entry]) => [id, { who: entry.who, name: entry.name, did: entry.did }])) }
1541
+ writeAtomic(holdersFile, `${JSON.stringify({ schemaVersion: 1, grants: holders }, null, 2)}\n`, 0o644)
1542
+ handOverFile(holdersFile, { uid: rootUid, gid: rootGid, mode: 0o644 })
1543
+ })
1544
+ // Permanent marker the Butler reads inside the bundle (src/a2a/replay-harness.ts isReplayIdentity): rebuilt from the root-owned list on every provision, never read back from the bundle.
1141
1545
  const registry = path.join(replayDir, "identities.json")
1142
- let known = {}
1143
- try { known = JSON.parse(readFileSync(registry, "utf8")).friends ?? {} } catch { /* first provision */ }
1546
+ const known = Object.fromEntries(Object.entries(trustedReplayList(trustDir)).map(([id, entry]) => [id, { name: entry.name, who: entry.who, did: entry.did }]))
1144
1547
  writeAtomic(registry, JSON.stringify({ friends: { ...known, ...replayIdentities } }, null, 2), 0o644)
1145
- chownSync(registry, rootUid, rootGid)
1146
- writeAtomic(path.join(clientDir, "provision.json"), JSON.stringify(out, null, 2), 0o644)
1548
+ handOverFile(registry, { uid: rootUid, gid: rootGid })
1549
+ writeAtomic(recordFile, JSON.stringify(out, null, 2), 0o600)
1550
+ handOverFile(recordFile, { uid: rootUid, gid: rootGid, mode: 0o600 })
1147
1551
  log(`provisioned; card ${resolvedCard}`)
1148
1552
  return out
1149
1553
  }
@@ -1165,7 +1569,11 @@ export function parseArgs(argv) {
1165
1569
  if (flag === "--cases" && rest[i + 1]) options.cases = rest[++i].split(",").filter(Boolean)
1166
1570
  else if (flag === "--plant" && rest[i + 1]) options.plant = rest[++i]
1167
1571
  else if (flag === "--bundle" && rest[i + 1]) options.bundle = rest[++i]
1572
+ else if (flag === "--trust-dir" && rest[i + 1]) options.trustDir = rest[++i]
1168
1573
  else if (flag === "--card-url" && rest[i + 1]) options.cardUrl = rest[++i]
1574
+ else if (flag === "--client-dir" && rest[i + 1]) options.clientDir = rest[++i]
1575
+ else if (flag === "--retire-friend" && rest[i + 1]) options.retire = [...(options.retire ?? []), rest[++i]]
1576
+ else if (flag === "--rotate-replay-identities") options.rotate = true
1169
1577
  else if (flag === "--window-minutes" && rest[i + 1]) options.windowMinutes = Number(rest[++i])
1170
1578
  else throw new Error(`unknown argument: ${flag}`)
1171
1579
  }
@@ -1182,11 +1590,11 @@ export async function main(argv, io = { out: (text) => console.log(text), err: (
1182
1590
  return problems.length === 0 ? 0 : 1
1183
1591
  }
1184
1592
  if (options.command === "provision") {
1185
- try { deps.provision({ ...(options.bundle ? { bundle: options.bundle } : {}), ...(options.cardUrl ? { cardUrl: options.cardUrl } : {}), log: io.out }); return 0 } catch (error) { io.err(`provision failed: ${error.message}`); return 1 }
1593
+ try { deps.provision({ ...(options.bundle ? { bundle: options.bundle } : {}), ...(options.trustDir ? { trustDir: options.trustDir } : {}), ...(options.cardUrl ? { cardUrl: options.cardUrl } : {}), ...(options.clientDir ? { clientDir: options.clientDir } : {}), ...(options.retire ? { retire: options.retire } : {}), ...(options.rotate ? { rotate: true } : {}), log: io.out }); return 0 } catch (error) { io.err(`provision failed: ${error.message}`); return 1 }
1186
1594
  }
1187
1595
  if (options.command === "run") {
1188
1596
  let host
1189
- try { host = deps.makeHost({ ...(options.bundle ? { bundle: options.bundle } : {}), ...(options.cardUrl ? { cardUrl: options.cardUrl } : {}), log: io.err }) } catch (error) { io.err(error.message); return 1 }
1597
+ try { host = deps.makeHost({ ...(options.bundle ? { bundle: options.bundle } : {}), ...(options.trustDir ? { trustDir: options.trustDir } : {}), ...(options.clientDir ? { clientDir: options.clientDir } : {}), ...(options.cardUrl ? { cardUrl: options.cardUrl } : {}), log: io.err }) } catch (error) { io.err(error.message); return 1 }
1190
1598
  let suite
1191
1599
  try {
1192
1600
  suite = await runSuite(host, { ...(options.cases ? { cases: options.cases } : {}), ...(options.plant ? { plant: options.plant } : {}), ...(options.windowMinutes ? { windowMinutes: options.windowMinutes } : {}) })
@@ -1195,7 +1603,7 @@ export async function main(argv, io = { out: (text) => console.log(text), err: (
1195
1603
  io.out(JSON.stringify({ summary: suite.summary }))
1196
1604
  return suite.summary.ok ? 0 : 1
1197
1605
  }
1198
- io.err("Usage: sanctuary-replay-gate.mjs <provision|run [--cases a,b] [--plant <case>] [--window-minutes n]|self-test> [--bundle <dir>] [--card-url <url>]")
1606
+ io.err("Usage: sanctuary-replay-gate.mjs <provision [--rotate-replay-identities]|run [--cases a,b] [--plant <case>] [--window-minutes n]|self-test> [--bundle <dir>] [--trust-dir <dir>] [--client-dir <dir>] [--card-url <url>]")
1199
1607
  return 2
1200
1608
  }
1201
1609