@ouro.bot/cli 0.1.0-alpha.886 → 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 +21 -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 +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.
@@ -910,22 +919,78 @@ export function mkdirOwned(root, dir, owner, { mkdir = mkdirSync, chown = chownS
910
919
  for (const made of missing) { mkdir(made, { mode: 0o700 }); chown(made, owner.uid, owner.gid) }
911
920
  }
912
921
 
913
- 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 } = {}) {
914
962
  const state = path.join(bundle, "state")
915
963
  const replayDir = path.join(state, "replay")
916
- const clientDir = path.join(state, "replay-client")
917
- 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
918
968
  if (!provisioned) throw new Error("replay peers are not provisioned; run `provision` first")
919
969
  if (!provisioned.escalation) throw new Error("the replay escalation peer is not provisioned; run `provision` again")
920
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 }
921
974
  const card = cardUrl ?? provisioned.cardUrl
922
- const readJson = (file) => JSON.parse(readFileSync(file, "utf8"))
923
- const readLines = (file) => (existsSync(file) ? readFileSync(file, "utf8").split("\n").filter(Boolean) : [])
924
- 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)
925
985
  const awaitEntries = (dir) => list(dir).filter((name) => name.endsWith(".md")).map((name) => {
926
- const text = readFileSync(path.join(dir, name), "utf8")
986
+ const text = readTextNoFollow(path.join(dir, name))
927
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() ?? "" }
928
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
+ }
929
994
  return {
930
995
  friends: { principal: provisioned.principal.friendId, stranger: provisioned.stranger.friendId, escalation: provisioned.escalation.friendId },
931
996
  now: () => Date.now(),
@@ -939,12 +1004,35 @@ export function makeHost({ bundle = DEFAULT_BUNDLE, cardUrl, log = console.error
939
1004
  throw new Error(`the Butler's A2A card did not answer at ${card}`)
940
1005
  },
941
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
+ }
942
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 })
943
1025
  writeAtomic(path.join(replayDir, "window.json"), JSON.stringify({ friends: Object.fromEntries(replayPeers.map((id) => [id, { expiresAt }])) }), 0o644)
944
1026
  log(`replay window open until ${expiresAt}`)
945
1027
  },
946
1028
  closeWindow() {
947
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
+ }
948
1036
  log("replay window closed")
949
1037
  },
950
1038
  /** Asks the Butler's own replay code, inside the container, whether it opens the window for both peers. */
@@ -961,8 +1049,7 @@ export function makeHost({ bundle = DEFAULT_BUNDLE, cardUrl, log = console.error
961
1049
  }
962
1050
  },
