@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.
- package/changelog.json +21 -0
- package/deploy/unraid/README.txt +7 -5
- package/deploy/unraid/sanctuary-butler-upgrade.mjs +87 -30
- package/deploy/unraid/sanctuary-replay-gate.mjs +473 -74
- package/deploy/unraid/sanctuary-unit16-host-broker.mjs +1 -0
- package/deploy/unraid/sanctuary.ouro/bundle-meta.json +1 -1
- package/deploy/unraid/sanctuary.xml +2 -1
- package/dist/a2a/client.js +16 -4
- package/dist/a2a/delegated-command-grants.js +93 -0
- package/dist/a2a/delegated-command.js +57 -11
- package/dist/a2a/escalation-grants.js +37 -75
- package/dist/a2a/identity.js +54 -0
- package/dist/a2a/operator-trust.js +382 -0
- package/dist/a2a/outbox-wire.js +2 -2
- package/dist/a2a/replay-grant-holders.js +33 -0
- package/dist/a2a/server.js +12 -7
- package/dist/a2a/trust-commands.js +203 -0
- package/dist/a2a/trusted-files.js +113 -26
- package/dist/heart/core.js +9 -5
- package/dist/heart/daemon/cli-exec.js +38 -37
- package/dist/heart/daemon/cli-parse.js +45 -5
- package/dist/heart/daemon/container-spec-auditor.js +3 -0
- package/dist/heart/daemon/sanctuary-acceptance-harness.js +1 -1
- package/dist/heart/daemon/sanctuary-acceptance-scenarios.js +1 -1
- package/dist/heart/daemon/sanctuary-authority-root-lifecycle.js +4 -47
- package/dist/heart/failure-reports.js +5 -5
- package/dist/mind/prompt.js +31 -1
- package/dist/repertoire/guardrails.js +89 -7
- package/dist/repertoire/house-sweep.js +4 -1
- package/dist/senses/a2a-entry.js +2 -1
- package/dist/senses/private-runtime.js +11 -4
- package/dist/senses/shared-turn.js +5 -1
- package/dist/senses/telegram.js +5 -3
- package/npm-shrinkwrap.json +2 -2
- 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
|
|
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
|
-
|
|
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
|
|
917
|
-
|
|
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
|
-
|
|
923
|
-
const
|
|
924
|
-
const
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
|
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",
|
|
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 (
|
|
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${
|
|
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 =
|
|
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:
|
|
1055
|
-
auditSha:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
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
|
-
*
|
|
1101
|
-
*
|
|
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
|
-
|
|
1104
|
-
|
|
1105
|
-
const
|
|
1106
|
-
|
|
1107
|
-
const
|
|
1108
|
-
|
|
1109
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1457
|
+
refuseLinkOrWrongType(state, "directory")
|
|
1119
1458
|
const owner = statSync(state)
|
|
1120
|
-
// The replay
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
const
|
|
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
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
const
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
if (!
|
|
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
|
-
|
|
1140
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
1155
|
-
writeAtomic(
|
|
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
|
|