@ouro.bot/cli 0.1.0-alpha.816 → 0.1.0-alpha.818
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/assets/sanctuary-host-launcher.sh +16 -0
- package/changelog.json +16 -0
- package/deploy/unraid/Dockerfile +6 -0
- package/deploy/unraid/README.txt +296 -62
- package/deploy/unraid/docker-man-template-transaction.mjs +192 -7
- package/deploy/unraid/sanctuary-acceptance-adapter.sh +1 -1
- package/deploy/unraid/sanctuary-acceptance-contract.json +7 -7
- package/deploy/unraid/sanctuary-authority-installation.json +36 -0
- package/deploy/unraid/sanctuary-authority-service.sh +30 -0
- package/deploy/unraid/sanctuary-unit16-host-broker.mjs +62 -9
- package/deploy/unraid/sanctuary-unit16-run.sh +15 -15
- package/deploy/unraid/sanctuary.ouro/bundle-meta.json +1 -1
- package/deploy/unraid/sanctuary.ouro/tool-profiles.json +2 -2
- package/deploy/unraid/sanctuary.xml +2 -1
- package/dist/heart/core.js +2 -1
- package/dist/heart/daemon/container-spec-auditor-main.js +8 -7
- package/dist/heart/daemon/container-spec-auditor.js +16 -9
- package/dist/heart/daemon/sanctuary-acceptance-adapter.js +95 -91
- package/dist/heart/daemon/sanctuary-acceptance-harness.js +95 -169
- package/dist/heart/daemon/sanctuary-acceptance-scenarios.js +4 -5
- package/dist/heart/daemon/sanctuary-authority-codec.js +86 -0
- package/dist/heart/daemon/sanctuary-authority-epoch.js +256 -0
- package/dist/heart/daemon/sanctuary-authority-installation.js +140 -0
- package/dist/heart/daemon/sanctuary-authority-ledger.js +241 -0
- package/dist/heart/daemon/sanctuary-authority-root-lifecycle.js +780 -0
- package/dist/heart/daemon/sanctuary-authority-vault-migration.js +79 -0
- package/dist/heart/daemon/sanctuary-host-authority.js +1008 -0
- package/dist/heart/daemon/sanctuary-host-detached-supervisor.js +494 -0
- package/dist/heart/daemon/sanctuary-host-executor.js +670 -0
- package/dist/heart/daemon/sanctuary-host-linux-kernel.js +334 -0
- package/dist/heart/daemon/sanctuary-host-supervisor-entry.js +207 -0
- package/dist/heart/daemon/sanctuary-host-supervisor.js +95 -0
- package/dist/heart/daemon/sanctuary-telegram-authority-entry.js +445 -0
- package/dist/heart/daemon/sanctuary-telegram-authority-gateway.js +585 -0
- package/dist/heart/daemon/sanctuary-telegram-authority-service.js +615 -0
- package/dist/heart/daemon/sense-manager.js +20 -10
- package/dist/repertoire/tools-sanctuary-host.js +202 -0
- package/dist/repertoire/tools.js +15 -6
- package/dist/senses/private-runtime.js +2 -2
- package/dist/senses/root-host-approval-port.js +345 -0
- package/dist/senses/root-host-approval-runtime.js +393 -0
- package/dist/senses/sanctuary-authority-resident.js +102 -0
- package/dist/senses/telegram-admission.js +7 -0
- package/dist/senses/telegram-attachments.js +5 -1
- package/dist/senses/telegram-authority-transport.js +231 -0
- package/dist/senses/telegram-client.js +31 -8
- package/dist/senses/telegram-entry.js +1 -1
- package/dist/senses/telegram.js +168 -28
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
|
@@ -0,0 +1,1008 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.FileSanctuaryHostAuthority = void 0;
|
|
37
|
+
exports.hasHostInlineCodeSwitch = hasHostInlineCodeSwitch;
|
|
38
|
+
exports.validateHostProposalRequest = validateHostProposalRequest;
|
|
39
|
+
exports.hostProposalCommitment = hostProposalCommitment;
|
|
40
|
+
exports.sanctuaryHostAuthorityStatePath = sanctuaryHostAuthorityStatePath;
|
|
41
|
+
const node_crypto_1 = require("node:crypto");
|
|
42
|
+
const fs = __importStar(require("node:fs"));
|
|
43
|
+
const path = __importStar(require("node:path"));
|
|
44
|
+
const runtime_1 = require("../../nerves/runtime");
|
|
45
|
+
const session_transaction_1 = require("../../mind/session-transaction");
|
|
46
|
+
const sanctuary_authority_codec_1 = require("./sanctuary-authority-codec");
|
|
47
|
+
const SCHEMA_VERSION = 1;
|
|
48
|
+
const PROPOSAL_DOMAIN = "ouro.sanctuary.host-proposal.v1";
|
|
49
|
+
const REGISTRATION_DOMAIN = "ouro.sanctuary.host-registration.v1";
|
|
50
|
+
const DECISION_DOMAIN = "ouro.sanctuary.host-decision.v1";
|
|
51
|
+
const PERMIT_DOMAIN = "ouro.sanctuary.host-permit.v1";
|
|
52
|
+
const RECEIPT_DOMAIN = "ouro.sanctuary.host-receipt.v1";
|
|
53
|
+
const RETIREMENT_DOMAIN = "ouro.sanctuary.host-retirement.v1";
|
|
54
|
+
const ROOT_HOST_PREFIX = "<b>OURO ROOT HOST APPROVAL</b>";
|
|
55
|
+
const APPROVAL_TTL_MS = 300_000;
|
|
56
|
+
const MAX_TIMEOUT_MS = 900_000;
|
|
57
|
+
const MAX_SCRIPT_BYTES = 2_048;
|
|
58
|
+
const MAX_ARGUMENTS = 64;
|
|
59
|
+
const MAX_ARGUMENT_BYTES = 512;
|
|
60
|
+
const PRINTABLE_ASCII = /^[\x20-\x7e]+$/u;
|
|
61
|
+
const SCRIPT_BYTES = /^[\x0a\x20-\x7e]+$/u;
|
|
62
|
+
const DIGEST = /^sha256:[a-f0-9]{64}$/u;
|
|
63
|
+
const DECIMAL_ID = /^[1-9][0-9]*$/u;
|
|
64
|
+
const REGISTRATION_ID = /^hostreg-[A-Za-z0-9_-]{43}$/u;
|
|
65
|
+
const CALLBACK_ID = /^[\x20-\x7e]{1,256}$/u;
|
|
66
|
+
const INLINE_CODE_SWITCHES = new Set(["-c", "-e", "--eval", "--evaluate"]);
|
|
67
|
+
const CARD_STATUS = {
|
|
68
|
+
committed: "Awaiting owner decision.",
|
|
69
|
+
approved: "Approved. Root execution is pending.",
|
|
70
|
+
denied: "Denied. This approval is consumed and cannot be reused.",
|
|
71
|
+
expired: "Expired. This approval is consumed and cannot be reused.",
|
|
72
|
+
executed_verified: "Execution completed and verification succeeded.",
|
|
73
|
+
executed_failed: "Execution failed or was interrupted.",
|
|
74
|
+
executed_ambiguous: "Execution finished, but verification is ambiguous.",
|
|
75
|
+
retired: "Authority retired before execution. This permit is refused.",
|
|
76
|
+
};
|
|
77
|
+
function isObject(value) {
|
|
78
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
79
|
+
}
|
|
80
|
+
function exactKeys(value, keys) {
|
|
81
|
+
const actual = Object.keys(value).sort();
|
|
82
|
+
const expected = [...keys].sort();
|
|
83
|
+
return actual.length === expected.length && actual.every((key, index) => key === expected[index]);
|
|
84
|
+
}
|
|
85
|
+
function validTime(value) {
|
|
86
|
+
if (typeof value !== "string")
|
|
87
|
+
return false;
|
|
88
|
+
const instant = new Date(value);
|
|
89
|
+
return !Number.isNaN(instant.getTime()) && instant.toISOString() === value;
|
|
90
|
+
}
|
|
91
|
+
function printable(value, maximum) {
|
|
92
|
+
return typeof value === "string" && value.length > 0 && value.length <= maximum && PRINTABLE_ASCII.test(value);
|
|
93
|
+
}
|
|
94
|
+
function absoluteExecutable(value) {
|
|
95
|
+
return printable(value, 512)
|
|
96
|
+
&& path.posix.isAbsolute(value)
|
|
97
|
+
&& path.posix.normalize(value) === value
|
|
98
|
+
&& !value.includes("/../")
|
|
99
|
+
&& !value.endsWith("/..");
|
|
100
|
+
}
|
|
101
|
+
function validArguments(value) {
|
|
102
|
+
return Array.isArray(value)
|
|
103
|
+
&& value.length <= MAX_ARGUMENTS
|
|
104
|
+
&& value.every((argument) => printable(argument, MAX_ARGUMENT_BYTES));
|
|
105
|
+
}
|
|
106
|
+
function hasHostInlineCodeSwitch(arguments_, program) {
|
|
107
|
+
const name = typeof program === "string" ? path.posix.basename(program) : "";
|
|
108
|
+
return arguments_.some((argument) => INLINE_CODE_SWITCHES.has(argument)
|
|
109
|
+
|| [...INLINE_CODE_SWITCHES].some((flag) => argument.startsWith(`${flag}=`))
|
|
110
|
+
|| /^-[A-Za-z]*c/u.test(argument)
|
|
111
|
+
|| (/^(node|nodejs)$/u.test(name) && (/^-[A-Za-z]*[ep]/u.test(argument) || /^--print(?:=|$)/u.test(argument)))
|
|
112
|
+
|| (name === "perl" && /^-[A-Za-z]*[eE]/u.test(argument))
|
|
113
|
+
|| (name === "ruby" && /^-[A-Za-z]*e/u.test(argument)));
|
|
114
|
+
}
|
|
115
|
+
function validateCommand(value) {
|
|
116
|
+
if (!isObject(value) || typeof value.kind !== "string")
|
|
117
|
+
throw new Error("Sanctuary host command is invalid");
|
|
118
|
+
if (value.kind === "executable") {
|
|
119
|
+
if (!exactKeys(value, ["kind", "executable", "arguments"])
|
|
120
|
+
|| !absoluteExecutable(value.executable)
|
|
121
|
+
|| !validArguments(value.arguments)
|
|
122
|
+
|| hasHostInlineCodeSwitch(value.arguments, value.executable)) {
|
|
123
|
+
throw new Error("Sanctuary host executable command is invalid");
|
|
124
|
+
}
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (value.kind !== "script"
|
|
128
|
+
|| !exactKeys(value, ["kind", "interpreter", "arguments", "script"])
|
|
129
|
+
|| !absoluteExecutable(value.interpreter)
|
|
130
|
+
|| !validArguments(value.arguments)
|
|
131
|
+
|| hasHostInlineCodeSwitch(value.arguments, value.interpreter)
|
|
132
|
+
|| typeof value.script !== "string"
|
|
133
|
+
|| Buffer.byteLength(value.script, "utf8") === 0
|
|
134
|
+
|| Buffer.byteLength(value.script, "utf8") > MAX_SCRIPT_BYTES
|
|
135
|
+
|| !SCRIPT_BYTES.test(value.script)
|
|
136
|
+
|| value.script.split("\n").some((line) => line.endsWith(" "))) {
|
|
137
|
+
throw new Error("Sanctuary host script command is invalid");
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
function validateHostProposalRequest(value, expectedHost) {
|
|
141
|
+
if (!isObject(value) || !exactKeys(value, [
|
|
142
|
+
"targetHost",
|
|
143
|
+
"targetResource",
|
|
144
|
+
"command",
|
|
145
|
+
"workingDirectoryProfile",
|
|
146
|
+
"environmentProfile",
|
|
147
|
+
"timeoutMs",
|
|
148
|
+
"verification",
|
|
149
|
+
"ownerObservation",
|
|
150
|
+
])) {
|
|
151
|
+
throw new Error("Sanctuary host proposal is malformed");
|
|
152
|
+
}
|
|
153
|
+
if (value.targetHost !== expectedHost
|
|
154
|
+
|| !printable(value.targetResource, 256)
|
|
155
|
+
|| value.workingDirectoryProfile !== "host.root.v1"
|
|
156
|
+
|| value.environmentProfile !== "host.clean.v1"
|
|
157
|
+
|| !Number.isSafeInteger(value.timeoutMs)
|
|
158
|
+
|| value.timeoutMs < 1_000
|
|
159
|
+
|| value.timeoutMs > MAX_TIMEOUT_MS) {
|
|
160
|
+
throw new Error("Sanctuary host proposal fields are invalid");
|
|
161
|
+
}
|
|
162
|
+
validateCommand(value.command);
|
|
163
|
+
if (value.verification !== null) {
|
|
164
|
+
if (!isObject(value.verification)
|
|
165
|
+
|| !exactKeys(value.verification, ["profile", "expectedStateDigest"])
|
|
166
|
+
|| value.verification.profile !== "file.digest.v1"
|
|
167
|
+
|| typeof value.verification.expectedStateDigest !== "string"
|
|
168
|
+
|| !DIGEST.test(value.verification.expectedStateDigest)
|
|
169
|
+
|| !absoluteExecutable(value.targetResource)) {
|
|
170
|
+
throw new Error("Sanctuary host verification is invalid");
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (!isObject(value.ownerObservation)
|
|
174
|
+
|| !exactKeys(value.ownerObservation, ["digest", "updateId", "userId", "chatId", "messageId"])
|
|
175
|
+
|| typeof value.ownerObservation.digest !== "string"
|
|
176
|
+
|| !DIGEST.test(value.ownerObservation.digest)
|
|
177
|
+
|| !Number.isSafeInteger(value.ownerObservation.updateId)
|
|
178
|
+
|| value.ownerObservation.updateId < 0
|
|
179
|
+
|| typeof value.ownerObservation.userId !== "string"
|
|
180
|
+
|| !DECIMAL_ID.test(value.ownerObservation.userId)
|
|
181
|
+
|| typeof value.ownerObservation.chatId !== "string"
|
|
182
|
+
|| !DECIMAL_ID.test(value.ownerObservation.chatId)
|
|
183
|
+
|| typeof value.ownerObservation.messageId !== "string"
|
|
184
|
+
|| !DECIMAL_ID.test(value.ownerObservation.messageId)) {
|
|
185
|
+
throw new Error("Sanctuary host owner observation is invalid");
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
function html(value) {
|
|
189
|
+
return value
|
|
190
|
+
.replaceAll("&", "&")
|
|
191
|
+
.replaceAll("<", "<")
|
|
192
|
+
.replaceAll(">", ">")
|
|
193
|
+
.replaceAll("\"", """)
|
|
194
|
+
.replaceAll("'", "'");
|
|
195
|
+
}
|
|
196
|
+
function digestText(value) {
|
|
197
|
+
return `sha256:${(0, node_crypto_1.createHash)("sha256").update(value, "utf8").digest("hex")}`;
|
|
198
|
+
}
|
|
199
|
+
function hostProposalCommitment(proposal) {
|
|
200
|
+
return (0, sanctuary_authority_codec_1.authorityArtifactDigest)(PROPOSAL_DOMAIN, proposal);
|
|
201
|
+
}
|
|
202
|
+
function renderProposal(proposal, proposalDigest) {
|
|
203
|
+
const command = proposal.command.kind === "executable"
|
|
204
|
+
? [
|
|
205
|
+
`<b>Executable:</b> <code>${html(proposal.command.executable)}</code>`,
|
|
206
|
+
`<b>Arguments:</b> <code>${html((0, sanctuary_authority_codec_1.canonicalAuthorityJson)(proposal.command.arguments))}</code>`,
|
|
207
|
+
]
|
|
208
|
+
: [
|
|
209
|
+
`<b>Interpreter:</b> <code>${html(proposal.command.interpreter)}</code>`,
|
|
210
|
+
`<b>Arguments:</b> <code>${html((0, sanctuary_authority_codec_1.canonicalAuthorityJson)(proposal.command.arguments))}</code>`,
|
|
211
|
+
`<b>Complete script bytes:</b>\n<pre>${html(proposal.command.script)}</pre>`,
|
|
212
|
+
];
|
|
213
|
+
return [
|
|
214
|
+
ROOT_HOST_PREFIX,
|
|
215
|
+
"",
|
|
216
|
+
"<b>Effect:</b> unrestricted root, potentially destructive and persistent",
|
|
217
|
+
"<b>Namespaces:</b> real Sanctuary host namespaces",
|
|
218
|
+
`<b>Target:</b> ${html(proposal.targetHost)} / ${html(proposal.targetResource)}`,
|
|
219
|
+
...command,
|
|
220
|
+
`<b>Working directory profile:</b> ${html(proposal.workingDirectoryProfile)} (resolved path <code>/</code>)`,
|
|
221
|
+
`<b>Environment profile:</b> ${html(proposal.environmentProfile)}`,
|
|
222
|
+
`<b>Timeout:</b> ${proposal.timeoutMs} ms`,
|
|
223
|
+
`<b>Verification:</b> ${proposal.verification ? `${html(proposal.verification.profile)} / ${proposal.verification.expectedStateDigest}` : "none"}`,
|
|
224
|
+
`<b>Proposal commitment:</b> <code>${proposalDigest}</code>`,
|
|
225
|
+
"",
|
|
226
|
+
"Approved uid-0 code can read or change this gateway, its token and signing key; escape process controls; survive the receipt; install persistence; and invalidate future authority. Approval prevents an unapproved start. It cannot contain malicious code after approval.",
|
|
227
|
+
].join("\n");
|
|
228
|
+
}
|
|
229
|
+
function initialState() {
|
|
230
|
+
return { schemaVersion: SCHEMA_VERSION, records: {}, callbackIds: {}, messageIds: {} };
|
|
231
|
+
}
|
|
232
|
+
function requireState(condition) {
|
|
233
|
+
if (!condition)
|
|
234
|
+
throw new Error("Sanctuary host authority state is malformed");
|
|
235
|
+
}
|
|
236
|
+
function validateStoredProposal(value, expectedHost) {
|
|
237
|
+
requireState(isObject(value));
|
|
238
|
+
const proposal = value;
|
|
239
|
+
requireState(exactKeys(proposal, [
|
|
240
|
+
"targetHost",
|
|
241
|
+
"targetResource",
|
|
242
|
+
"command",
|
|
243
|
+
"workingDirectoryProfile",
|
|
244
|
+
"environmentProfile",
|
|
245
|
+
"timeoutMs",
|
|
246
|
+
"verification",
|
|
247
|
+
"ownerObservation",
|
|
248
|
+
"expiresAt",
|
|
249
|
+
]));
|
|
250
|
+
const { expiresAt, ...request } = proposal;
|
|
251
|
+
validateHostProposalRequest(request, expectedHost);
|
|
252
|
+
requireState(validTime(expiresAt));
|
|
253
|
+
}
|
|
254
|
+
function validateStoredArtifact(value, domain, options) {
|
|
255
|
+
return (0, sanctuary_authority_codec_1.verifyAuthorityPayload)({
|
|
256
|
+
artifact: value,
|
|
257
|
+
expectedDomain: domain,
|
|
258
|
+
expectedKeyId: options.keyId,
|
|
259
|
+
publicKey: options.publicKey,
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
function validateStoredRecord(registrationId, value, options) {
|
|
263
|
+
requireState(REGISTRATION_ID.test(registrationId));
|
|
264
|
+
requireState(isObject(value));
|
|
265
|
+
const record = value;
|
|
266
|
+
requireState(typeof record.state === "string");
|
|
267
|
+
const preparedKeys = [
|
|
268
|
+
"schemaVersion",
|
|
269
|
+
"state",
|
|
270
|
+
"registrationId",
|
|
271
|
+
"proposal",
|
|
272
|
+
"proposalDigest",
|
|
273
|
+
"prompt",
|
|
274
|
+
"promptDigest",
|
|
275
|
+
"approveHandle",
|
|
276
|
+
"denyHandle",
|
|
277
|
+
"approveHandleDigest",
|
|
278
|
+
"denyHandleDigest",
|
|
279
|
+
"preparedAt",
|
|
280
|
+
"expiresAt",
|
|
281
|
+
"nonce",
|
|
282
|
+
];
|
|
283
|
+
const committedKeys = [
|
|
284
|
+
...preparedKeys,
|
|
285
|
+
"telegramMessageId",
|
|
286
|
+
"registration",
|
|
287
|
+
"callbackQueryId",
|
|
288
|
+
"callbackObservationDigest",
|
|
289
|
+
"decidedAt",
|
|
290
|
+
"decision",
|
|
291
|
+
"permit",
|
|
292
|
+
"receipt",
|
|
293
|
+
"cardRevision",
|
|
294
|
+
"cardRenderedRevision",
|
|
295
|
+
];
|
|
296
|
+
if (record.state === "orphaned") {
|
|
297
|
+
requireState(exactKeys(record, [...preparedKeys.filter((key) => key !== "approveHandle" && key !== "denyHandle"), "orphanedAt"]));
|
|
298
|
+
}
|
|
299
|
+
else if (record.state === "prepared") {
|
|
300
|
+
requireState(exactKeys(record, preparedKeys));
|
|
301
|
+
}
|
|
302
|
+
else {
|
|
303
|
+
requireState(["committed", "approved", "denied", "expired", "permitted", "executed", "retired"].includes(record.state));
|
|
304
|
+
requireState(exactKeys(record, committedKeys));
|
|
305
|
+
}
|
|
306
|
+
requireState(record.schemaVersion === SCHEMA_VERSION);
|
|
307
|
+
requireState(record.registrationId === registrationId);
|
|
308
|
+
validateStoredProposal(record.proposal, options.targetHost);
|
|
309
|
+
const proposal = record.proposal;
|
|
310
|
+
requireState(record.proposalDigest === hostProposalCommitment(proposal));
|
|
311
|
+
requireState(record.prompt === renderProposal(proposal, record.proposalDigest));
|
|
312
|
+
requireState(record.promptDigest === digestText(record.prompt));
|
|
313
|
+
requireState(validTime(record.preparedAt));
|
|
314
|
+
requireState(record.expiresAt === proposal.expiresAt);
|
|
315
|
+
requireState(Date.parse(record.expiresAt) === Date.parse(record.preparedAt) + APPROVAL_TTL_MS);
|
|
316
|
+
requireState(typeof record.nonce === "string");
|
|
317
|
+
requireState(/^[A-Za-z0-9_-]{43}$/u.test(record.nonce));
|
|
318
|
+
requireState(typeof record.approveHandleDigest === "string");
|
|
319
|
+
requireState(typeof record.denyHandleDigest === "string");
|
|
320
|
+
if (record.state === "orphaned") {
|
|
321
|
+
requireState(validTime(record.orphanedAt));
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
requireState(printable(record.approveHandle, 256));
|
|
325
|
+
requireState(printable(record.denyHandle, 256));
|
|
326
|
+
requireState(record.approveHandleDigest === digestText(record.approveHandle));
|
|
327
|
+
requireState(record.denyHandleDigest === digestText(record.denyHandle));
|
|
328
|
+
if (record.state === "prepared")
|
|
329
|
+
return;
|
|
330
|
+
requireState(Number.isSafeInteger(record.telegramMessageId));
|
|
331
|
+
requireState(record.telegramMessageId > 0);
|
|
332
|
+
const registration = validateStoredArtifact(record.registration, REGISTRATION_DOMAIN, options);
|
|
333
|
+
requireState(registration.registrationId === registrationId);
|
|
334
|
+
requireState(registration.proposalDigest === record.proposalDigest);
|
|
335
|
+
requireState(registration.telegramMessageId === record.telegramMessageId);
|
|
336
|
+
requireState(registration.targetHost === options.targetHost);
|
|
337
|
+
requireState(registration.publicKeyDigest === options.publicKeyDigest);
|
|
338
|
+
const validCardRevisions = Object.keys(CARD_STATUS);
|
|
339
|
+
requireState(validCardRevisions.includes(record.cardRevision));
|
|
340
|
+
requireState(validCardRevisions.includes(record.cardRenderedRevision));
|
|
341
|
+
if (record.state === "committed") {
|
|
342
|
+
requireState(record.callbackQueryId === null);
|
|
343
|
+
requireState(record.callbackObservationDigest === null);
|
|
344
|
+
requireState(record.decidedAt === null);
|
|
345
|
+
requireState(record.decision === null);
|
|
346
|
+
requireState(record.permit === null);
|
|
347
|
+
requireState(record.receipt === null);
|
|
348
|
+
requireState(record.cardRevision === record.state);
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
if (record.state === "expired" && record.callbackQueryId === null) {
|
|
352
|
+
requireState(record.callbackObservationDigest === null);
|
|
353
|
+
requireState(record.decidedAt === null);
|
|
354
|
+
requireState(record.decision === null);
|
|
355
|
+
requireState(record.permit === null);
|
|
356
|
+
requireState(record.receipt === null);
|
|
357
|
+
requireState(record.cardRevision === "expired");
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
requireState(printable(record.callbackQueryId, 256));
|
|
361
|
+
requireState(typeof record.callbackObservationDigest === "string");
|
|
362
|
+
requireState(DIGEST.test(record.callbackObservationDigest));
|
|
363
|
+
requireState(validTime(record.decidedAt));
|
|
364
|
+
const decision = validateStoredArtifact(record.decision, DECISION_DOMAIN, options);
|
|
365
|
+
requireState(decision.registrationId === registrationId);
|
|
366
|
+
requireState(decision.callbackQueryId === record.callbackQueryId);
|
|
367
|
+
requireState(decision.callbackObservationDigest === record.callbackObservationDigest);
|
|
368
|
+
requireState(decision.decidedAt === record.decidedAt);
|
|
369
|
+
requireState(decision.decision === (record.state === "denied" ? "deny" : "approve"));
|
|
370
|
+
if (record.state === "approved" || record.state === "denied" || record.state === "expired") {
|
|
371
|
+
requireState(record.permit === null);
|
|
372
|
+
requireState(record.receipt === null);
|
|
373
|
+
requireState(record.cardRevision === (record.state === "expired" ? "expired" : record.state));
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
const permit = validateStoredArtifact(record.permit, PERMIT_DOMAIN, options);
|
|
377
|
+
requireState(permit.registrationId === registrationId);
|
|
378
|
+
requireState(permit.targetHost === options.targetHost);
|
|
379
|
+
requireState(permit.publicKeyDigest === options.publicKeyDigest);
|
|
380
|
+
if (record.state === "permitted") {
|
|
381
|
+
requireState(record.receipt === null);
|
|
382
|
+
requireState(record.cardRevision === "approved");
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
const receipt = validateStoredArtifact(record.receipt, record.state === "retired" ? RETIREMENT_DOMAIN : RECEIPT_DOMAIN, options);
|
|
386
|
+
requireState(receipt.registrationId === registrationId);
|
|
387
|
+
requireState(receipt.permitId === permit.permitId);
|
|
388
|
+
requireState(receipt.permitDigest === (0, sanctuary_authority_codec_1.authorityArtifactDigest)(PERMIT_DOMAIN, permit));
|
|
389
|
+
requireState(receipt.targetHost === options.targetHost);
|
|
390
|
+
requireState(receipt.publicKeyDigest === options.publicKeyDigest);
|
|
391
|
+
requireState(record.cardRevision === (record.state === "retired" ? "retired" : `executed_${String(receipt.state)}`));
|
|
392
|
+
}
|
|
393
|
+
function validateHostAuthorityState(value, options) {
|
|
394
|
+
requireState(isObject(value));
|
|
395
|
+
const state = value;
|
|
396
|
+
requireState(exactKeys(state, ["schemaVersion", "records", "callbackIds", "messageIds"]));
|
|
397
|
+
requireState(state.schemaVersion === SCHEMA_VERSION);
|
|
398
|
+
requireState(isObject(state.records));
|
|
399
|
+
requireState(isObject(state.callbackIds));
|
|
400
|
+
requireState(isObject(state.messageIds));
|
|
401
|
+
const records = state.records;
|
|
402
|
+
for (const [registrationId, record] of Object.entries(records)) {
|
|
403
|
+
validateStoredRecord(registrationId, record, options);
|
|
404
|
+
}
|
|
405
|
+
const expectedCallbackIds = {};
|
|
406
|
+
const expectedMessageIds = {};
|
|
407
|
+
for (const [registrationId, record] of Object.entries(records)) {
|
|
408
|
+
if ("telegramMessageId" in record)
|
|
409
|
+
expectedMessageIds[String(record.telegramMessageId)] = registrationId;
|
|
410
|
+
if ("callbackQueryId" in record && record.callbackQueryId)
|
|
411
|
+
expectedCallbackIds[record.callbackQueryId] = registrationId;
|
|
412
|
+
}
|
|
413
|
+
requireState((0, sanctuary_authority_codec_1.canonicalAuthorityJson)(state.callbackIds) === (0, sanctuary_authority_codec_1.canonicalAuthorityJson)(expectedCallbackIds));
|
|
414
|
+
requireState((0, sanctuary_authority_codec_1.canonicalAuthorityJson)(state.messageIds) === (0, sanctuary_authority_codec_1.canonicalAuthorityJson)(expectedMessageIds));
|
|
415
|
+
return state;
|
|
416
|
+
}
|
|
417
|
+
function sanctuaryHostAuthorityStatePath(agentRoot) {
|
|
418
|
+
return path.join(agentRoot, "authority", "host-authority.json");
|
|
419
|
+
}
|
|
420
|
+
class FileSanctuaryHostAuthority {
|
|
421
|
+
#statePath;
|
|
422
|
+
#options;
|
|
423
|
+
constructor(agentRoot, options) {
|
|
424
|
+
if (!path.isAbsolute(agentRoot))
|
|
425
|
+
throw new Error("Sanctuary host authority root must be absolute");
|
|
426
|
+
this.#statePath = sanctuaryHostAuthorityStatePath(agentRoot);
|
|
427
|
+
this.#options = options;
|
|
428
|
+
}
|
|
429
|
+
prepare(request) {
|
|
430
|
+
const now = this.#now();
|
|
431
|
+
validateHostProposalRequest(request, this.#options.targetHost);
|
|
432
|
+
const proposal = {
|
|
433
|
+
...request,
|
|
434
|
+
expiresAt: new Date(Date.parse(now) + APPROVAL_TTL_MS).toISOString(),
|
|
435
|
+
};
|
|
436
|
+
const observation = this.#options.resolveOwnerObservation({
|
|
437
|
+
updateId: proposal.ownerObservation.updateId,
|
|
438
|
+
observationDigest: proposal.ownerObservation.digest,
|
|
439
|
+
});
|
|
440
|
+
if (!observation
|
|
441
|
+
|| (0, sanctuary_authority_codec_1.authorityArtifactDigest)(observation.domain, observation.payload) !== proposal.ownerObservation.digest
|
|
442
|
+
|| observation.payload.targetHost !== this.#options.targetHost
|
|
443
|
+
|| observation.payload.botId !== this.#options.botId
|
|
444
|
+
|| observation.payload.publicKeyDigest !== this.#options.publicKeyDigest
|
|
445
|
+
|| !observation.payload.ownerEligible
|
|
446
|
+
|| observation.payload.userId !== this.#options.ownerUserId
|
|
447
|
+
|| observation.payload.chatId !== this.#options.ownerChatId
|
|
448
|
+
|| observation.payload.updateId !== proposal.ownerObservation.updateId
|
|
449
|
+
|| observation.payload.userId !== proposal.ownerObservation.userId
|
|
450
|
+
|| observation.payload.chatId !== proposal.ownerObservation.chatId
|
|
451
|
+
|| observation.payload.messageId !== proposal.ownerObservation.messageId) {
|
|
452
|
+
throw new Error("Sanctuary host owner observation is not root-authorized");
|
|
453
|
+
}
|
|
454
|
+
const proposalDigest = hostProposalCommitment(proposal);
|
|
455
|
+
const registrationNonce = this.#nonce();
|
|
456
|
+
const registrationId = `hostreg-${registrationNonce}`;
|
|
457
|
+
const approveHandle = `ouh:a:${this.#nonce()}`;
|
|
458
|
+
const denyHandle = `ouh:d:${this.#nonce()}`;
|
|
459
|
+
const prompt = renderProposal(proposal, proposalDigest);
|
|
460
|
+
if (prompt.length > 4_096)
|
|
461
|
+
throw new Error("Sanctuary host approval prompt is too large");
|
|
462
|
+
const record = {
|
|
463
|
+
schemaVersion: SCHEMA_VERSION,
|
|
464
|
+
state: "prepared",
|
|
465
|
+
registrationId,
|
|
466
|
+
proposal,
|
|
467
|
+
proposalDigest,
|
|
468
|
+
prompt,
|
|
469
|
+
promptDigest: digestText(prompt),
|
|
470
|
+
approveHandle,
|
|
471
|
+
denyHandle,
|
|
472
|
+
approveHandleDigest: digestText(approveHandle),
|
|
473
|
+
denyHandleDigest: digestText(denyHandle),
|
|
474
|
+
preparedAt: now,
|
|
475
|
+
expiresAt: proposal.expiresAt,
|
|
476
|
+
nonce: registrationNonce,
|
|
477
|
+
};
|
|
478
|
+
this.#transaction((state) => {
|
|
479
|
+
if (Object.values(state.records).some((record) => record.state === "prepared" || record.state === "committed" || record.state === "approved" || record.state === "permitted")) {
|
|
480
|
+
throw new Error("Sanctuary host approval is already active");
|
|
481
|
+
}
|
|
482
|
+
if (state.records[registrationId])
|
|
483
|
+
throw new Error("Sanctuary host registration id already exists");
|
|
484
|
+
state.records[registrationId] = record;
|
|
485
|
+
});
|
|
486
|
+
(0, runtime_1.emitNervesEvent)({ component: "daemon", event: "daemon.sanctuary_host_approval_prepared", message: "Sanctuary host approval intent durably prepared", meta: { proposalDigest } });
|
|
487
|
+
return {
|
|
488
|
+
registrationId,
|
|
489
|
+
proposalDigest,
|
|
490
|
+
prompt,
|
|
491
|
+
expiresAt: proposal.expiresAt,
|
|
492
|
+
replyMarkup: {
|
|
493
|
+
inline_keyboard: [[
|
|
494
|
+
{ text: "Approve root execution", callback_data: approveHandle },
|
|
495
|
+
{ text: "Deny", callback_data: denyHandle },
|
|
496
|
+
]],
|
|
497
|
+
},
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
commit(input) {
|
|
501
|
+
if (!REGISTRATION_ID.test(input.registrationId)
|
|
502
|
+
|| !Number.isSafeInteger(input.telegramMessageId)
|
|
503
|
+
|| input.telegramMessageId <= 0) {
|
|
504
|
+
throw new Error("Sanctuary host registration commit is invalid");
|
|
505
|
+
}
|
|
506
|
+
let registration;
|
|
507
|
+
this.#transaction((state) => {
|
|
508
|
+
const current = state.records[input.registrationId];
|
|
509
|
+
if (!current || current.state !== "prepared")
|
|
510
|
+
throw new Error("Sanctuary host registration is not prepared");
|
|
511
|
+
if (state.messageIds[String(input.telegramMessageId)])
|
|
512
|
+
throw new Error("Sanctuary host approval message already exists");
|
|
513
|
+
registration = (0, sanctuary_authority_codec_1.signAuthorityPayload)({
|
|
514
|
+
domain: REGISTRATION_DOMAIN,
|
|
515
|
+
keyId: this.#options.keyId,
|
|
516
|
+
privateKey: this.#options.privateKey,
|
|
517
|
+
payload: {
|
|
518
|
+
targetHost: this.#options.targetHost,
|
|
519
|
+
botId: this.#options.botId,
|
|
520
|
+
targetResource: current.proposal.targetResource,
|
|
521
|
+
registrationId: current.registrationId,
|
|
522
|
+
proposalDigest: current.proposalDigest,
|
|
523
|
+
ownerObservationDigest: current.proposal.ownerObservation.digest,
|
|
524
|
+
ownerUpdateId: current.proposal.ownerObservation.updateId,
|
|
525
|
+
ownerUserId: current.proposal.ownerObservation.userId,
|
|
526
|
+
ownerChatId: current.proposal.ownerObservation.chatId,
|
|
527
|
+
ownerMessageId: current.proposal.ownerObservation.messageId,
|
|
528
|
+
command: current.proposal.command,
|
|
529
|
+
workingDirectoryProfile: current.proposal.workingDirectoryProfile,
|
|
530
|
+
environmentProfile: current.proposal.environmentProfile,
|
|
531
|
+
timeoutMs: current.proposal.timeoutMs,
|
|
532
|
+
verification: current.proposal.verification,
|
|
533
|
+
prompt: current.prompt,
|
|
534
|
+
promptDigest: current.promptDigest,
|
|
535
|
+
displayedProposalDigest: current.proposalDigest,
|
|
536
|
+
marker: ROOT_HOST_PREFIX,
|
|
537
|
+
telegramMessageId: input.telegramMessageId,
|
|
538
|
+
approveHandleDigest: current.approveHandleDigest,
|
|
539
|
+
denyHandleDigest: current.denyHandleDigest,
|
|
540
|
+
registeredAt: this.#now(),
|
|
541
|
+
expiresAt: current.expiresAt,
|
|
542
|
+
nonce: current.nonce,
|
|
543
|
+
publicKeyDigest: this.#options.publicKeyDigest,
|
|
544
|
+
},
|
|
545
|
+
});
|
|
546
|
+
const committed = {
|
|
547
|
+
...current,
|
|
548
|
+
state: "committed",
|
|
549
|
+
telegramMessageId: input.telegramMessageId,
|
|
550
|
+
registration,
|
|
551
|
+
callbackQueryId: null,
|
|
552
|
+
callbackObservationDigest: null,
|
|
553
|
+
decidedAt: null,
|
|
554
|
+
decision: null,
|
|
555
|
+
permit: null,
|
|
556
|
+
receipt: null,
|
|
557
|
+
cardRevision: "committed",
|
|
558
|
+
cardRenderedRevision: "committed",
|
|
559
|
+
};
|
|
560
|
+
state.records[current.registrationId] = committed;
|
|
561
|
+
state.messageIds[String(input.telegramMessageId)] = current.registrationId;
|
|
562
|
+
});
|
|
563
|
+
return registration;
|
|
564
|
+
}
|
|
565
|
+
decide(input) {
|
|
566
|
+
if (!CALLBACK_ID.test(input.callbackQueryId)
|
|
567
|
+
|| !printable(input.callbackData, 256)
|
|
568
|
+
|| !Number.isSafeInteger(input.telegramMessageId)
|
|
569
|
+
|| input.telegramMessageId <= 0
|
|
570
|
+
|| input.userId !== this.#options.ownerUserId
|
|
571
|
+
|| input.chatId !== this.#options.ownerChatId
|
|
572
|
+
|| !DIGEST.test(input.callbackObservationDigest)
|
|
573
|
+
|| !validTime(input.decidedAt)) {
|
|
574
|
+
throw new Error("Sanctuary host decision is invalid");
|
|
575
|
+
}
|
|
576
|
+
let decision = null;
|
|
577
|
+
this.#transaction((state) => {
|
|
578
|
+
if (state.callbackIds[input.callbackQueryId])
|
|
579
|
+
throw new Error("Sanctuary host callback is already consumed");
|
|
580
|
+
const registrationId = state.messageIds[String(input.telegramMessageId)];
|
|
581
|
+
const current = registrationId ? state.records[registrationId] : null;
|
|
582
|
+
if (!current || !("approveHandle" in current))
|
|
583
|
+
throw new Error("Sanctuary host registration state changed");
|
|
584
|
+
const approved = input.callbackData === current.approveHandle;
|
|
585
|
+
const denied = input.callbackData === current.denyHandle;
|
|
586
|
+
if (!approved && !denied)
|
|
587
|
+
throw new Error("Sanctuary host callback handle is invalid");
|
|
588
|
+
if (current.state !== "committed")
|
|
589
|
+
return false;
|
|
590
|
+
if (Date.parse(input.decidedAt) > Date.parse(current.expiresAt))
|
|
591
|
+
throw new Error("Sanctuary host approval expired");
|
|
592
|
+
const outcome = approved ? "approve" : "deny";
|
|
593
|
+
decision = (0, sanctuary_authority_codec_1.signAuthorityPayload)({
|
|
594
|
+
domain: DECISION_DOMAIN,
|
|
595
|
+
keyId: this.#options.keyId,
|
|
596
|
+
privateKey: this.#options.privateKey,
|
|
597
|
+
payload: {
|
|
598
|
+
targetHost: this.#options.targetHost,
|
|
599
|
+
registrationId: current.registrationId,
|
|
600
|
+
registrationDigest: (0, sanctuary_authority_codec_1.authorityArtifactDigest)(current.registration.domain, current.registration.payload),
|
|
601
|
+
decision: outcome,
|
|
602
|
+
callbackQueryId: input.callbackQueryId,
|
|
603
|
+
callbackObservationDigest: input.callbackObservationDigest,
|
|
604
|
+
decidedAt: input.decidedAt,
|
|
605
|
+
nonce: this.#nonce(),
|
|
606
|
+
publicKeyDigest: this.#options.publicKeyDigest,
|
|
607
|
+
},
|
|
608
|
+
});
|
|
609
|
+
state.records[current.registrationId] = {
|
|
610
|
+
...current,
|
|
611
|
+
state: approved ? "approved" : "denied",
|
|
612
|
+
callbackQueryId: input.callbackQueryId,
|
|
613
|
+
callbackObservationDigest: input.callbackObservationDigest,
|
|
614
|
+
decidedAt: input.decidedAt,
|
|
615
|
+
decision,
|
|
616
|
+
cardRevision: approved ? "approved" : "denied",
|
|
617
|
+
};
|
|
618
|
+
state.callbackIds[input.callbackQueryId] = current.registrationId;
|
|
619
|
+
});
|
|
620
|
+
return decision;
|
|
621
|
+
}
|
|
622
|
+
reconcilePrepared() {
|
|
623
|
+
const orphaned = [];
|
|
624
|
+
this.#transaction((state) => {
|
|
625
|
+
const now = this.#now();
|
|
626
|
+
for (const [registrationId, record] of Object.entries(state.records)) {
|
|
627
|
+
if (record.state !== "prepared")
|
|
628
|
+
continue;
|
|
629
|
+
const { approveHandle: _approveHandle, denyHandle: _denyHandle, ...safe } = record;
|
|
630
|
+
state.records[registrationId] = { ...safe, state: "orphaned", orphanedAt: now };
|
|
631
|
+
orphaned.push(registrationId);
|
|
632
|
+
}
|
|
633
|
+
});
|
|
634
|
+
return orphaned;
|
|
635
|
+
}
|
|
636
|
+
// Caller has fenced ingress. Journal refusal intent in the registration before
|
|
637
|
+
// reserving its replay tombstone, so interruption cannot turn it into execution.
|
|
638
|
+
retireRegistrations(ledger) {
|
|
639
|
+
this.reconcilePrepared();
|
|
640
|
+
const pending = [];
|
|
641
|
+
this.#transaction((state) => {
|
|
642
|
+
for (const [id, record] of Object.entries(state.records)) {
|
|
643
|
+
if (record.state === "committed" || record.state === "approved") {
|
|
644
|
+
state.records[id] = { ...record, state: "expired", cardRevision: "expired" };
|
|
645
|
+
}
|
|
646
|
+
else if (record.state === "permitted") {
|
|
647
|
+
const permit = record.permit;
|
|
648
|
+
if (ledger.read(String(permit.payload.permitId))) {
|
|
649
|
+
pending.push(id);
|
|
650
|
+
continue;
|
|
651
|
+
}
|
|
652
|
+
const receipt = (0, sanctuary_authority_codec_1.signAuthorityPayload)({
|
|
653
|
+
domain: RETIREMENT_DOMAIN, keyId: this.#options.keyId, privateKey: this.#options.privateKey,
|
|
654
|
+
payload: {
|
|
655
|
+
registrationId: id, permitId: permit.payload.permitId,
|
|
656
|
+
permitDigest: (0, sanctuary_authority_codec_1.authorityArtifactDigest)(permit.domain, permit.payload),
|
|
657
|
+
targetHost: this.#options.targetHost, publicKeyDigest: this.#options.publicKeyDigest,
|
|
658
|
+
state: "refused", reason: "epoch_retired_before_reservation", retiredAt: this.#now(),
|
|
659
|
+
},
|
|
660
|
+
});
|
|
661
|
+
state.records[id] = { ...record, state: "retired", cardRevision: "retired", receipt };
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
});
|
|
665
|
+
for (const record of Object.values(this.#read().records)) {
|
|
666
|
+
if (record.state !== "retired")
|
|
667
|
+
continue;
|
|
668
|
+
const permit = record.permit;
|
|
669
|
+
const permitId = String(permit.payload.permitId);
|
|
670
|
+
const permitDigest = (0, sanctuary_authority_codec_1.authorityArtifactDigest)(permit.domain, permit.payload);
|
|
671
|
+
const outcomeDigest = (0, sanctuary_authority_codec_1.authorityArtifactDigest)(record.receipt.domain, record.receipt.payload);
|
|
672
|
+
const retiredAt = String(record.receipt.payload.retiredAt);
|
|
673
|
+
const current = ledger.read(permitId);
|
|
674
|
+
if (current && (current.permitDigest !== permitDigest || (current.state !== "reserved" && (current.state !== "refused" || current.outcomeDigest !== outcomeDigest))))
|
|
675
|
+
throw new Error("Sanctuary retired permit ledger changed");
|
|
676
|
+
if (!current)
|
|
677
|
+
ledger.reserve({ permitId, nonce: String(permit.payload.nonce), permitDigest, reservedAt: retiredAt });
|
|
678
|
+
if (!current || current.state === "reserved")
|
|
679
|
+
ledger.terminalize({ permitId, state: "refused", outcomeDigest, updatedAt: retiredAt });
|
|
680
|
+
}
|
|
681
|
+
return pending;
|
|
682
|
+
}
|
|
683
|
+
issuePermit(input) {
|
|
684
|
+
if (!isObject(input)
|
|
685
|
+
|| !exactKeys(input, [
|
|
686
|
+
"registrationId",
|
|
687
|
+
"residentFriendId",
|
|
688
|
+
"relationshipProfileId",
|
|
689
|
+
"relationshipProfileVersion",
|
|
690
|
+
"requestId",
|
|
691
|
+
"sessionKey",
|
|
692
|
+
"sessionEventId",
|
|
693
|
+
"residentApprovalId",
|
|
694
|
+
"stewardPolicy",
|
|
695
|
+
])
|
|
696
|
+
|| !REGISTRATION_ID.test(input.registrationId)
|
|
697
|
+
|| !printable(input.residentFriendId, 512)
|
|
698
|
+
|| !printable(input.relationshipProfileId, 256)
|
|
699
|
+
|| !Number.isSafeInteger(input.relationshipProfileVersion)
|
|
700
|
+
|| input.relationshipProfileVersion < 1
|
|
701
|
+
|| !printable(input.requestId, 512)
|
|
702
|
+
|| !printable(input.sessionKey, 1_024)
|
|
703
|
+
|| !printable(input.sessionEventId, 512)
|
|
704
|
+
|| !printable(input.residentApprovalId, 512)) {
|
|
705
|
+
throw new Error("Sanctuary host permit correlation is invalid");
|
|
706
|
+
}
|
|
707
|
+
if (input.stewardPolicy !== null && (!isObject(input.stewardPolicy)
|
|
708
|
+
|| !exactKeys(input.stewardPolicy, ["key", "version", "digest"])
|
|
709
|
+
|| !printable(input.stewardPolicy.key, 256)
|
|
710
|
+
|| !Number.isSafeInteger(input.stewardPolicy.version)
|
|
711
|
+
|| input.stewardPolicy.version < 1
|
|
712
|
+
|| typeof input.stewardPolicy.digest !== "string"
|
|
713
|
+
|| !DIGEST.test(input.stewardPolicy.digest))) {
|
|
714
|
+
throw new Error("Sanctuary host permit steward policy is invalid");
|
|
715
|
+
}
|
|
716
|
+
let permit;
|
|
717
|
+
let issueWindowExpired = false;
|
|
718
|
+
this.#transaction((state) => {
|
|
719
|
+
const current = state.records[input.registrationId];
|
|
720
|
+
if (!current || current.state !== "approved" || !current.decision || !current.decidedAt || !current.callbackObservationDigest) {
|
|
721
|
+
throw new Error("Sanctuary host approval state cannot issue a permit");
|
|
722
|
+
}
|
|
723
|
+
const issuedAt = this.#now();
|
|
724
|
+
if (Date.parse(issuedAt) < Date.parse(current.decidedAt)) {
|
|
725
|
+
throw new Error("Sanctuary host permit issue window expired");
|
|
726
|
+
}
|
|
727
|
+
if (Date.parse(issuedAt) > Date.parse(current.decidedAt) + 120_000) {
|
|
728
|
+
state.records[current.registrationId] = { ...current, state: "expired", cardRevision: "expired" };
|
|
729
|
+
issueWindowExpired = true;
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
const permitSeed = this.#nonce();
|
|
733
|
+
const permitId = `permit-${permitSeed}`;
|
|
734
|
+
const permitNonce = (0, node_crypto_1.createHash)("sha256").update(`permit-nonce\0${permitSeed}`, "utf8").digest("hex");
|
|
735
|
+
permit = (0, sanctuary_authority_codec_1.signAuthorityPayload)({
|
|
736
|
+
domain: PERMIT_DOMAIN,
|
|
737
|
+
keyId: this.#options.keyId,
|
|
738
|
+
privateKey: this.#options.privateKey,
|
|
739
|
+
payload: {
|
|
740
|
+
targetHost: this.#options.targetHost,
|
|
741
|
+
permitId,
|
|
742
|
+
registrationId: current.registrationId,
|
|
743
|
+
registrationDigest: (0, sanctuary_authority_codec_1.authorityArtifactDigest)(current.registration.domain, current.registration.payload),
|
|
744
|
+
ownerUserId: this.#options.ownerUserId,
|
|
745
|
+
ownerChatId: this.#options.ownerChatId,
|
|
746
|
+
ownerObservationDigest: current.proposal.ownerObservation.digest,
|
|
747
|
+
callbackObservationDigest: current.callbackObservationDigest,
|
|
748
|
+
residentFriendId: input.residentFriendId,
|
|
749
|
+
relationshipProfileId: input.relationshipProfileId,
|
|
750
|
+
relationshipProfileVersion: input.relationshipProfileVersion,
|
|
751
|
+
requestId: input.requestId,
|
|
752
|
+
sessionKey: input.sessionKey,
|
|
753
|
+
sessionEventId: input.sessionEventId,
|
|
754
|
+
residentApprovalId: input.residentApprovalId,
|
|
755
|
+
effectClass: "owner_approved_arbitrary_host",
|
|
756
|
+
executionProfile: "host.owner_approved.v1",
|
|
757
|
+
targetResource: current.proposal.targetResource,
|
|
758
|
+
command: current.proposal.command,
|
|
759
|
+
scriptDigest: current.proposal.command.kind === "script" ? digestText(current.proposal.command.script) : null,
|
|
760
|
+
workingDirectoryProfile: current.proposal.workingDirectoryProfile,
|
|
761
|
+
environmentProfile: current.proposal.environmentProfile,
|
|
762
|
+
environmentProfileDigest: digestText(current.proposal.environmentProfile),
|
|
763
|
+
timeoutMs: current.proposal.timeoutMs,
|
|
764
|
+
// Resident-supplied audit correlation, like the session IDs above; never a root authorization input.
|
|
765
|
+
stewardPolicy: input.stewardPolicy,
|
|
766
|
+
verification: current.proposal.verification,
|
|
767
|
+
issuedAt,
|
|
768
|
+
expiresAt: new Date(Date.parse(issuedAt) + 120_000).toISOString(),
|
|
769
|
+
nonce: permitNonce,
|
|
770
|
+
publicKeyDigest: this.#options.publicKeyDigest,
|
|
771
|
+
},
|
|
772
|
+
});
|
|
773
|
+
state.records[current.registrationId] = { ...current, state: "permitted", permit };
|
|
774
|
+
});
|
|
775
|
+
if (issueWindowExpired)
|
|
776
|
+
throw new Error("Sanctuary host permit issue window expired");
|
|
777
|
+
return permit;
|
|
778
|
+
}
|
|
779
|
+
completeExecution(registrationId, receipt) {
|
|
780
|
+
if (!REGISTRATION_ID.test(registrationId)) {
|
|
781
|
+
throw new Error("Sanctuary host execution receipt is invalid");
|
|
782
|
+
}
|
|
783
|
+
const receiptPayload = (0, sanctuary_authority_codec_1.verifyAuthorityPayload)({
|
|
784
|
+
artifact: receipt,
|
|
785
|
+
expectedDomain: RECEIPT_DOMAIN,
|
|
786
|
+
expectedKeyId: this.#options.keyId,
|
|
787
|
+
publicKey: this.#options.publicKey,
|
|
788
|
+
});
|
|
789
|
+
const receiptState = receiptPayload.state;
|
|
790
|
+
if (!["verified", "failed", "ambiguous"].includes(String(receiptState))) {
|
|
791
|
+
throw new Error("Sanctuary host execution receipt state is invalid");
|
|
792
|
+
}
|
|
793
|
+
this.#transaction((state) => {
|
|
794
|
+
const current = state.records[registrationId];
|
|
795
|
+
if (!current || current.state !== "permitted" || !current.permit) {
|
|
796
|
+
throw new Error("Sanctuary host execution state changed");
|
|
797
|
+
}
|
|
798
|
+
if (receiptPayload.targetHost !== this.#options.targetHost
|
|
799
|
+
|| receiptPayload.registrationId !== registrationId
|
|
800
|
+
|| receiptPayload.permitId !== current.permit.payload.permitId
|
|
801
|
+
|| receiptPayload.permitDigest !== (0, sanctuary_authority_codec_1.authorityArtifactDigest)(current.permit.domain, current.permit.payload)
|
|
802
|
+
|| receiptPayload.publicKeyDigest !== this.#options.publicKeyDigest
|
|
803
|
+
|| !validTime(receiptPayload.startedAt)
|
|
804
|
+
|| !validTime(receiptPayload.completedAt)
|
|
805
|
+
|| Date.parse(receiptPayload.completedAt) < Date.parse(receiptPayload.startedAt)) {
|
|
806
|
+
throw new Error("Sanctuary host execution receipt permit changed");
|
|
807
|
+
}
|
|
808
|
+
state.records[registrationId] = {
|
|
809
|
+
...current,
|
|
810
|
+
state: "executed",
|
|
811
|
+
receipt,
|
|
812
|
+
cardRevision: `executed_${receiptState}`,
|
|
813
|
+
};
|
|
814
|
+
});
|
|
815
|
+
}
|
|
816
|
+
expireRegistrations() {
|
|
817
|
+
const now = Date.parse(this.#now());
|
|
818
|
+
const expired = [];
|
|
819
|
+
this.#transaction((state) => {
|
|
820
|
+
for (const [registrationId, record] of Object.entries(state.records)) {
|
|
821
|
+
const committedExpired = record.state === "committed" && Date.parse(record.expiresAt) < now;
|
|
822
|
+
const approvedExpired = record.state === "approved"
|
|
823
|
+
&& record.decidedAt !== null
|
|
824
|
+
&& Date.parse(record.decidedAt) + 120_000 < now;
|
|
825
|
+
if (!committedExpired && !approvedExpired)
|
|
826
|
+
continue;
|
|
827
|
+
state.records[registrationId] = {
|
|
828
|
+
...record,
|
|
829
|
+
state: "expired",
|
|
830
|
+
cardRevision: "expired",
|
|
831
|
+
};
|
|
832
|
+
expired.push(registrationId);
|
|
833
|
+
}
|
|
834
|
+
return expired.length > 0;
|
|
835
|
+
});
|
|
836
|
+
return expired;
|
|
837
|
+
}
|
|
838
|
+
pendingCardEdit(registrationId) {
|
|
839
|
+
if (!REGISTRATION_ID.test(registrationId))
|
|
840
|
+
throw new Error("Sanctuary host registration id is invalid");
|
|
841
|
+
const record = this.#read().records[registrationId];
|
|
842
|
+
if (!record || !("telegramMessageId" in record) || record.cardRevision === record.cardRenderedRevision)
|
|
843
|
+
return null;
|
|
844
|
+
return {
|
|
845
|
+
telegramMessageId: record.telegramMessageId,
|
|
846
|
+
text: `${ROOT_HOST_PREFIX}\n\n<b>Status:</b> ${CARD_STATUS[record.cardRevision]}\n<b>Proposal commitment:</b> <code>${record.proposalDigest}</code>`,
|
|
847
|
+
revision: record.cardRevision,
|
|
848
|
+
};
|
|
849
|
+
}
|
|
850
|
+
markCardEdited(registrationId, revision) {
|
|
851
|
+
this.#transaction((state) => {
|
|
852
|
+
const record = state.records[registrationId];
|
|
853
|
+
if (!record || !("cardRevision" in record) || record.cardRevision !== revision) {
|
|
854
|
+
throw new Error("Sanctuary host approval card revision changed");
|
|
855
|
+
}
|
|
856
|
+
state.records[registrationId] = { ...record, cardRenderedRevision: revision };
|
|
857
|
+
});
|
|
858
|
+
}
|
|
859
|
+
ownsMessage(telegramMessageId) {
|
|
860
|
+
if (!Number.isSafeInteger(telegramMessageId) || telegramMessageId <= 0)
|
|
861
|
+
return false;
|
|
862
|
+
return this.#read().messageIds[String(telegramMessageId)] !== undefined;
|
|
863
|
+
}
|
|
864
|
+
ownsPrompt(text) {
|
|
865
|
+
return typeof text === "string"
|
|
866
|
+
&& (text.startsWith(ROOT_HOST_PREFIX) || text.startsWith("OURO ROOT HOST APPROVAL"));
|
|
867
|
+
}
|
|
868
|
+
ownsHandle(callbackData) {
|
|
869
|
+
if (typeof callbackData !== "string")
|
|
870
|
+
return false;
|
|
871
|
+
return Object.values(this.#read().records).some((record) => "approveHandle" in record && (record.approveHandle === callbackData || record.denyHandle === callbackData));
|
|
872
|
+
}
|
|
873
|
+
ownerMutationFrozen() {
|
|
874
|
+
return Object.values(this.#read().records).some((record) => record.state === "prepared");
|
|
875
|
+
}
|
|
876
|
+
decisionForCallback(callbackQueryId) {
|
|
877
|
+
if (!CALLBACK_ID.test(callbackQueryId))
|
|
878
|
+
return null;
|
|
879
|
+
const state = this.#read();
|
|
880
|
+
const registrationId = state.callbackIds[callbackQueryId];
|
|
881
|
+
if (!registrationId)
|
|
882
|
+
return null;
|
|
883
|
+
return state.records[registrationId].decision;
|
|
884
|
+
}
|
|
885
|
+
claimCallback(input) {
|
|
886
|
+
const callbackDigest = digestText(input.callbackData);
|
|
887
|
+
const record = Object.values(this.#read().records).find((record) => record.approveHandleDigest === callbackDigest || record.denyHandleDigest === callbackDigest
|
|
888
|
+
|| ("telegramMessageId" in record && record.telegramMessageId === input.telegramMessageId && input.chatId === this.#options.ownerChatId));
|
|
889
|
+
if (!record)
|
|
890
|
+
return null;
|
|
891
|
+
let decision = null;
|
|
892
|
+
const previous = this.decisionForCallback(input.callbackQueryId);
|
|
893
|
+
if (previous) {
|
|
894
|
+
if (previous.payload.callbackObservationDigest === input.callbackObservationDigest)
|
|
895
|
+
decision = previous;
|
|
896
|
+
}
|
|
897
|
+
else if (record.state === "committed"
|
|
898
|
+
&& record.telegramMessageId === input.telegramMessageId
|
|
899
|
+
&& (record.approveHandle === input.callbackData || record.denyHandle === input.callbackData)
|
|
900
|
+
&& input.userId === this.#options.ownerUserId
|
|
901
|
+
&& input.chatId === this.#options.ownerChatId
|
|
902
|
+
&& Date.parse(input.decidedAt) <= Date.parse(record.expiresAt)) {
|
|
903
|
+
decision = this.decide(input);
|
|
904
|
+
}
|
|
905
|
+
return (0, sanctuary_authority_codec_1.signAuthorityPayload)({
|
|
906
|
+
domain: "ouro.sanctuary.host-callback.v1",
|
|
907
|
+
keyId: this.#options.keyId,
|
|
908
|
+
privateKey: this.#options.privateKey,
|
|
909
|
+
payload: {
|
|
910
|
+
...this.#identity(),
|
|
911
|
+
handled: true,
|
|
912
|
+
registrationId: record.registrationId,
|
|
913
|
+
callbackQueryId: input.callbackQueryId,
|
|
914
|
+
observationDigest: input.callbackObservationDigest,
|
|
915
|
+
decision,
|
|
916
|
+
},
|
|
917
|
+
});
|
|
918
|
+
}
|
|
919
|
+
attestStatus(status) {
|
|
920
|
+
return (0, sanctuary_authority_codec_1.signAuthorityPayload)({
|
|
921
|
+
domain: `ouro.sanctuary.host-${typeof status === "boolean" ? "health" : "status"}.v1`,
|
|
922
|
+
keyId: this.#options.keyId,
|
|
923
|
+
privateKey: this.#options.privateKey,
|
|
924
|
+
payload: {
|
|
925
|
+
...this.#identity(),
|
|
926
|
+
observedAt: this.#now(),
|
|
927
|
+
...(typeof status === "boolean" ? { healthy: status } : { status }),
|
|
928
|
+
},
|
|
929
|
+
});
|
|
930
|
+
}
|
|
931
|
+
#identity() {
|
|
932
|
+
return {
|
|
933
|
+
targetHost: this.#options.targetHost,
|
|
934
|
+
botId: this.#options.botId,
|
|
935
|
+
ownerUserId: this.#options.ownerUserId,
|
|
936
|
+
ownerChatId: this.#options.ownerChatId,
|
|
937
|
+
publicKeyDigest: this.#options.publicKeyDigest,
|
|
938
|
+
};
|
|
939
|
+
}
|
|
940
|
+
status(registrationId) {
|
|
941
|
+
if (!REGISTRATION_ID.test(registrationId))
|
|
942
|
+
throw new Error("Sanctuary host registration id is invalid");
|
|
943
|
+
const record = this.#read().records[registrationId];
|
|
944
|
+
if (!record)
|
|
945
|
+
return null;
|
|
946
|
+
return {
|
|
947
|
+
registrationId: record.registrationId,
|
|
948
|
+
state: record.state,
|
|
949
|
+
proposalDigest: record.proposalDigest,
|
|
950
|
+
expiresAt: record.expiresAt,
|
|
951
|
+
...("telegramMessageId" in record ? { telegramMessageId: record.telegramMessageId } : {}),
|
|
952
|
+
...("decision" in record && record.decision ? { decision: record.decision } : {}),
|
|
953
|
+
...("permit" in record && record.permit ? {
|
|
954
|
+
permit: (0, sanctuary_authority_codec_1.signAuthorityPayload)({
|
|
955
|
+
domain: "ouro.sanctuary.host-attempt.v1",
|
|
956
|
+
keyId: this.#options.keyId,
|
|
957
|
+
privateKey: this.#options.privateKey,
|
|
958
|
+
payload: { ...record.permit.payload, permitDigest: (0, sanctuary_authority_codec_1.authorityArtifactDigest)(record.permit.domain, record.permit.payload) },
|
|
959
|
+
}),
|
|
960
|
+
} : {}),
|
|
961
|
+
...("receipt" in record && record.receipt ? { receipt: record.receipt } : {}),
|
|
962
|
+
...("cardRevision" in record ? { cardPending: record.cardRevision !== record.cardRenderedRevision } : {}),
|
|
963
|
+
};
|
|
964
|
+
}
|
|
965
|
+
#now() {
|
|
966
|
+
const value = this.#options.now?.() ?? new Date().toISOString();
|
|
967
|
+
if (!validTime(value))
|
|
968
|
+
throw new Error("Sanctuary host authority time is invalid");
|
|
969
|
+
return value;
|
|
970
|
+
}
|
|
971
|
+
#nonce() {
|
|
972
|
+
const value = this.#options.nonce?.() ?? (0, node_crypto_1.randomBytes)(32).toString("base64url");
|
|
973
|
+
if (!/^[A-Za-z0-9_-]{43}$/u.test(value))
|
|
974
|
+
throw new Error("Sanctuary host authority nonce is invalid");
|
|
975
|
+
return value;
|
|
976
|
+
}
|
|
977
|
+
#read() {
|
|
978
|
+
return (0, session_transaction_1.withImmediateSessionTurnLease)(this.#statePath, (lease) => {
|
|
979
|
+
const transaction = (0, session_transaction_1.readSessionTransaction)(this.#statePath, lease);
|
|
980
|
+
if (transaction.value === null) {
|
|
981
|
+
if (transaction.bytes)
|
|
982
|
+
throw new Error("Sanctuary host authority state is malformed");
|
|
983
|
+
return initialState();
|
|
984
|
+
}
|
|
985
|
+
return validateHostAuthorityState(transaction.value, this.#options);
|
|
986
|
+
});
|
|
987
|
+
}
|
|
988
|
+
#transaction(mutate) {
|
|
989
|
+
(0, session_transaction_1.withImmediateSessionTurnLease)(this.#statePath, (lease) => {
|
|
990
|
+
const transaction = (0, session_transaction_1.readSessionTransaction)(this.#statePath, lease);
|
|
991
|
+
let state;
|
|
992
|
+
if (transaction.value === null) {
|
|
993
|
+
if (transaction.bytes)
|
|
994
|
+
throw new Error("Sanctuary host authority state is malformed");
|
|
995
|
+
state = initialState();
|
|
996
|
+
}
|
|
997
|
+
else {
|
|
998
|
+
state = validateHostAuthorityState(transaction.value, this.#options);
|
|
999
|
+
}
|
|
1000
|
+
if (mutate(state) === false)
|
|
1001
|
+
return;
|
|
1002
|
+
this.#options.beforeWrite?.();
|
|
1003
|
+
fs.chmodSync(path.dirname(this.#statePath), 0o700);
|
|
1004
|
+
(0, session_transaction_1.writeSessionTransaction)(this.#statePath, state, { lease, expectedRevision: transaction.revision });
|
|
1005
|
+
});
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
exports.FileSanctuaryHostAuthority = FileSanctuaryHostAuthority;
|