963
1051
  async send({ who, text, delegated, context }) {
964
- const peer = provisioned[who]
965
- 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"] : [])]
966
1053
  try {
967
1054
  const out = exec("docker", args, { timeout: MESSAGE_TIMEOUT_MS })
968
1055
  const parsed = JSON.parse(out.trim().split("\n").at(-1))
@@ -976,17 +1063,20 @@ export function makeHost({ bundle = DEFAULT_BUNDLE, cardUrl, log = console.error
976
1063
  if (!replayPeers.includes(friendId)) throw new Error(`refusing to seed the outbox of a non-replay friend: ${friendId}`)
977
1064
  const dir = path.join(state, "outbox", friendId)
978
1065
  const owner = statSync(state)
1066
+ for (const part of [state, path.join(state, "outbox"), dir]) refuseLinkOrWrongType(part, "directory")
979
1067
  mkdirOwned(state, dir, owner)
980
1068
  const id = `${String(Date.now()).padStart(13, "0")}-${randomUUID().replace(/-/g, "").slice(0, 6)}`
981
1069
  writeAtomic(path.join(dir, `${id}.json`), JSON.stringify({ id, kind: "isolation_canary", createdAt: new Date().toISOString(), body }), 0o600)
982
- 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 })
983
1073
  return { id }
984
1074
  },
985
1075
  /** Calls the outbox methods as one replay peer, through the same CLI an operator uses. */
986
1076
  async outbox(who, args) {
987
- const peer = provisioned[who]
1077
+ const identity = identityFor(who)
988
1078
  try {
989
- 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 })
990
1080
  return { ok: true, value: JSON.parse(out.trim().split("\n").at(-1)) }
991
1081
  } catch (error) {
992
1082
  return { ok: false, error: `${error.stderr ?? ""} ${error.message ?? ""}`.replace(/\s+/g, " ").trim() }
@@ -996,7 +1086,7 @@ export function makeHost({ bundle = DEFAULT_BUNDLE, cardUrl, log = console.error
996
1086
  const root = path.join(state, "sessions")
997
1087
  for (const dir of list(root)) {
998
1088
  const file = path.join(root, dir, "a2a", `${context}.json`)
999
- if (existsSync(file)) return readJson(file)
1089
+ if (lstatOrNull(file)) return readJson(file)
1000
1090
  }
1001
1091
  return null
1002
1092
  },
@@ -1026,7 +1116,7 @@ export function makeHost({ bundle = DEFAULT_BUNDLE, cardUrl, log = console.error
1026
1116
  let psycheSha = null
1027
1117
  try {
1028
1118
  const dir = path.join(bundle, "psyche")
1029
- 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"))
1030
1120
  } catch { /* stays null */ }
1031
1121
  let radarrProfilesSha = null
1032
1122
  try {
@@ -1037,7 +1127,7 @@ export function makeHost({ bundle = DEFAULT_BUNDLE, cardUrl, log = console.error
1037
1127
  const containers = {}
1038
1128
  for (const name of exec("docker", ["ps", "--format", "{{.Names}}"]).split("\n").filter(Boolean)) containers[name] = true
1039
1129
  const effectsDir = path.join(state, "telegram", "effects")
1040
- const effectsReadable = existsSync(effectsDir)
1130
+ const effectsReadable = lstatOrNull(effectsDir) !== null
1041
1131
  const effects = list(effectsDir).filter((name) => name.endsWith(".json")).flatMap((name) => {
1042
1132
  try { const record = readJson(path.join(effectsDir, name)); return [{ idempotencyKey: record.idempotencyKey, createdAt: record.createdAt }] } catch { return [] }
1043
1133
  })
@@ -1051,8 +1141,8 @@ export function makeHost({ bundle = DEFAULT_BUNDLE, cardUrl, log = console.error
1051
1141
  return {
1052
1142
  outbox,
1053
1143
  outboxBodies,
1054
- stewardSha: existsSync(policy) ? sha256(readFileSync(policy)) : null,
1055
- auditSha: existsSync(audit) ? sha256(readFileSync(audit)) : null,
1144
+ stewardSha: hashOf(policy),
1145
+ auditSha: hashOf(audit),
1056
1146
  principalSig,
1057
1147
  psycheSha,
1058
1148
  radarrProfilesSha,
@@ -1070,89 +1160,394 @@ export function makeHost({ bundle = DEFAULT_BUNDLE, cardUrl, log = console.error
1070
1160
  }
1071
1161
  }
1072
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
+
1073
1298
  function writeAtomic(file, text, mode) {
1074
1299
  const tmp = `${file}.${process.pid}.tmp`
1075
- writeFileSync(tmp, text, { mode })
1300
+ rmSync(tmp, { force: true })
1301
+ writeFileSync(tmp, text, { mode, flag: "wx" })
1076
1302
  renameSync(tmp, file)
1077
1303
  }
1078
1304
 
1079
1305
  // ---- provision ---------------------------------------------------------------------------------------------------
1080
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. */
1081
1308
  function ctr(args) {
1082
- 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()
1083
1311
  }
1084
1312
 
1085
1313
  function friendFile(bundle, friendId) {
1086
1314
  return path.join(bundle, "friends", `${friendId}.json`)
1087
1315
  }
1088
1316
 
1089
- /** Adds the operator-set delegation grant to a friend record, as root, keeping the file's owner and mode. */
1090
- export function grantPrincipalCommands(file, now = new Date()) {
1091
- const owner = statSync(file)
1092
- const record = JSON.parse(readFileSync(file, "utf8"))
1093
- record.delegationGrant = { scope: "principal_commands", grantedAt: now.toISOString(), source: "replay gate provisioning (host root)" }
1094
- record.updatedAt = now.toISOString()
1095
- writeAtomic(file, `${JSON.stringify(record, null, 2)}\n`, owner.mode & 0o777)
1096
- 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) }
1097
1350
  }
1098
1351
 
1099
1352
  /**
1100
- * Gives a friend the escalation grant in the agent's grant file, as root. The directory and file stay root-owned and
1101
- * 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.
1102
1356
  */
1103
- export function grantEscalation(bundle, friendId, did, now = new Date(), { rootUid = 0, rootGid = 0 } = {}) {
1104
- const dir = path.join(bundle, "state", "a2a")
1105
- const file = path.join(dir, "escalation-grants.json")
1106
- mkdirSync(dir, { recursive: true, mode: 0o755 }); chownSync(dir, rootUid, rootGid); chmodSync(dir, 0o755)
1107
- const current = existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : { schemaVersion: 1, grants: {} }
1108
- if (current.grants?.[friendId]?.scope !== "escalation" || current.grants[friendId].did !== did) {
1109
- 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 } : {}) } }
1110
1400
  writeAtomic(file, `${JSON.stringify(current, null, 2)}\n`, 0o644)
1111
1401
  }
1112
- 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 })
1113
1419
  }
1114
1420
 
1115
- export function provision({ bundle = DEFAULT_BUNDLE, cardUrl, log = console.log, run = ctr, discover = discoverCardUrl, rootUid = 0, rootGid = 0 } = {}) {
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)
1441
+ }
1442
+
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() } = {}) {
1116
1455
  const state = path.join(bundle, "state")
1117
1456
  const replayDir = path.join(state, "replay")
1118
- const clientDir = path.join(state, "replay-client")
1457
+ refuseLinkOrWrongType(state, "directory")
1119
1458
  const owner = statSync(state)
1120
- // The replay directory is root-owned so the Butler process cannot create or edit the window file; only the sink is its own.
1121
- mkdirSync(replayDir, { recursive: true, mode: 0o755 }); chownSync(replayDir, rootUid, rootGid); chmodSync(replayDir, 0o755)
1122
- const sink = path.join(replayDir, "notices.ndjson")
1123
- writeFileSync(sink, "", { flag: "a", mode: 0o600 }); chownSync(sink, owner.uid, owner.gid)
1124
- mkdirSync(clientDir, { recursive: true, mode: 0o700 }); chownSync(clientDir, owner.uid, owner.gid)
1125
- const replayIdentities = {}
1126
- 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
+ }
1127
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
+ }
1128
1503
  const out = { cardUrl: resolvedCard }
