@ouro.bot/cli 0.1.0-alpha.814 → 0.1.0-alpha.816
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 +15 -0
- package/deploy/unraid/README.txt +25 -305
- package/deploy/unraid/sanctuary.ouro/bundle-meta.json +1 -1
- package/deploy/unraid/sanctuary.xml +1 -1
- package/dist/heart/approval-store.js +11 -1
- package/dist/heart/daemon/container-spec-auditor-main.js +3 -3
- package/dist/heart/daemon/container-spec-auditor.js +3 -17
- package/dist/heart/external-events/router.js +31 -15
- package/dist/heart/steward-policy.js +374 -58
- package/dist/heart/tool-approval.js +8 -1
- package/dist/repertoire/relationship-authorization.js +128 -0
- package/dist/repertoire/tools-base.js +9 -13
- package/dist/repertoire/tools-steward-policy.js +34 -10
- package/dist/repertoire/tools-unraid.js +79 -32
- package/dist/repertoire/tools.js +22 -19
- package/dist/repertoire/unraid-restart.js +179 -52
- package/dist/senses/private-runtime.js +27 -12
- package/dist/senses/sanctuary-health-runner.js +0 -1
- package/dist/senses/sanctuary-interactive-control.js +160 -56
- package/dist/senses/sanctuary-media-catalog-contract.js +4 -1
- package/dist/senses/sanctuary-runtime.js +2 -0
- package/dist/senses/telegram-approval-runtime.js +130 -12
- package/dist/senses/telegram.js +30 -21
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
|
@@ -38,14 +38,119 @@ exports.updateStewardPolicy = updateStewardPolicy;
|
|
|
38
38
|
exports.readRoutineActionReceipts = readRoutineActionReceipts;
|
|
39
39
|
exports.inspectRoutineActionGrant = inspectRoutineActionGrant;
|
|
40
40
|
exports.consumeRoutineActionGrant = consumeRoutineActionGrant;
|
|
41
|
+
exports.withStewardPolicyLease = withStewardPolicyLease;
|
|
42
|
+
exports.withRoutineActionAttempt = withRoutineActionAttempt;
|
|
41
43
|
exports.transitionRoutineActionReceipt = transitionRoutineActionReceipt;
|
|
42
44
|
exports.recoverRoutineActionReceipts = recoverRoutineActionReceipts;
|
|
43
45
|
const fs = __importStar(require("node:fs"));
|
|
44
46
|
const path = __importStar(require("node:path"));
|
|
45
47
|
const node_crypto_1 = require("node:crypto");
|
|
48
|
+
const node_util_1 = require("node:util");
|
|
46
49
|
const runtime_1 = require("../nerves/runtime");
|
|
47
50
|
const session_transaction_1 = require("../mind/session-transaction");
|
|
48
51
|
const EMPTY_POLICY = { schemaVersion: 1, version: 0, desiredStates: {}, routineActionGrants: {}, updatedAt: null };
|
|
52
|
+
const MAX_POLICY_BYTES = 1024 * 1024;
|
|
53
|
+
const MAX_AUDIT_ROW_BYTES = 16 * MAX_POLICY_BYTES;
|
|
54
|
+
function sha256(bytes) {
|
|
55
|
+
return (0, node_crypto_1.createHash)("sha256").update(bytes, "utf8").digest("hex");
|
|
56
|
+
}
|
|
57
|
+
function record(value) {
|
|
58
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
59
|
+
}
|
|
60
|
+
function exactKeys(value, required, optional = []) {
|
|
61
|
+
return required.every((key) => Object.hasOwn(value, key)) && Object.keys(value).every((key) => required.includes(key) || optional.includes(key));
|
|
62
|
+
}
|
|
63
|
+
function text(value) {
|
|
64
|
+
return typeof value === "string" && value.length > 0 && value.trim() === value;
|
|
65
|
+
}
|
|
66
|
+
function canonicalTime(value) {
|
|
67
|
+
return typeof value === "string" && Number.isFinite(Date.parse(value)) && new Date(Date.parse(value)).toISOString() === value;
|
|
68
|
+
}
|
|
69
|
+
function positiveInteger(value) {
|
|
70
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value > 0;
|
|
71
|
+
}
|
|
72
|
+
function textArray(value) {
|
|
73
|
+
return Array.isArray(value) && value.every(text) && new Set(value).size === value.length;
|
|
74
|
+
}
|
|
75
|
+
function ownerAuthorization(value) {
|
|
76
|
+
return record(value) && exactKeys(value, ["profileId", "profileVersion", "requestId", "sessionKey", "receiptId"])
|
|
77
|
+
&& value.profileId === "sanctuary-owner" && positiveInteger(value.profileVersion)
|
|
78
|
+
&& text(value.requestId) && text(value.sessionKey) && text(value.receiptId);
|
|
79
|
+
}
|
|
80
|
+
function desiredEntry(value, version) {
|
|
81
|
+
return record(value) && exactKeys(value, ["value", "provenance", "version", "source"], ["expiresAt"])
|
|
82
|
+
&& text(value.value) && text(value.source) && positiveInteger(value.version) && value.version <= version
|
|
83
|
+
&& (value.provenance === "stated" || value.provenance === "observed" || value.provenance === "default")
|
|
84
|
+
&& (value.expiresAt === undefined || canonicalTime(value.expiresAt));
|
|
85
|
+
}
|
|
86
|
+
function grantEntry(value, version) {
|
|
87
|
+
return record(value) && exactKeys(value, ["action", "targets", "maxCount", "windowMs", "verificationRequired", "exclusions", "provenance", "issuer", "authorizedAt", "authorizingSessionEvent", "version"], ["expiresAt"])
|
|
88
|
+
&& text(value.action) && textArray(value.targets) && value.targets.length > 0 && textArray(value.exclusions)
|
|
89
|
+
&& positiveInteger(value.maxCount) && positiveInteger(value.windowMs) && value.verificationRequired === true
|
|
90
|
+
&& (value.provenance === "stated" || value.provenance === "installed_explicit_policy")
|
|
91
|
+
&& text(value.issuer) && text(value.authorizingSessionEvent) && canonicalTime(value.authorizedAt)
|
|
92
|
+
&& positiveInteger(value.version) && value.version <= version && (value.expiresAt === undefined || canonicalTime(value.expiresAt));
|
|
93
|
+
}
|
|
94
|
+
function policyRecord(value) {
|
|
95
|
+
if (!record(value) || !exactKeys(value, ["schemaVersion", "version", "desiredStates", "routineActionGrants", "updatedAt"])
|
|
96
|
+
|| value.schemaVersion !== 1 || typeof value.version !== "number" || !Number.isSafeInteger(value.version) || value.version < 0
|
|
97
|
+
|| !record(value.desiredStates) || !record(value.routineActionGrants)
|
|
98
|
+
|| !(value.updatedAt === null ? value.version === 0 : canonicalTime(value.updatedAt)))
|
|
99
|
+
return false;
|
|
100
|
+
const version = value.version;
|
|
101
|
+
return Object.entries(value.desiredStates).every(([key, entry]) => text(key) && desiredEntry(entry, version))
|
|
102
|
+
&& Object.entries(value.routineActionGrants).every(([key, entry]) => text(key) && grantEntry(entry, version));
|
|
103
|
+
}
|
|
104
|
+
function policyImage(bytes) {
|
|
105
|
+
if (Buffer.byteLength(bytes) > MAX_POLICY_BYTES)
|
|
106
|
+
throw new Error("steward policy audit image exceeds its bound");
|
|
107
|
+
if (bytes === "")
|
|
108
|
+
return structuredClone(EMPTY_POLICY);
|
|
109
|
+
let value;
|
|
110
|
+
try {
|
|
111
|
+
value = JSON.parse(bytes);
|
|
112
|
+
}
|
|
113
|
+
catch (cause) {
|
|
114
|
+
throw new Error("steward policy audit image is invalid", { cause });
|
|
115
|
+
}
|
|
116
|
+
if (!policyRecord(value))
|
|
117
|
+
throw new Error("steward policy audit image is invalid");
|
|
118
|
+
return value;
|
|
119
|
+
}
|
|
120
|
+
function mutationFingerprint(kind, key, result) {
|
|
121
|
+
const generated = ["version", "issuer", "authorizedAt", "authorizingSessionEvent"];
|
|
122
|
+
const input = Object.fromEntries(Object.entries(result).filter(([field]) => !generated.includes(field)).sort(([left], [right]) => left.localeCompare(right)));
|
|
123
|
+
return sha256(JSON.stringify([kind, key, input]));
|
|
124
|
+
}
|
|
125
|
+
function operationIdentity(issuer, event, requestId, kind, key) {
|
|
126
|
+
return JSON.stringify([issuer, event, requestId, kind, key]);
|
|
127
|
+
}
|
|
128
|
+
function auditRow(value) {
|
|
129
|
+
if (!record(value) || !exactKeys(value, ["schemaVersion", "transactionId", "precedingBytesSha256", "mutationKind", "key", "mutationFingerprint", "affectedKeyResult", "affectedKeyResultSha256", "issuer", "authorizingSessionEvent", "authorization", "preimage", "preimageVersion", "preimageSha256", "postimage", "postimageVersion", "postimageSha256", "at"])
|
|
130
|
+
|| value.schemaVersion !== 2 || (value.mutationKind !== "set_desired_state" && value.mutationKind !== "grant_routine_action")
|
|
131
|
+
|| !text(value.key) || !text(value.issuer) || !text(value.authorizingSessionEvent) || !ownerAuthorization(value.authorization)
|
|
132
|
+
|| typeof value.preimage !== "string" || typeof value.postimage !== "string" || !canonicalTime(value.at))
|
|
133
|
+
return false;
|
|
134
|
+
const before = policyImage(value.preimage);
|
|
135
|
+
const after = policyImage(value.postimage);
|
|
136
|
+
const result = value.mutationKind === "set_desired_state" ? after.desiredStates[value.key] : after.routineActionGrants[value.key];
|
|
137
|
+
if (!result || value.preimageVersion !== before.version || value.postimageVersion !== after.version
|
|
138
|
+
|| after.version !== before.version + 1 || result.version !== after.version || after.updatedAt !== value.at
|
|
139
|
+
|| value.preimageSha256 !== sha256(value.preimage) || value.postimageSha256 !== sha256(value.postimage)
|
|
140
|
+
|| value.postimage !== JSON.stringify(after, null, 2) || !(0, node_util_1.isDeepStrictEqual)(value.affectedKeyResult, result)
|
|
141
|
+
|| value.affectedKeyResultSha256 !== sha256(JSON.stringify(result))
|
|
142
|
+
|| value.mutationFingerprint !== mutationFingerprint(value.mutationKind, value.key, result)
|
|
143
|
+
|| value.transactionId !== sha256(JSON.stringify([operationIdentity(value.issuer, value.authorizingSessionEvent, value.authorization.requestId, value.mutationKind, value.key), value.mutationFingerprint])))
|
|
144
|
+
return false;
|
|
145
|
+
if ("action" in result && (result.issuer !== value.issuer || result.authorizingSessionEvent !== value.authorizingSessionEvent || result.authorizedAt !== value.at))
|
|
146
|
+
return false;
|
|
147
|
+
const expected = {
|
|
148
|
+
...before, version: after.version, updatedAt: value.at,
|
|
149
|
+
desiredStates: value.mutationKind === "set_desired_state" ? { ...before.desiredStates, [value.key]: result } : before.desiredStates,
|
|
150
|
+
routineActionGrants: value.mutationKind === "grant_routine_action" ? { ...before.routineActionGrants, [value.key]: result } : before.routineActionGrants,
|
|
151
|
+
};
|
|
152
|
+
return (0, node_util_1.isDeepStrictEqual)(after, expected);
|
|
153
|
+
}
|
|
49
154
|
function policyDir(agentRoot) {
|
|
50
155
|
return path.join(agentRoot, "state", "policy");
|
|
51
156
|
}
|
|
@@ -62,6 +167,15 @@ function ensureDirectory(agentRoot) {
|
|
|
62
167
|
fs.mkdirSync(policyDir(agentRoot), { recursive: true, mode: 0o700 });
|
|
63
168
|
fs.chmodSync(policyDir(agentRoot), 0o700);
|
|
64
169
|
}
|
|
170
|
+
function syncParentDirectory(filePath) {
|
|
171
|
+
const directory = fs.openSync(path.dirname(filePath), fs.constants.O_RDONLY | fs.constants.O_DIRECTORY | fs.constants.O_NOFOLLOW);
|
|
172
|
+
try {
|
|
173
|
+
fs.fsyncSync(directory);
|
|
174
|
+
}
|
|
175
|
+
finally {
|
|
176
|
+
fs.closeSync(directory);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
65
179
|
function appendReceipt(filePath, value) {
|
|
66
180
|
const creating = !fs.existsSync(filePath);
|
|
67
181
|
const fd = fs.openSync(filePath, "a", 0o600);
|
|
@@ -73,18 +187,11 @@ function appendReceipt(filePath, value) {
|
|
|
73
187
|
fs.closeSync(fd);
|
|
74
188
|
}
|
|
75
189
|
fs.chmodSync(filePath, 0o600);
|
|
76
|
-
if (creating)
|
|
77
|
-
|
|
78
|
-
try {
|
|
79
|
-
fs.fsyncSync(directory);
|
|
80
|
-
}
|
|
81
|
-
finally {
|
|
82
|
-
fs.closeSync(directory);
|
|
83
|
-
}
|
|
84
|
-
}
|
|
190
|
+
if (creating)
|
|
191
|
+
syncParentDirectory(filePath);
|
|
85
192
|
}
|
|
86
193
|
function requireText(value, label) {
|
|
87
|
-
const result = value.trim();
|
|
194
|
+
const result = typeof value === "string" ? value.trim() : "";
|
|
88
195
|
if (!result)
|
|
89
196
|
throw new Error(`${label} must be nonempty`);
|
|
90
197
|
return result;
|
|
@@ -93,67 +200,198 @@ function optionalExpiry(value, now) {
|
|
|
93
200
|
if (!value)
|
|
94
201
|
return undefined;
|
|
95
202
|
const epoch = Date.parse(value);
|
|
96
|
-
if (!Number.isFinite(epoch) || new Date(epoch).toISOString() !== value || epoch <= Date.parse(now))
|
|
203
|
+
if (!Number.isFinite(epoch) || new Date(epoch).toISOString() !== value || (now !== undefined && epoch <= Date.parse(now)))
|
|
97
204
|
throw new Error("policy expiry must be a future canonical timestamp");
|
|
98
205
|
return value;
|
|
99
206
|
}
|
|
100
207
|
function validateStewardPolicy(value) {
|
|
101
|
-
if (!
|
|
102
|
-
throw new Error("steward policy is invalid");
|
|
103
|
-
const candidate = value;
|
|
104
|
-
if (candidate.schemaVersion !== 1 || !Number.isInteger(candidate.version) || candidate.version < 0 || typeof candidate.desiredStates !== "object" || !candidate.desiredStates || typeof candidate.routineActionGrants !== "object" || !candidate.routineActionGrants) {
|
|
208
|
+
if (!policyRecord(value))
|
|
105
209
|
throw new Error("steward policy is invalid");
|
|
210
|
+
return value;
|
|
211
|
+
}
|
|
212
|
+
function readAuditBytes(agentRoot) {
|
|
213
|
+
try {
|
|
214
|
+
const bytes = fs.readFileSync(auditPath(agentRoot));
|
|
215
|
+
const decoded = bytes.toString("utf8");
|
|
216
|
+
if (!Buffer.from(decoded, "utf8").equals(bytes))
|
|
217
|
+
throw new Error("steward policy audit encoding is invalid");
|
|
218
|
+
return decoded;
|
|
219
|
+
}
|
|
220
|
+
catch (error) {
|
|
221
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT")
|
|
222
|
+
return "";
|
|
223
|
+
throw error;
|
|
106
224
|
}
|
|
107
|
-
return candidate;
|
|
108
225
|
}
|
|
109
|
-
function
|
|
226
|
+
function readPolicyTransaction(filePath, lease) {
|
|
227
|
+
const exists = fs.existsSync(filePath);
|
|
228
|
+
if (exists && fs.statSync(filePath).size > MAX_POLICY_BYTES)
|
|
229
|
+
throw new Error("steward policy exceeds its bound");
|
|
230
|
+
const snapshot = (0, session_transaction_1.readSessionTransaction)(filePath, lease);
|
|
231
|
+
const raw = exists ? fs.readFileSync(filePath) : Buffer.alloc(0);
|
|
232
|
+
if (!Buffer.from(snapshot.bytes, "utf8").equals(raw))
|
|
233
|
+
throw new Error("steward policy encoding or bytes changed");
|
|
234
|
+
return { ...snapshot, exists };
|
|
235
|
+
}
|
|
236
|
+
function readAuditedPolicy(agentRoot, lease) {
|
|
110
237
|
const filePath = policyPath(agentRoot);
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
238
|
+
let snapshot = readPolicyTransaction(filePath, lease);
|
|
239
|
+
let policy = !snapshot.exists && snapshot.bytes === "" ? structuredClone(EMPTY_POLICY) : validateStewardPolicy(snapshot.value);
|
|
240
|
+
const auditBytes = readAuditBytes(agentRoot);
|
|
241
|
+
const rows = [];
|
|
242
|
+
const seen = new Set();
|
|
243
|
+
const preceding = (0, node_crypto_1.createHash)("sha256");
|
|
244
|
+
let legacyVersion = 0;
|
|
245
|
+
if (auditBytes && !auditBytes.endsWith("\n"))
|
|
246
|
+
throw new Error("steward policy audit has a partial final row");
|
|
247
|
+
for (const line of auditBytes ? auditBytes.slice(0, -1).split("\n") : []) {
|
|
248
|
+
if (Buffer.byteLength(line) > MAX_AUDIT_ROW_BYTES)
|
|
249
|
+
throw new Error("steward policy audit row exceeds its bound");
|
|
250
|
+
let row;
|
|
251
|
+
try {
|
|
252
|
+
row = JSON.parse(line);
|
|
253
|
+
}
|
|
254
|
+
catch (cause) {
|
|
255
|
+
throw new Error("steward policy audit row is invalid", { cause });
|
|
256
|
+
}
|
|
257
|
+
if (record(row) && row.schemaVersion === 1) {
|
|
258
|
+
if (rows.length || !positiveInteger(row.policyVersion) || row.policyVersion <= legacyVersion)
|
|
259
|
+
throw new Error("steward policy audit legacy prefix is invalid");
|
|
260
|
+
legacyVersion = row.policyVersion;
|
|
261
|
+
}
|
|
262
|
+
else {
|
|
263
|
+
if (!auditRow(row) || row.precedingBytesSha256 !== preceding.copy().digest("hex"))
|
|
264
|
+
throw new Error("steward policy audit row is invalid");
|
|
265
|
+
const prior = rows.at(-1);
|
|
266
|
+
if (prior ? row.preimage !== prior.postimage || row.preimageVersion !== prior.postimageVersion : row.preimageVersion !== legacyVersion)
|
|
267
|
+
throw new Error("steward policy audit chain is invalid");
|
|
268
|
+
const identity = operationIdentity(row.issuer, row.authorizingSessionEvent, row.authorization.requestId, row.mutationKind, row.key);
|
|
269
|
+
if (seen.has(identity))
|
|
270
|
+
throw new Error("steward policy audit contains a duplicate transaction");
|
|
271
|
+
seen.add(identity);
|
|
272
|
+
rows.push(row);
|
|
273
|
+
}
|
|
274
|
+
preceding.update(`${line}\n`, "utf8");
|
|
275
|
+
}
|
|
276
|
+
const last = rows.at(-1);
|
|
277
|
+
if (!last) {
|
|
278
|
+
if (policy.version !== legacyVersion)
|
|
279
|
+
throw new Error("steward policy audit does not match the policy head");
|
|
280
|
+
}
|
|
281
|
+
else if (snapshot.bytes !== last.postimage) {
|
|
282
|
+
if (snapshot.bytes !== last.preimage)
|
|
283
|
+
throw new Error("steward policy audit does not match the policy head");
|
|
284
|
+
const fd = fs.openSync(auditPath(agentRoot), "r");
|
|
285
|
+
try {
|
|
286
|
+
fs.fsyncSync(fd);
|
|
287
|
+
}
|
|
288
|
+
finally {
|
|
289
|
+
fs.closeSync(fd);
|
|
290
|
+
}
|
|
291
|
+
syncParentDirectory(auditPath(agentRoot));
|
|
292
|
+
(0, session_transaction_1.writeSessionTransaction)(filePath, policyImage(last.postimage), { lease, expectedRevision: snapshot.revision });
|
|
293
|
+
snapshot = readPolicyTransaction(filePath, lease);
|
|
294
|
+
if (snapshot.bytes !== last.postimage)
|
|
295
|
+
throw new Error("steward policy audit recovery readback differs");
|
|
296
|
+
policy = validateStewardPolicy(snapshot.value);
|
|
297
|
+
(0, runtime_1.emitNervesEvent)({ component: "heart", event: "heart.steward_policy_recovered", message: "recovered an authorized steward policy write", meta: { version: policy.version } });
|
|
298
|
+
}
|
|
299
|
+
return { ...snapshot, policy, rows, auditBytes };
|
|
300
|
+
}
|
|
301
|
+
function readPolicyState(agentRoot) {
|
|
302
|
+
if (!fs.existsSync(policyPath(agentRoot)) && !fs.existsSync(auditPath(agentRoot))) {
|
|
303
|
+
return { policy: structuredClone(EMPTY_POLICY), rows: [] };
|
|
304
|
+
}
|
|
305
|
+
return (0, session_transaction_1.withImmediateSessionTurnLease)(policyPath(agentRoot), (lease) => readAuditedPolicy(agentRoot, lease));
|
|
306
|
+
}
|
|
307
|
+
function readStewardPolicy(agentRoot) {
|
|
308
|
+
return readPolicyState(agentRoot).policy;
|
|
114
309
|
}
|
|
115
310
|
function updateStewardPolicy(agentRoot, input) {
|
|
116
311
|
if (input.actor.trustLevel !== "family")
|
|
117
312
|
throw new Error("steward policy mutation requires family authority");
|
|
118
313
|
const authorizingSessionEvent = requireText(input.actor.sessionEventId, "authorizing session event");
|
|
119
314
|
const issuer = requireText(input.actor.friendId, "issuer");
|
|
315
|
+
const authorization = input.actor.authorization;
|
|
316
|
+
if (!ownerAuthorization(authorization))
|
|
317
|
+
throw new Error("steward policy mutation requires current owner authorization");
|
|
318
|
+
if (input.mutation.kind !== "set_desired_state" && input.mutation.kind !== "grant_routine_action")
|
|
319
|
+
throw new Error("steward policy mutation kind is invalid");
|
|
120
320
|
return (0, session_transaction_1.withImmediateSessionTurnLease)(policyPath(agentRoot), (lease) => {
|
|
121
|
-
const snapshot = (
|
|
122
|
-
const current = snapshot.
|
|
123
|
-
if (current.version !== input.expectedVersion)
|
|
124
|
-
throw new Error(`steward policy version changed: expected ${input.expectedVersion}, got ${current.version}`);
|
|
321
|
+
const snapshot = readAuditedPolicy(agentRoot, lease);
|
|
322
|
+
const current = snapshot.policy;
|
|
125
323
|
const now = input.now ?? new Date().toISOString();
|
|
126
|
-
|
|
324
|
+
if (!canonicalTime(now))
|
|
325
|
+
throw new Error("policy update time must be canonical");
|
|
326
|
+
const expiresAt = optionalExpiry(input.mutation.expiresAt);
|
|
127
327
|
const version = current.version + 1;
|
|
328
|
+
if (!positiveInteger(version))
|
|
329
|
+
throw new Error("steward policy version is exhausted");
|
|
128
330
|
const next = { ...current, version, desiredStates: { ...current.desiredStates }, routineActionGrants: { ...current.routineActionGrants }, updatedAt: now };
|
|
331
|
+
const key = requireText(input.mutation.key, input.mutation.kind === "set_desired_state" ? "desired state key" : "routine action key");
|
|
332
|
+
let affectedKeyResult;
|
|
129
333
|
if (input.mutation.kind === "set_desired_state") {
|
|
130
|
-
|
|
334
|
+
if (input.mutation.provenance !== "stated" && input.mutation.provenance !== "observed" && input.mutation.provenance !== "default")
|
|
335
|
+
throw new Error("desired state provenance is invalid");
|
|
336
|
+
affectedKeyResult = {
|
|
131
337
|
value: requireText(input.mutation.value, "desired state value"),
|
|
132
338
|
provenance: input.mutation.provenance,
|
|
133
339
|
version,
|
|
134
340
|
source: requireText(input.mutation.source, "desired state source"),
|
|
135
341
|
...(expiresAt ? { expiresAt } : {}),
|
|
136
342
|
};
|
|
343
|
+
next.desiredStates = { ...current.desiredStates, [key]: affectedKeyResult };
|
|
137
344
|
}
|
|
138
345
|
else {
|
|
139
346
|
if (input.mutation.provenance !== "stated" && input.mutation.provenance !== "installed_explicit_policy")
|
|
140
347
|
throw new Error("routine action grants require explicit authority");
|
|
141
|
-
if (!
|
|
348
|
+
if (!positiveInteger(input.mutation.maxCount) || !positiveInteger(input.mutation.windowMs))
|
|
142
349
|
throw new Error("routine action grant bounds are invalid");
|
|
143
|
-
if (
|
|
350
|
+
if (input.mutation.verificationRequired !== true)
|
|
144
351
|
throw new Error("routine action grants require post-action verification");
|
|
145
|
-
|
|
352
|
+
if (!Array.isArray(input.mutation.targets) || !Array.isArray(input.mutation.exclusions))
|
|
353
|
+
throw new Error("routine action targets and exclusions must be arrays");
|
|
354
|
+
const targets = [...new Set(input.mutation.targets.map((value) => requireText(value, "routine action target")))].sort();
|
|
146
355
|
if (targets.length === 0)
|
|
147
356
|
throw new Error("routine action grant requires a target");
|
|
148
|
-
|
|
357
|
+
affectedKeyResult = {
|
|
149
358
|
action: requireText(input.mutation.action, "routine action"), targets, maxCount: input.mutation.maxCount, windowMs: input.mutation.windowMs,
|
|
150
|
-
verificationRequired: input.mutation.verificationRequired, exclusions: [...new Set(input.mutation.exclusions)], provenance: input.mutation.provenance,
|
|
359
|
+
verificationRequired: input.mutation.verificationRequired, exclusions: [...new Set(input.mutation.exclusions.map((value) => requireText(value, "routine action exclusion")))].sort(), provenance: input.mutation.provenance,
|
|
151
360
|
issuer, authorizedAt: now, authorizingSessionEvent, version, ...(expiresAt ? { expiresAt } : {}),
|
|
152
361
|
};
|
|
362
|
+
next.routineActionGrants = { ...current.routineActionGrants, [key]: affectedKeyResult };
|
|
363
|
+
}
|
|
364
|
+
const fingerprint = mutationFingerprint(input.mutation.kind, key, affectedKeyResult);
|
|
365
|
+
const identity = operationIdentity(issuer, authorizingSessionEvent, authorization.requestId, input.mutation.kind, key);
|
|
366
|
+
const previousIndex = snapshot.rows.findIndex((row) => operationIdentity(row.issuer, row.authorizingSessionEvent, row.authorization.requestId, row.mutationKind, row.key) === identity);
|
|
367
|
+
if (previousIndex >= 0) {
|
|
368
|
+
const previous = snapshot.rows[previousIndex];
|
|
369
|
+
const currentResult = previous.mutationKind === "set_desired_state" ? current.desiredStates[key] : current.routineActionGrants[key];
|
|
370
|
+
if (previous.mutationFingerprint !== fingerprint || snapshot.rows.slice(previousIndex + 1).some((row) => row.mutationKind === previous.mutationKind && row.key === key)
|
|
371
|
+
|| !(0, node_util_1.isDeepStrictEqual)(currentResult, previous.affectedKeyResult))
|
|
372
|
+
throw new Error("steward policy transaction replay changed");
|
|
373
|
+
return current;
|
|
153
374
|
}
|
|
375
|
+
if (current.version !== input.expectedVersion)
|
|
376
|
+
throw new Error(`steward policy version changed: expected ${input.expectedVersion}, got ${current.version}`);
|
|
377
|
+
optionalExpiry(expiresAt, now);
|
|
378
|
+
const postimage = JSON.stringify(next, null, 2);
|
|
379
|
+
if (Buffer.byteLength(postimage) > MAX_POLICY_BYTES)
|
|
380
|
+
throw new Error("steward policy exceeds its bound");
|
|
381
|
+
const row = {
|
|
382
|
+
schemaVersion: 2, transactionId: sha256(JSON.stringify([identity, fingerprint])), precedingBytesSha256: sha256(snapshot.auditBytes),
|
|
383
|
+
mutationKind: input.mutation.kind, key, mutationFingerprint: fingerprint, affectedKeyResult, affectedKeyResultSha256: sha256(JSON.stringify(affectedKeyResult)),
|
|
384
|
+
issuer, authorizingSessionEvent, authorization: { ...authorization },
|
|
385
|
+
preimage: snapshot.bytes, preimageVersion: current.version, preimageSha256: snapshot.revision,
|
|
386
|
+
postimage, postimageVersion: version, postimageSha256: sha256(postimage), at: now,
|
|
387
|
+
};
|
|
388
|
+
if (Buffer.byteLength(JSON.stringify(row)) > MAX_AUDIT_ROW_BYTES)
|
|
389
|
+
throw new Error("steward policy audit row exceeds its bound");
|
|
154
390
|
ensureDirectory(agentRoot);
|
|
391
|
+
appendReceipt(auditPath(agentRoot), row);
|
|
155
392
|
(0, session_transaction_1.writeSessionTransaction)(policyPath(agentRoot), next, { lease, expectedRevision: snapshot.revision });
|
|
156
|
-
|
|
393
|
+
if (readPolicyTransaction(policyPath(agentRoot), lease).bytes !== postimage)
|
|
394
|
+
throw new Error("steward policy publication readback differs");
|
|
157
395
|
(0, runtime_1.emitNervesEvent)({ component: "heart", event: "heart.steward_policy_updated", message: "updated steward policy", meta: { version, mutationKind: input.mutation.kind, issuer } });
|
|
158
396
|
return next;
|
|
159
397
|
});
|
|
@@ -170,58 +408,106 @@ function readRoutineActionReceipts(agentRoot) {
|
|
|
170
408
|
latest.set(receipt.id, receipt);
|
|
171
409
|
return [...latest.values()];
|
|
172
410
|
}
|
|
173
|
-
function
|
|
174
|
-
|
|
175
|
-
if (!desired || (desired.expiresAt && Date.parse(desired.expiresAt) <= Date.parse(now)))
|
|
411
|
+
function currentRequester(value, target) {
|
|
412
|
+
if (!record(value) || !text(value.friendId))
|
|
176
413
|
return false;
|
|
177
|
-
|
|
414
|
+
if (value.kind === "owner" || value.kind === "household_request") {
|
|
415
|
+
return exactKeys(value, ["kind", "friendId", "profileId", "requestId", "sessionEventId", "origin"])
|
|
416
|
+
&& value.profileId === (value.kind === "owner" ? "sanctuary-owner" : "sanctuary-household")
|
|
417
|
+
&& text(value.requestId) && text(value.sessionEventId) && record(value.origin)
|
|
418
|
+
&& exactKeys(value.origin, ["friendId", "channel", "key"])
|
|
419
|
+
&& value.origin.friendId === value.friendId && value.origin.channel === "telegram" && text(value.origin.key);
|
|
420
|
+
}
|
|
421
|
+
return value.kind === "owner_event" && exactKeys(value, ["kind", "friendId", "profileId", "event", "target"])
|
|
422
|
+
&& value.profileId === "sanctuary-event" && record(value.target) && exactKeys(value.target, ["id", "name"])
|
|
423
|
+
&& text(value.target.id) && value.target.name === target && record(value.event)
|
|
424
|
+
&& exactKeys(value.event, ["schemaVersion", "recordPath", "agent", "source", "eventId", "generation", "observationRevision", "claimOwner"])
|
|
425
|
+
&& value.event.schemaVersion === 1 && value.event.source === "sanctuary-health"
|
|
426
|
+
&& value.event.eventId === `container:${value.target.id}:availability`
|
|
427
|
+
&& text(value.event.recordPath) && text(value.event.agent) && positiveInteger(value.event.generation)
|
|
428
|
+
&& text(value.event.observationRevision) && text(value.event.claimOwner);
|
|
429
|
+
}
|
|
430
|
+
function appliedEntry(rows, kind, key, entry) {
|
|
431
|
+
return rows.some((row) => row.mutationKind === kind && row.key === key && row.postimageVersion === entry.version && (0, node_util_1.isDeepStrictEqual)(row.affectedKeyResult, entry));
|
|
178
432
|
}
|
|
179
433
|
const UNRESOLVED_ACTION_STATES = new Set(["reserved", "attempting", "effect_acknowledged", "recovery_pending", "indeterminate"]);
|
|
180
|
-
function inspectRoutineActionGrantSnapshot(policy, receipts, input) {
|
|
434
|
+
function inspectRoutineActionGrantSnapshot(policy, rows, receipts, input) {
|
|
435
|
+
const now = input.now === undefined ? new Date().toISOString() : input.now;
|
|
436
|
+
if (!canonicalTime(now))
|
|
437
|
+
return { allowed: false, reason: "routine action time must be canonical" };
|
|
438
|
+
if (!text(input.key) || !text(input.action) || !text(input.target))
|
|
439
|
+
return { allowed: false, reason: "routine action requires an exact key, action, and target" };
|
|
181
440
|
if (input.expectedPolicyVersion !== undefined && policy.version !== input.expectedPolicyVersion)
|
|
182
441
|
return { allowed: false, reason: "routine action policy version changed" };
|
|
442
|
+
if (!currentRequester(input.requester, input.target) || !positiveInteger(input.authorizationVersion))
|
|
443
|
+
return { allowed: false, reason: "routine action requires a current versioned requester" };
|
|
444
|
+
const desiredKey = `container:${input.target}`;
|
|
445
|
+
const desired = policy.desiredStates[desiredKey];
|
|
446
|
+
if (input.expectedDesiredStateVersion !== undefined && desired?.version !== input.expectedDesiredStateVersion)
|
|
447
|
+
return { allowed: false, reason: "routine action desired state version changed" };
|
|
448
|
+
const activeDesired = desired && (desired.expiresAt === undefined || Date.parse(desired.expiresAt) > Date.parse(now));
|
|
449
|
+
const desiredValue = desired?.value.toLowerCase();
|
|
450
|
+
if (activeDesired && /^(?:off|disabled|paused|intentionally_off|intentionally_paused)$/u.test(desiredValue))
|
|
451
|
+
return { allowed: false, reason: "container is expected off" };
|
|
183
452
|
const grant = policy.routineActionGrants[input.key];
|
|
453
|
+
if (input.expectedGrantVersion !== undefined && grant?.version !== input.expectedGrantVersion)
|
|
454
|
+
return { allowed: false, reason: "routine action grant version changed" };
|
|
455
|
+
const fallback = input.requester.kind === "owner" ? { approvalFallback: true } : {};
|
|
184
456
|
if (!grant)
|
|
185
|
-
return { allowed: false, reason: "routine action grant is missing" };
|
|
457
|
+
return { allowed: false, reason: "routine action grant is missing", ...fallback };
|
|
458
|
+
if (grant.provenance !== "stated")
|
|
459
|
+
return { allowed: false, reason: "routine action grant must be owner-stated" };
|
|
460
|
+
if (!appliedEntry(rows, "grant_routine_action", input.key, grant))
|
|
461
|
+
return { allowed: false, reason: "routine action grant has no applied owner authorization" };
|
|
186
462
|
if (grant.action !== input.action)
|
|
187
463
|
return { allowed: false, reason: "routine action does not match the grant" };
|
|
188
464
|
if (!grant.targets.includes(input.target) || grant.exclusions.includes(input.target))
|
|
189
465
|
return { allowed: false, reason: "routine action target is not authorized" };
|
|
190
|
-
const now = input.now ?? new Date().toISOString();
|
|
191
466
|
if (grant.expiresAt && Date.parse(grant.expiresAt) <= Date.parse(now))
|
|
192
|
-
return { allowed: false, reason: "routine action grant expired" };
|
|
193
|
-
if (
|
|
194
|
-
return { allowed: false, reason: "container
|
|
467
|
+
return { allowed: false, reason: "routine action grant expired", ...fallback };
|
|
468
|
+
if (!activeDesired || desired.provenance !== "stated" || !appliedEntry(rows, "set_desired_state", desiredKey, desired))
|
|
469
|
+
return { allowed: false, reason: "container has no active applied owner-stated desired state" };
|
|
470
|
+
if (!["on", "always_on", "expected_on"].includes(desiredValue) && !(desiredValue === "on_demand" && input.requester.kind !== "owner_event"))
|
|
471
|
+
return { allowed: false, reason: "container desired state does not authorize this request" };
|
|
195
472
|
if (receipts.some((receipt) => receipt.action === input.action && receipt.target === input.target && UNRESOLVED_ACTION_STATES.has(receipt.state))) {
|
|
196
473
|
return { allowed: false, reason: "routine action has an unresolved receipt for this target" };
|
|
197
474
|
}
|
|
198
|
-
|
|
475
|
+
const windowStart = Date.parse(now) - grant.windowMs;
|
|
476
|
+
if (receipts.filter((receipt) => receipt.key === input.key && Date.parse(receipt.reservedAt) > windowStart).length >= grant.maxCount)
|
|
477
|
+
return { allowed: false, reason: "routine action rate limit reached" };
|
|
478
|
+
return { allowed: true, policyVersion: policy.version, desiredStateVersion: desired.version, grantVersion: grant.version, key: input.key, action: grant.action, target: input.target, requester: input.requester, authorizationVersion: input.authorizationVersion };
|
|
199
479
|
}
|
|
200
480
|
function inspectRoutineActionGrant(agentRoot, input) {
|
|
201
481
|
try {
|
|
202
|
-
const policy =
|
|
203
|
-
return inspectRoutineActionGrantSnapshot(policy, readRoutineActionReceipts(agentRoot), input);
|
|
482
|
+
const { policy, rows } = readPolicyState(agentRoot);
|
|
483
|
+
return inspectRoutineActionGrantSnapshot(policy, rows, readRoutineActionReceipts(agentRoot), input);
|
|
204
484
|
}
|
|
205
485
|
catch (error) {
|
|
206
486
|
return { allowed: false, reason: error instanceof Error ? error.message : "routine action policy is unavailable" };
|
|
207
487
|
}
|
|
208
488
|
}
|
|
209
489
|
function consumeRoutineActionGrant(agentRoot, input) {
|
|
490
|
+
if (!text(input.authorizationReceiptId))
|
|
491
|
+
throw new Error("routine action requires a current authorization receipt");
|
|
492
|
+
const authorizationReceiptId = input.authorizationReceiptId;
|
|
210
493
|
return (0, session_transaction_1.withImmediateSessionTurnLease)(policyPath(agentRoot), (lease) => {
|
|
211
|
-
const snapshot = (
|
|
212
|
-
const policy = snapshot.
|
|
494
|
+
const snapshot = readAuditedPolicy(agentRoot, lease);
|
|
495
|
+
const policy = snapshot.policy;
|
|
213
496
|
const grant = policy.routineActionGrants[input.key];
|
|
214
|
-
const now = input.now
|
|
215
|
-
const action = input.action
|
|
497
|
+
const now = input.now === undefined ? new Date().toISOString() : input.now;
|
|
498
|
+
const action = input.action === undefined ? grant?.action ?? "" : input.action;
|
|
216
499
|
const receipts = readRoutineActionReceipts(agentRoot);
|
|
217
|
-
const decision = inspectRoutineActionGrantSnapshot(policy, receipts, {
|
|
500
|
+
const decision = inspectRoutineActionGrantSnapshot(policy, snapshot.rows, receipts, { ...input, action, now });
|
|
218
501
|
if (!decision.allowed)
|
|
219
502
|
throw new Error(decision.reason);
|
|
503
|
+
const resolvedTarget = input.resolvedTarget === undefined
|
|
504
|
+
? decision.requester.kind === "owner_event" ? decision.requester.target : { id: "unresolved", name: input.target }
|
|
505
|
+
: input.resolvedTarget;
|
|
506
|
+
if (!record(resolvedTarget) || !text(resolvedTarget.id) || resolvedTarget.name !== input.target)
|
|
507
|
+
throw new Error("routine action resolved target is invalid");
|
|
508
|
+
if (decision.requester.kind === "owner_event" && resolvedTarget.id !== decision.requester.target.id)
|
|
509
|
+
throw new Error("routine action event target binding changed");
|
|
220
510
|
ensureDirectory(agentRoot);
|
|
221
|
-
const windowStart = Date.parse(now) - grant.windowMs;
|
|
222
|
-
const used = receipts.filter((receipt) => receipt.key === input.key && Date.parse(receipt.reservedAt) > windowStart).length;
|
|
223
|
-
if (used >= grant.maxCount)
|
|
224
|
-
throw new Error("routine action rate limit reached");
|
|
225
511
|
const id = `action-${(0, node_crypto_1.randomUUID)()}`;
|
|
226
512
|
const receipt = {
|
|
227
513
|
schemaVersion: 2,
|
|
@@ -232,15 +518,17 @@ function consumeRoutineActionGrant(agentRoot, input) {
|
|
|
232
518
|
target: input.target,
|
|
233
519
|
policyVersion: policy.version,
|
|
234
520
|
grantVersion: grant.version,
|
|
521
|
+
desiredStateVersion: decision.desiredStateVersion,
|
|
522
|
+
requester: structuredClone(decision.requester),
|
|
235
523
|
reservedAt: now,
|
|
236
524
|
updatedAt: now,
|
|
237
|
-
authorizationReceiptId
|
|
238
|
-
authorizationVersion:
|
|
525
|
+
authorizationReceiptId,
|
|
526
|
+
authorizationVersion: decision.authorizationVersion,
|
|
239
527
|
attemptId: input.attemptId ?? `attempt-${(0, node_crypto_1.randomUUID)()}`,
|
|
240
528
|
attempt: 1,
|
|
241
529
|
expectedBeforeState: input.expectedBeforeState ?? null,
|
|
242
|
-
resolvedTarget:
|
|
243
|
-
effect: input.effect ?? { operation: action, targetId:
|
|
530
|
+
resolvedTarget: { id: resolvedTarget.id, name: resolvedTarget.name },
|
|
531
|
+
effect: input.effect ?? { operation: action, targetId: resolvedTarget.id },
|
|
244
532
|
effectReceipt: null,
|
|
245
533
|
verifiedAfterState: null,
|
|
246
534
|
recoveryState: { state: "not_needed", compensation: "none" },
|
|
@@ -250,6 +538,34 @@ function consumeRoutineActionGrant(agentRoot, input) {
|
|
|
250
538
|
return receipt;
|
|
251
539
|
});
|
|
252
540
|
}
|
|
541
|
+
function withStewardPolicyLease(agentRoot, operation) {
|
|
542
|
+
return (0, session_transaction_1.withSessionTurnLease)(policyPath(agentRoot), operation);
|
|
543
|
+
}
|
|
544
|
+
async function withRoutineActionAttempt(agentRoot, reservation, validate, attempt) {
|
|
545
|
+
await withStewardPolicyLease(agentRoot, async (lease) => {
|
|
546
|
+
await validate();
|
|
547
|
+
const snapshot = readAuditedPolicy(agentRoot, lease);
|
|
548
|
+
const receipts = readRoutineActionReceipts(agentRoot);
|
|
549
|
+
const current = receipts.find((receipt) => receipt.id === reservation.id);
|
|
550
|
+
if (!current || current.state !== "reserved" || !(0, node_util_1.isDeepStrictEqual)(current, reservation))
|
|
551
|
+
throw new Error("routine action reservation changed");
|
|
552
|
+
if (!reservation.requester || reservation.desiredStateVersion === undefined)
|
|
553
|
+
throw new Error("routine action reservation has no current requester binding");
|
|
554
|
+
const now = new Date().toISOString();
|
|
555
|
+
const decision = inspectRoutineActionGrantSnapshot(snapshot.policy, snapshot.rows, receipts.filter((receipt) => receipt.id !== reservation.id), {
|
|
556
|
+
key: reservation.key, action: reservation.action, target: reservation.target, requester: reservation.requester,
|
|
557
|
+
authorizationVersion: reservation.authorizationVersion, expectedPolicyVersion: reservation.policyVersion,
|
|
558
|
+
expectedDesiredStateVersion: reservation.desiredStateVersion, expectedGrantVersion: reservation.grantVersion, now,
|
|
559
|
+
});
|
|
560
|
+
if (!decision.allowed)
|
|
561
|
+
throw new Error(decision.reason);
|
|
562
|
+
const reservedAt = Date.parse(reservation.reservedAt);
|
|
563
|
+
const windowStart = Date.parse(now) - snapshot.policy.routineActionGrants[reservation.key].windowMs;
|
|
564
|
+
if (!(reservedAt > windowStart && reservedAt <= Date.parse(now)))
|
|
565
|
+
throw new Error("routine action reservation is outside its rate window");
|
|
566
|
+
await attempt();
|
|
567
|
+
});
|
|
568
|
+
}
|
|
253
569
|
function transitionRoutineActionReceipt(agentRoot, input) {
|
|
254
570
|
return (0, session_transaction_1.withImmediateSessionTurnLease)(policyPath(agentRoot), () => {
|
|
255
571
|
const current = readRoutineActionReceipts(agentRoot).find((receipt) => receipt.id === input.id);
|
|
@@ -283,7 +283,14 @@ async function executeApprovalDecision(options) {
|
|
|
283
283
|
if (decided.state !== "claimed")
|
|
284
284
|
return decided;
|
|
285
285
|
await options.hooks?.afterClaim?.();
|
|
286
|
-
|
|
286
|
+
let currentSessionRevision;
|
|
287
|
+
try {
|
|
288
|
+
currentSessionRevision = typeof options.currentSessionRevision === "function" ? options.currentSessionRevision() : options.currentSessionRevision;
|
|
289
|
+
}
|
|
290
|
+
catch {
|
|
291
|
+
return terminalizeClaimed(options.approvalStore, decided, "drifted", "current session revision is unavailable");
|
|
292
|
+
}
|
|
293
|
+
if (currentSessionRevision !== decided.suspendedSessionRevision) {
|
|
287
294
|
return terminalizeClaimed(options.approvalStore, decided, "session_head_changed", "suspended session revision changed");
|
|
288
295
|
}
|
|
289
296
|
const checkpoint = options.checkpointStore.read(decided.approvalId);
|