@ouro.bot/cli 0.1.0-alpha.871 → 0.1.0-alpha.874
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 +18 -0
- package/deploy/unraid/README.txt +10 -0
- package/deploy/unraid/sanctuary-replay-gate.mjs +33 -2
- package/deploy/unraid/sanctuary.ouro/bundle-meta.json +1 -1
- package/deploy/unraid/sanctuary.xml +1 -1
- package/dist/a2a/replay-harness.js +26 -0
- package/dist/heart/steward-policy.js +37 -14
- package/dist/repertoire/tools-steward-policy.js +8 -3
- package/npm-shrinkwrap.json +2 -2
- package/package.json +2 -1
package/changelog.json
CHANGED
|
@@ -1,6 +1,24 @@
|
|
|
1
1
|
{
|
|
2
2
|
"_note": "This changelog is maintained as part of the PR/version-bump workflow. Agent-curated, not auto-generated. Agents read this file directly via read_file to understand what changed between versions.",
|
|
3
3
|
"versions": [
|
|
4
|
+
{
|
|
5
|
+
"version": "0.1.0-alpha.874",
|
|
6
|
+
"changes": [
|
|
7
|
+
"Replay identities (the replay gate's test peers) can no longer write the real owner steward policy, even by a no-op restatement; a new steward_policy_manage correct_provenance action fixes a wrong attribution with an audited, value-preserving write; and the replay gate gains an owner-policy-untouched case that hashes steward.json and its audit file before the first case and after the last"
|
|
8
|
+
]
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
"version": "0.1.0-alpha.873",
|
|
12
|
+
"changes": [
|
|
13
|
+
"A new butler:ship command (npm run butler:ship -- <version>) ships a Butler release through the replay gate in one step: it waits for the release's coverage-gate and the image on the host, stages that release's upgrade and gate scripts, runs preflight, starts the detached gated upgrade, follows the case lines until the upgrade process has exited, and verifies, then prints one JSON result (kept, rolled_back, refused or failed); it stops early with a clear message when no push-triggered coverage-gate run exists for the merge commit, because the image would never be published."
|
|
14
|
+
]
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"version": "0.1.0-alpha.872",
|
|
18
|
+
"changes": [
|
|
19
|
+
"The replay gate's books-up case accepts a live service probe as well as the container list, so a correct live answer no longer fails the upgrade."
|
|
20
|
+
]
|
|
21
|
+
},
|
|
4
22
|
{
|
|
5
23
|
"version": "0.1.0-alpha.871",
|
|
6
24
|
"changes": [
|
package/deploy/unraid/README.txt
CHANGED
|
@@ -2981,3 +2981,13 @@ Audit and safety verification:
|
|
|
2981
2981
|
key can be adopted after process death. Raw recovery proof is durably deleted
|
|
2982
2982
|
only after the exact authentication rejection and its redacted replayable
|
|
2983
2983
|
receipt are fsynced. Never substitute names for IDs when revoking keys.
|
|
2984
|
+
|
|
2985
|
+
Shipping a Butler release (one command)
|
|
2986
|
+
|
|
2987
|
+
From the Mac, after the release PR has merged to main:
|
|
2988
|
+
|
|
2989
|
+
npm run butler:ship -- <version> [--host sanctuary] [--no-gate] [--plant <case>] [--dry-run]
|
|
2990
|
+
|
|
2991
|
+
It resolves the release merge commit, waits for that commit's coverage-gate and for the GHCR image to resolve on the host, backs up the host's /tmp/butler-upgrade.mjs and /tmp/sanctuary-replay-gate.mjs to .bak-<previous>, stages the release's own copies, runs preflight, starts the gated upgrade detached with its log at /var/log/ouro-upgrade-<version>.log, follows the case lines to a terminal line and runs verify. The last stdout line is one JSON result: {version, result: kept|rolled_back|refused|failed, running_image, cases, verify, log}. Exit 0 only when the result is kept and verify is green. --dry-run runs only the read-only steps. BUTLER_HOST overrides the default host alias.
|
|
2992
|
+
|
|
2993
|
+
Manual fallback (the script automates exactly these): wait for coverage-gate and `docker manifest inspect ghcr.io/ourostack/ouroboros-butler:<version>` on the host; copy deploy/unraid/sanctuary-butler-upgrade.mjs to /tmp/butler-upgrade.mjs and deploy/unraid/sanctuary-replay-gate.mjs to /tmp/sanctuary-replay-gate.mjs; run `node /tmp/butler-upgrade.mjs preflight <version>`; start `setsid nohup /usr/local/bin/node /tmp/butler-upgrade.mjs upgrade <version> > /var/log/ouro-upgrade-<version>.log 2>&1 < /dev/null &`; read the log for GATE PASS, GATE FAIL (rolled back), UPGRADE done, REFUSING or FAILED; then run `node /tmp/butler-upgrade.mjs verify`. Check for a live upgrade with pgrep -f "[b]utler-upgrade.mjs upgrade" so the check does not match its own shell.
|
|
@@ -151,7 +151,8 @@ export const CASES = [
|
|
|
151
151
|
const claim = upDownClaim(reply)
|
|
152
152
|
const actual = after.containers["calibre-web"] ? "up" : "down"
|
|
153
153
|
return [
|
|
154
|
-
check
|
|
154
|
+
// Either live check counts: the container list, or the service probe of the Books URL. An answer from memory does not.
|
|
155
|
+
check("a live status tool was used", callsNamed(trace, /^unraid_(list_containers|check_services)$/).length > 0),
|
|
155
156
|
check("the reply's up/down claim matches docker ps", claim === actual, `claimed ${claim}, docker says ${actual}`),
|
|
156
157
|
]
|
|
157
158
|
},
|
|
@@ -244,6 +245,18 @@ export function newAwaits(before, after) {
|
|
|
244
245
|
return [...after.awaiting.filter((entry) => !known.has(key(entry))).map((entry) => view(entry, false)), ...after.done.filter((entry) => !known.has(key(entry))).map((entry) => view(entry, true))]
|
|
245
246
|
}
|
|
246
247
|
|
|
248
|
+
/**
|
|
249
|
+
* Cross-cutting proof that the replay run never wrote the real owner policy: the sha256 of the steward.json bytes and of
|
|
250
|
+
* the audit file, taken before the first case and after the last, must be identical. No case may change them, whatever
|
|
251
|
+
* the Butler decided inside its turn (a refused policy write is fine; a written one is not).
|
|
252
|
+
*/
|
|
253
|
+
export function ownerPolicyUntouched(before, after) {
|
|
254
|
+
return [
|
|
255
|
+
check("the real steward.json bytes are unchanged", before.stewardSha === after.stewardSha, `${before.stewardSha?.slice(0, 12) ?? "absent"} -> ${after.stewardSha?.slice(0, 12) ?? "absent"}`),
|
|
256
|
+
check("the policy audit file bytes are unchanged", before.auditSha === after.auditSha, `${before.auditSha?.slice(0, 12) ?? "absent"} -> ${after.auditSha?.slice(0, 12) ?? "absent"}`),
|
|
257
|
+
]
|
|
258
|
+
}
|
|
259
|
+
|
|
247
260
|
// ---- orchestration (all I/O is behind `host`) --------------------------------------------------------------------
|
|
248
261
|
|
|
249
262
|
export async function runCase(host, testCase, { plant } = {}) {
|
|
@@ -288,6 +301,7 @@ export async function runSuite(host, { cases = CASES.map((entry) => entry.id), p
|
|
|
288
301
|
const selected = CASES.filter((entry) => cases.includes(entry.id))
|
|
289
302
|
await host.waitReady?.()
|
|
290
303
|
const runStart = host.now()
|
|
304
|
+
const baseline = await host.observe()
|
|
291
305
|
const results = []
|
|
292
306
|
host.openWindow(windowMinutes)
|
|
293
307
|
try {
|
|
@@ -309,7 +323,9 @@ export async function runSuite(host, { cases = CASES.map((entry) => entry.id), p
|
|
|
309
323
|
const clean = readable && leaks.length === 0
|
|
310
324
|
const detail = readable ? [...leaks.map((leak) => `${leak.key} (${leak.reason})`), ...info.map((entry) => `info: ${entry.key} (${entry.reason})`)].join(", ") : "state/telegram/effects is not readable, so the absence of a leak cannot be shown"
|
|
311
325
|
const telegram = { id: "no-telegram", status: clean ? "pass" : "fail", checks: [check("no owner-notice Telegram effect attributable to the replay run was recorded", clean, detail)] }
|
|
312
|
-
const
|
|
326
|
+
const policyChecks = ownerPolicyUntouched(baseline, finalObservation)
|
|
327
|
+
const policy = { id: "owner-policy-untouched", status: policyChecks.every((entry) => entry.ok) ? "pass" : "fail", checks: policyChecks }
|
|
328
|
+
const all = [...results, policy, telegram]
|
|
313
329
|
const failed = all.filter((entry) => entry.status === "fail").map((entry) => entry.id)
|
|
314
330
|
return { results: all, summary: { ok: failed.length === 0, passed: all.filter((entry) => entry.status === "pass").length, skipped: all.filter((entry) => entry.status === "skipped").length, failed } }
|
|
315
331
|
}
|
|
@@ -378,6 +394,11 @@ export function selfTest() {
|
|
|
378
394
|
if (pass !== (kind === "pass")) problems.push(`${testCase.id}: the ${kind} fixture ${pass ? "passed" : "failed"} (${checks.filter((entry) => !entry.ok).map((entry) => entry.name).join("; ")})`)
|
|
379
395
|
}
|
|
380
396
|
}
|
|
397
|
+
const same = ownerPolicyUntouched(emptyObservation({ auditSha: "x" }), emptyObservation({ auditSha: "x" }))
|
|
398
|
+
if (!same.every((entry) => entry.ok)) problems.push("owner-policy-untouched failed on identical policy bytes")
|
|
399
|
+
for (const [name, changed] of [["steward.json", { stewardSha: "bbb" }], ["audit file", { auditSha: "y" }]]) {
|
|
400
|
+
if (ownerPolicyUntouched(emptyObservation({ auditSha: "x" }), emptyObservation({ auditSha: "x", ...changed })).every((entry) => entry.ok)) problems.push(`owner-policy-untouched passed with different ${name} bytes`)
|
|
401
|
+
}
|
|
381
402
|
const stalled = { trackedDownloadStatus: "warning" }
|
|
382
403
|
if (CASES.find((entry) => entry.id === "stall-kept").applicable(emptyObservation({ queue: [{ id: 1 }] })) === null) problems.push("stall-kept ran with no stalled item")
|
|
383
404
|
if (CASES.find((entry) => entry.id === "stall-kept").applicable(emptyObservation({ queue: [stalled] })) !== null) problems.push("stall-kept skipped with a stalled item")
|
|
@@ -470,6 +491,7 @@ export function makeHost({ bundle = DEFAULT_BUNDLE, cardUrl, log = console.error
|
|
|
470
491
|
},
|
|
471
492
|
async observe() {
|
|
472
493
|
const policy = path.join(state, "policy", "steward.json")
|
|
494
|
+
const audit = path.join(state, "policy", "policy-audit.ndjson")
|
|
473
495
|
const ledger = path.join(bundle, "books", "ledger.ndjson")
|
|
474
496
|
let queue = null
|
|
475
497
|
try {
|
|
@@ -486,6 +508,7 @@ export function makeHost({ bundle = DEFAULT_BUNDLE, cardUrl, log = console.error
|
|
|
486
508
|
})
|
|
487
509
|
return {
|
|
488
510
|
stewardSha: existsSync(policy) ? sha256(readFileSync(policy)) : null,
|
|
511
|
+
auditSha: existsSync(audit) ? sha256(readFileSync(audit)) : null,
|
|
489
512
|
ledgerLines: readLines(ledger).length,
|
|
490
513
|
queue,
|
|
491
514
|
containers,
|
|
@@ -535,6 +558,7 @@ export function provision({ bundle = DEFAULT_BUNDLE, cardUrl, log = console.log,
|
|
|
535
558
|
const sink = path.join(replayDir, "notices.ndjson")
|
|
536
559
|
writeFileSync(sink, "", { flag: "a", mode: 0o600 }); chownSync(sink, owner.uid, owner.gid)
|
|
537
560
|
mkdirSync(clientDir, { recursive: true, mode: 0o700 }); chownSync(clientDir, owner.uid, owner.gid)
|
|
561
|
+
const replayIdentities = {}
|
|
538
562
|
const previous = existsSync(path.join(clientDir, "provision.json")) ? JSON.parse(readFileSync(path.join(clientDir, "provision.json"), "utf8")) : {}
|
|
539
563
|
const resolvedCard = cardUrl ?? previous.cardUrl ?? discover()
|
|
540
564
|
const out = { cardUrl: resolvedCard }
|
|
@@ -554,8 +578,15 @@ export function provision({ bundle = DEFAULT_BUNDLE, cardUrl, log = console.log,
|
|
|
554
578
|
if (grant && record.delegationGrant?.scope !== "principal_commands") grantPrincipalCommands(file)
|
|
555
579
|
if (!grant && record.delegationGrant) throw new Error(`${name} must not hold a delegation grant`)
|
|
556
580
|
out[who] = { friendId, did, containerIdentityFile, hostIdentity }
|
|
581
|
+
replayIdentities[friendId] = { name, who }
|
|
557
582
|
log(`${name}: friend ${friendId} (${trust}${grant ? ", principal_commands" : ", no grant"})`)
|
|
558
583
|
}
|
|
584
|
+
// Permanent marker, root-owned and never cleared with the window: the Butler refuses every owner-policy write from these friends (src/a2a/replay-harness.ts isReplayIdentity).
|
|
585
|
+
const registry = path.join(replayDir, "identities.json")
|
|
586
|
+
let known = {}
|
|
587
|
+
try { known = JSON.parse(readFileSync(registry, "utf8")).friends ?? {} } catch { /* first provision */ }
|
|
588
|
+
writeAtomic(registry, JSON.stringify({ friends: { ...known, ...replayIdentities } }, null, 2), 0o644)
|
|
589
|
+
chownSync(registry, rootUid, rootGid)
|
|
559
590
|
writeAtomic(path.join(clientDir, "provision.json"), JSON.stringify(out, null, 2), 0o644)
|
|
560
591
|
log(`provisioned; card ${resolvedCard}`)
|
|
561
592
|
return out
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<?xml version="1.0"?>
|
|
2
2
|
<Container version="2">
|
|
3
3
|
<Name>ouro-butler</Name>
|
|
4
|
-
<Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.
|
|
4
|
+
<Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.874</Repository>
|
|
5
5
|
<Registry>https://github.com/ourostack/ouroboros/pkgs/container/ouroboros-butler</Registry>
|
|
6
6
|
<Network>host</Network>
|
|
7
7
|
<Shell>sh</Shell>
|
|
@@ -36,6 +36,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
36
36
|
exports.REPLAY_WINDOW_MAX_MS = void 0;
|
|
37
37
|
exports.replayWindowPath = replayWindowPath;
|
|
38
38
|
exports.replaySinkPath = replaySinkPath;
|
|
39
|
+
exports.replayIdentitiesPath = replayIdentitiesPath;
|
|
40
|
+
exports.isReplayIdentity = isReplayIdentity;
|
|
39
41
|
exports.isReplayWindowOpen = isReplayWindowOpen;
|
|
40
42
|
exports.appendReplayNotice = appendReplayNotice;
|
|
41
43
|
exports.replayNoticeRecorded = replayNoticeRecorded;
|
|
@@ -62,6 +64,30 @@ function replayWindowPath(agentRoot) {
|
|
|
62
64
|
function replaySinkPath(agentRoot) {
|
|
63
65
|
return path.join(replayDir(agentRoot), "notices.ndjson");
|
|
64
66
|
}
|
|
67
|
+
/** Permanent registry of replay identities, written by the host's provisioning and never cleared. */
|
|
68
|
+
function replayIdentitiesPath(agentRoot) {
|
|
69
|
+
return path.join(replayDir(agentRoot), "identities.json");
|
|
70
|
+
}
|
|
71
|
+
function listsFriend(file, friendId) {
|
|
72
|
+
let parsed;
|
|
73
|
+
try {
|
|
74
|
+
parsed = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
// A missing file lists nobody; a file that exists but cannot be read or parsed fails closed.
|
|
78
|
+
return error.code !== "ENOENT";
|
|
79
|
+
}
|
|
80
|
+
const friends = parsed?.friends;
|
|
81
|
+
return !!friends && typeof friends === "object" && Object.hasOwn(friends, friendId);
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* True when `friendId` is a replay identity: listed in the permanent registry, or holding any window entry (open or
|
|
85
|
+
* expired). Unlike the window check this needs no trusted-owner proof, because the marker only ever restricts: forging
|
|
86
|
+
* an entry can block a friend from writing owner policy, never grant anything. Owner policy writes refuse these identities.
|
|
87
|
+
*/
|
|
88
|
+
function isReplayIdentity(agentRoot, friendId) {
|
|
89
|
+
return listsFriend(replayIdentitiesPath(agentRoot), friendId) || listsFriend(replayWindowPath(agentRoot), friendId);
|
|
90
|
+
}
|
|
65
91
|
/** A root-controlled directory: a real directory (no symlink), owned by the trusted uid, writable by neither group nor other. */
|
|
66
92
|
function isTrustedDirectory(target, trustedUid) {
|
|
67
93
|
try {
|
|
@@ -34,6 +34,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
36
|
exports.readStewardPolicy = readStewardPolicy;
|
|
37
|
+
exports.assertNotReplayIdentity = assertNotReplayIdentity;
|
|
37
38
|
exports.updateStewardPolicy = updateStewardPolicy;
|
|
38
39
|
exports.readRoutineActionReceipts = readRoutineActionReceipts;
|
|
39
40
|
exports.inspectRoutineActionGrant = inspectRoutineActionGrant;
|
|
@@ -47,6 +48,7 @@ const path = __importStar(require("node:path"));
|
|
|
47
48
|
const node_crypto_1 = require("node:crypto");
|
|
48
49
|
const node_util_1 = require("node:util");
|
|
49
50
|
const runtime_1 = require("../nerves/runtime");
|
|
51
|
+
const replay_harness_1 = require("../a2a/replay-harness");
|
|
50
52
|
const session_transaction_1 = require("../mind/session-transaction");
|
|
51
53
|
const EMPTY_POLICY = { schemaVersion: 1, version: 0, desiredStates: {}, routineActionGrants: {}, updatedAt: null };
|
|
52
54
|
const MAX_POLICY_BYTES = 1024 * 1024;
|
|
@@ -82,8 +84,8 @@ function delegation(value) {
|
|
|
82
84
|
&& text(value.delegateFriendId) && text(value.delegateDid) && text(value.noticeId);
|
|
83
85
|
}
|
|
84
86
|
function desiredEntry(value, version) {
|
|
85
|
-
return record(value) && exactKeys(value, ["value", "provenance", "version", "source"], ["expiresAt"])
|
|
86
|
-
&& text(value.value) && text(value.source) && positiveInteger(value.version) && value.version <= version
|
|
87
|
+
return record(value) && exactKeys(value, ["value", "provenance", "version", "source"], ["correction", "expiresAt"])
|
|
88
|
+
&& text(value.value) && text(value.source) && (value.correction === undefined || text(value.correction)) && positiveInteger(value.version) && value.version <= version
|
|
87
89
|
&& (value.provenance === "stated" || value.provenance === "observed" || value.provenance === "default")
|
|
88
90
|
&& (value.expiresAt === undefined || canonicalTime(value.expiresAt));
|
|
89
91
|
}
|
|
@@ -131,14 +133,14 @@ function operationIdentity(issuer, event, requestId, kind, key) {
|
|
|
131
133
|
}
|
|
132
134
|
function auditRow(value) {
|
|
133
135
|
if (!record(value) || !exactKeys(value, ["schemaVersion", "transactionId", "precedingBytesSha256", "mutationKind", "key", "mutationFingerprint", "affectedKeyResult", "affectedKeyResultSha256", "issuer", "authorizingSessionEvent", "authorization", "preimage", "preimageVersion", "preimageSha256", "postimage", "postimageVersion", "postimageSha256", "at"], ["delegatedVia"])
|
|
134
|
-
|| value.schemaVersion !== 2 || (value.mutationKind !== "set_desired_state" && value.mutationKind !== "grant_routine_action")
|
|
136
|
+
|| value.schemaVersion !== 2 || (value.mutationKind !== "set_desired_state" && value.mutationKind !== "grant_routine_action" && value.mutationKind !== "correct_provenance")
|
|
135
137
|
|| !text(value.key) || !text(value.issuer) || !text(value.authorizingSessionEvent) || !ownerAuthorization(value.authorization)
|
|
136
138
|
|| (value.delegatedVia !== undefined && !delegation(value.delegatedVia))
|
|
137
139
|
|| typeof value.preimage !== "string" || typeof value.postimage !== "string" || !canonicalTime(value.at))
|
|
138
140
|
return false;
|
|
139
141
|
const before = policyImage(value.preimage);
|
|
140
142
|
const after = policyImage(value.postimage);
|
|
141
|
-
const result = value.mutationKind === "
|
|
143
|
+
const result = value.mutationKind === "grant_routine_action" ? after.routineActionGrants[value.key] : after.desiredStates[value.key];
|
|
142
144
|
if (!result || value.preimageVersion !== before.version || value.postimageVersion !== after.version
|
|
143
145
|
|| after.version !== before.version + 1 || result.version !== after.version || after.updatedAt !== value.at
|
|
144
146
|
|| value.preimageSha256 !== sha256(value.preimage) || value.postimageSha256 !== sha256(value.postimage)
|
|
@@ -149,9 +151,17 @@ function auditRow(value) {
|
|
|
149
151
|
return false;
|
|
150
152
|
if ("action" in result && (result.issuer !== value.issuer || result.authorizingSessionEvent !== value.authorizingSessionEvent || result.authorizedAt !== value.at))
|
|
151
153
|
return false;
|
|
154
|
+
if (value.mutationKind === "correct_provenance") {
|
|
155
|
+
// A correction changes the source and records its note on one existing entry, and nothing else about it.
|
|
156
|
+
// A missing prior entry spreads to nothing, so the comparison fails without a separate branch.
|
|
157
|
+
const prior = before.desiredStates[value.key];
|
|
158
|
+
const corrected = result;
|
|
159
|
+
if (!(0, node_util_1.isDeepStrictEqual)(result, { ...prior, source: corrected.source, correction: corrected.correction, version: after.version }))
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
152
162
|
const expected = {
|
|
153
163
|
...before, version: after.version, updatedAt: value.at,
|
|
154
|
-
desiredStates: value.mutationKind
|
|
164
|
+
desiredStates: value.mutationKind !== "grant_routine_action" ? { ...before.desiredStates, [value.key]: result } : before.desiredStates,
|
|
155
165
|
routineActionGrants: value.mutationKind === "grant_routine_action" ? { ...before.routineActionGrants, [value.key]: result } : before.routineActionGrants,
|
|
156
166
|
};
|
|
157
167
|
return (0, node_util_1.isDeepStrictEqual)(after, expected);
|
|
@@ -312,7 +322,13 @@ function readPolicyState(agentRoot) {
|
|
|
312
322
|
function readStewardPolicy(agentRoot) {
|
|
313
323
|
return readPolicyState(agentRoot).policy;
|
|
314
324
|
}
|
|
325
|
+
/** Replay identities (the gate's test peers) can never write owner policy: the marker comes from the friend ids, never from model text. */
|
|
326
|
+
function assertNotReplayIdentity(agentRoot, friendIds) {
|
|
327
|
+
if (friendIds.some((friendId) => friendId !== undefined && (0, replay_harness_1.isReplayIdentity)(agentRoot, friendId)))
|
|
328
|
+
throw new Error("replay identities cannot write owner policy");
|
|
329
|
+
}
|
|
315
330
|
function updateStewardPolicy(agentRoot, input) {
|
|
331
|
+
assertNotReplayIdentity(agentRoot, [input.actor.friendId, input.actor.delegatedVia?.delegateFriendId]);
|
|
316
332
|
if (input.actor.trustLevel !== "family")
|
|
317
333
|
throw new Error("steward policy mutation requires family authority");
|
|
318
334
|
const authorizingSessionEvent = requireText(input.actor.sessionEventId, "authorizing session event");
|
|
@@ -322,7 +338,7 @@ function updateStewardPolicy(agentRoot, input) {
|
|
|
322
338
|
throw new Error("steward policy mutation requires current owner authorization");
|
|
323
339
|
if (input.actor.delegatedVia !== undefined && !delegation(input.actor.delegatedVia))
|
|
324
340
|
throw new Error("steward policy delegation is invalid");
|
|
325
|
-
if (input.mutation.kind !== "set_desired_state" && input.mutation.kind !== "grant_routine_action")
|
|
341
|
+
if (input.mutation.kind !== "set_desired_state" && input.mutation.kind !== "grant_routine_action" && input.mutation.kind !== "correct_provenance")
|
|
326
342
|
throw new Error("steward policy mutation kind is invalid");
|
|
327
343
|
return (0, session_transaction_1.withImmediateSessionTurnLease)(policyPath(agentRoot), (lease) => {
|
|
328
344
|
const snapshot = readAuditedPolicy(agentRoot, lease);
|
|
@@ -330,14 +346,21 @@ function updateStewardPolicy(agentRoot, input) {
|
|
|
330
346
|
const now = input.now ?? new Date().toISOString();
|
|
331
347
|
if (!canonicalTime(now))
|
|
332
348
|
throw new Error("policy update time must be canonical");
|
|
333
|
-
const expiresAt = optionalExpiry(input.mutation.expiresAt);
|
|
349
|
+
const expiresAt = input.mutation.kind === "correct_provenance" ? undefined : optionalExpiry(input.mutation.expiresAt);
|
|
334
350
|
const version = current.version + 1;
|
|
335
351
|
if (!positiveInteger(version))
|
|
336
352
|
throw new Error("steward policy version is exhausted");
|
|
337
353
|
const next = { ...current, version, desiredStates: { ...current.desiredStates }, routineActionGrants: { ...current.routineActionGrants }, updatedAt: now };
|
|
338
|
-
const key = requireText(input.mutation.key, input.mutation.kind === "
|
|
354
|
+
const key = requireText(input.mutation.key, input.mutation.kind === "grant_routine_action" ? "routine action key" : "desired state key");
|
|
339
355
|
let affectedKeyResult;
|
|
340
|
-
if (input.mutation.kind === "
|
|
356
|
+
if (input.mutation.kind === "correct_provenance") {
|
|
357
|
+
const existing = current.desiredStates[key];
|
|
358
|
+
if (!existing)
|
|
359
|
+
throw new Error("provenance correction requires an existing desired state key");
|
|
360
|
+
affectedKeyResult = { ...existing, source: requireText(input.mutation.source, "corrected source"), correction: requireText(input.mutation.correction, "correction"), version };
|
|
361
|
+
next.desiredStates = { ...current.desiredStates, [key]: affectedKeyResult };
|
|
362
|
+
}
|
|
363
|
+
else if (input.mutation.kind === "set_desired_state") {
|
|
341
364
|
if (input.mutation.provenance !== "stated" && input.mutation.provenance !== "observed" && input.mutation.provenance !== "default")
|
|
342
365
|
throw new Error("desired state provenance is invalid");
|
|
343
366
|
affectedKeyResult = {
|
|
@@ -373,7 +396,7 @@ function updateStewardPolicy(agentRoot, input) {
|
|
|
373
396
|
const previousIndex = snapshot.rows.findIndex((row) => operationIdentity(row.issuer, row.authorizingSessionEvent, row.authorization.requestId, row.mutationKind, row.key) === identity);
|
|
374
397
|
if (previousIndex >= 0) {
|
|
375
398
|
const previous = snapshot.rows[previousIndex];
|
|
376
|
-
const currentResult = previous.mutationKind === "
|
|
399
|
+
const currentResult = previous.mutationKind === "grant_routine_action" ? current.routineActionGrants[key] : current.desiredStates[key];
|
|
377
400
|
if (previous.mutationFingerprint !== fingerprint || snapshot.rows.slice(previousIndex + 1).some((row) => row.mutationKind === previous.mutationKind && row.key === key)
|
|
378
401
|
|| !(0, node_util_1.isDeepStrictEqual)(currentResult, previous.affectedKeyResult))
|
|
379
402
|
throw new Error("steward policy transaction replay changed");
|
|
@@ -442,8 +465,8 @@ function currentRequester(value, target) {
|
|
|
442
465
|
&& text(value.event.recordPath) && text(value.event.agent) && positiveInteger(value.event.generation)
|
|
443
466
|
&& text(value.event.observationRevision) && text(value.event.claimOwner);
|
|
444
467
|
}
|
|
445
|
-
function appliedEntry(rows,
|
|
446
|
-
return rows.some((row) => row.mutationKind
|
|
468
|
+
function appliedEntry(rows, kinds, key, entry) {
|
|
469
|
+
return rows.some((row) => kinds.includes(row.mutationKind) && row.key === key && row.postimageVersion === entry.version && (0, node_util_1.isDeepStrictEqual)(row.affectedKeyResult, entry));
|
|
447
470
|
}
|
|
448
471
|
const UNRESOLVED_ACTION_STATES = new Set(["reserved", "attempting", "effect_acknowledged", "recovery_pending", "indeterminate"]);
|
|
449
472
|
function inspectRoutineActionGrantSnapshot(policy, rows, receipts, input) {
|
|
@@ -472,7 +495,7 @@ function inspectRoutineActionGrantSnapshot(policy, rows, receipts, input) {
|
|
|
472
495
|
return { allowed: false, reason: "routine action grant is missing", ...fallback };
|
|
473
496
|
if (grant.provenance !== "stated")
|
|
474
497
|
return { allowed: false, reason: "routine action grant must be owner-stated" };
|
|
475
|
-
if (!appliedEntry(rows, "grant_routine_action", input.key, grant))
|
|
498
|
+
if (!appliedEntry(rows, ["grant_routine_action"], input.key, grant))
|
|
476
499
|
return { allowed: false, reason: "routine action grant has no applied owner authorization" };
|
|
477
500
|
if (grant.action !== input.action)
|
|
478
501
|
return { allowed: false, reason: "routine action does not match the grant" };
|
|
@@ -480,7 +503,7 @@ function inspectRoutineActionGrantSnapshot(policy, rows, receipts, input) {
|
|
|
480
503
|
return { allowed: false, reason: "routine action target is not authorized" };
|
|
481
504
|
if (grant.expiresAt && Date.parse(grant.expiresAt) <= Date.parse(now))
|
|
482
505
|
return { allowed: false, reason: "routine action grant expired", ...fallback };
|
|
483
|
-
if (!activeDesired || desired.provenance !== "stated" || !appliedEntry(rows, "set_desired_state", desiredKey, desired))
|
|
506
|
+
if (!activeDesired || desired.provenance !== "stated" || !appliedEntry(rows, ["set_desired_state", "correct_provenance"], desiredKey, desired))
|
|
484
507
|
return { allowed: false, reason: "container has no active applied owner-stated desired state" };
|
|
485
508
|
if (!["on", "always_on", "expected_on"].includes(desiredValue) && !(desiredValue === "on_demand" && input.requester.kind !== "owner_event"))
|
|
486
509
|
return { allowed: false, reason: "container desired state does not authorize this request" };
|
|
@@ -16,16 +16,17 @@ exports.stewardPolicyToolDefinition = {
|
|
|
16
16
|
type: "function",
|
|
17
17
|
function: {
|
|
18
18
|
name: "steward_policy_manage",
|
|
19
|
-
description: "Read or update the household steward's typed desired-state and routine-action policy. Updates require the current authenticated owner Telegram request and fresh authorization. expectedVersion is optional; omit it to apply against the current policy version, or supply a version from read for explicit stale-write detection.",
|
|
19
|
+
description: "Read or update the household steward's typed desired-state and routine-action policy. Updates require the current authenticated owner Telegram request and fresh authorization. Replay identities can never update it. correct_provenance (key, source, correction) is only for fixing a wrong attribution on an existing desired state: it replaces the recorded source and records the correction note, never changes the value, and leaves an audit row; do not use it to change a value. expectedVersion is optional; omit it to apply against the current policy version, or supply a version from read for explicit stale-write detection.",
|
|
20
20
|
parameters: {
|
|
21
21
|
type: "object",
|
|
22
22
|
properties: {
|
|
23
|
-
action: { type: "string", enum: ["read", "set_desired_state", "grant_routine_action"] },
|
|
23
|
+
action: { type: "string", enum: ["read", "set_desired_state", "grant_routine_action", "correct_provenance"] },
|
|
24
24
|
expectedVersion: { type: "integer", minimum: 0, description: "Optional version returned by read. When omitted, the runtime uses the current policy version." },
|
|
25
25
|
key: { type: "string" },
|
|
26
26
|
value: { type: "string" },
|
|
27
27
|
provenance: { type: "string", enum: ["stated", "observed", "default", "installed_explicit_policy"] },
|
|
28
|
-
source: { type: "string" },
|
|
28
|
+
source: { type: "string", description: "Who or what stated this. For correct_provenance, the corrected attribution that replaces the recorded source." },
|
|
29
|
+
correction: { type: "string", description: "correct_provenance only: required note naming what is being corrected and why." },
|
|
29
30
|
routineAction: { type: "string" },
|
|
30
31
|
targetsJson: { type: "string", description: "JSON array of exact target names" },
|
|
31
32
|
maxCount: { type: "integer", minimum: 1 },
|
|
@@ -54,6 +55,7 @@ exports.stewardPolicyToolDefinition = {
|
|
|
54
55
|
throw new Error("steward policy mutation requires authenticated relationship authority");
|
|
55
56
|
const { friendId, trustLevel, sessionEventId } = actor;
|
|
56
57
|
const { profileId, requestId } = relationship;
|
|
58
|
+
(0, steward_policy_1.assertNotReplayIdentity)(ctx.agentRoot, [friendId, ctx.delegatedCommand?.delegateFriendId]);
|
|
57
59
|
const sessionKey = ctx.currentSession?.key;
|
|
58
60
|
const delegated = ctx.delegatedCommand;
|
|
59
61
|
// Two ways to hold owner authority: the owner's own Telegram turn, or a delegated
|
|
@@ -82,6 +84,9 @@ exports.stewardPolicyToolDefinition = {
|
|
|
82
84
|
throw new Error("desired state provenance is invalid");
|
|
83
85
|
mutation = { kind: "set_desired_state", key: args.key ?? "", value: args.value ?? "", provenance: args.provenance, source: sourceFor(args.source), ...(args.expiresAt ? { expiresAt: args.expiresAt } : {}) };
|
|
84
86
|
}
|
|
87
|
+
else if (args.action === "correct_provenance") {
|
|
88
|
+
mutation = { kind: "correct_provenance", key: args.key ?? "", source: sourceFor(args.source), correction: args.correction ?? "" };
|
|
89
|
+
}
|
|
85
90
|
else if (args.action === "grant_routine_action") {
|
|
86
91
|
if (args.provenance !== "stated" && args.provenance !== "installed_explicit_policy")
|
|
87
92
|
throw new Error("routine action provenance is invalid");
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ouro.bot/cli",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.874",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@ouro.bot/cli",
|
|
9
|
-
"version": "0.1.0-alpha.
|
|
9
|
+
"version": "0.1.0-alpha.874",
|
|
10
10
|
"dependencies": {
|
|
11
11
|
"@anthropic-ai/sdk": "^0.78.0",
|
|
12
12
|
"@azure/identity": "^4.13.0",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ouro.bot/cli",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.874",
|
|
4
4
|
"engines": {
|
|
5
5
|
"node": ">=22"
|
|
6
6
|
},
|
|
@@ -46,6 +46,7 @@
|
|
|
46
46
|
"lint": "eslint src/",
|
|
47
47
|
"package:verify-assets": "node scripts/package-assets.cjs",
|
|
48
48
|
"prepack": "npm run build && npm run package:verify-assets",
|
|
49
|
+
"butler:ship": "node scripts/butler-ship.mjs",
|
|
49
50
|
"release:bump": "node scripts/release-bump.cjs",
|
|
50
51
|
"release:preflight": "node scripts/release-preflight.cjs",
|
|
51
52
|
"release:trust:check": "node scripts/npm-trusted-publishers.cjs check",
|