1129
- 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]]) {
1130
- const hostIdentity = path.join(clientDir, `${who}.json`)
1131
- const containerIdentityFile = `${CONTAINER_BUNDLE}/state/replay-client/${who}.json`
1132
- const did = JSON.parse(run(["a2a", "identity", "--identity-file", containerIdentityFile, "--json"])).did
1133
- let friendId = previous[who]?.did === did ? previous[who].friendId : null
1134
- if (!friendId) {
1135
- const onboarded = run(["a2a", "onboard", "--agent", "sanctuary", "--did", did, "--name", name, "--trust", trust])
1136
- friendId = /friend id:\s*(\S+)/.exec(onboarded)?.[1]
1137
- 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"})`)
1138
1532
  }
1139
- run(["friend", "update", friendId, "--agent", "sanctuary", "--admission", "active", "--initiative", "reactive_only", "--profile", PEER_PROFILE])
1140
- const file = friendFile(bundle, friendId)
1141
- const record = JSON.parse(readFileSync(file, "utf8"))
1142
- if (grant && record.delegationGrant?.scope !== "principal_commands") grantPrincipalCommands(file)
1143
- if (!grant && record.delegationGrant) throw new Error(`${name} must not hold a delegation grant`)
1144
- if (escalate) grantEscalation(bundle, friendId, did, new Date(), { rootUid, rootGid })
1145
- out[who] = { friendId, did, containerIdentityFile, hostIdentity }
1146
- replayIdentities[friendId] = { name, who }
1147
- 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}`) }
1148
1535
  }
1149
- // 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.
1150
1545
  const registry = path.join(replayDir, "identities.json")
1151
- let known = {}
1152
- 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 }]))
1153
1547
  writeAtomic(registry, JSON.stringify({ friends: { ...known, ...replayIdentities } }, null, 2), 0o644)
1154
- chownSync(registry, rootUid, rootGid)
1155
- 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 })
1156
1551
  log(`provisioned; card ${resolvedCard}`)
1157
1552
  return out
1158
1553
  }
@@ -1174,7 +1569,11 @@ export function parseArgs(argv) {
1174
1569
  if (flag === "--cases" && rest[i + 1]) options.cases = rest[++i].split(",").filter(Boolean)
1175
1570
  else if (flag === "--plant" && rest[i + 1]) options.plant = rest[++i]
1176
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]
1177
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
1178
1577
  else if (flag === "--window-minutes" && rest[i + 1]) options.windowMinutes = Number(rest[++i])
1179
1578
  else throw new Error(`unknown argument: ${flag}`)
1180
1579
  }
@@ -1191,11 +1590,11 @@ export async function main(argv, io = { out: (text) => console.log(text), err: (
1191
1590
  return problems.length === 0 ? 0 : 1
1192
1591
  }
1193
1592
  if (options.command === "provision") {
1194
- 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 }
1195
1594
  }
1196
1595
  if (options.command === "run") {
1197
1596
  let host
1198
- 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 }
1199
1598
  let suite
1200
1599
  try {
1201
1600
  suite = await runSuite(host, { ...(options.cases ? { cases: options.cases } : {}), ...(options.plant ? { plant: options.plant } : {}), ...(options.windowMinutes ? { windowMinutes: options.windowMinutes } : {}) })
@@ -1204,7 +1603,7 @@ export async function main(argv, io = { out: (text) => console.log(text), err: (
1204
1603
  io.out(JSON.stringify({ summary: suite.summary }))
1205
1604
  return suite.summary.ok ? 0 : 1
1206
1605
  }
1207
- 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>]")
1208
1607
  return 2
1209
1608
  }
1210
1609