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