@ashlr/hub 3.2.7 → 3.3.0
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.md +268 -0
- package/README.md +4 -1
- package/dist/build-identity.json +2 -2
- package/dist/cli/activation.d.ts +41 -0
- package/dist/cli/activation.js +485 -0
- package/dist/cli/activation.js.map +1 -0
- package/dist/cli/audit.js +18 -0
- package/dist/cli/audit.js.map +1 -1
- package/dist/cli/backlog.d.ts +0 -5
- package/dist/cli/backlog.js +18 -0
- package/dist/cli/backlog.js.map +1 -1
- package/dist/cli/comms.js +20 -0
- package/dist/cli/comms.js.map +1 -1
- package/dist/cli/completions.d.ts +6 -1
- package/dist/cli/completions.js +35 -11
- package/dist/cli/completions.js.map +1 -1
- package/dist/cli/daemon.js +10 -3
- package/dist/cli/daemon.js.map +1 -1
- package/dist/cli/demo.d.ts +0 -27
- package/dist/cli/demo.js +17 -0
- package/dist/cli/demo.js.map +1 -1
- package/dist/cli/help.js +19 -3
- package/dist/cli/help.js.map +1 -1
- package/dist/cli/inbox.d.ts +14 -2
- package/dist/cli/inbox.js +137 -23
- package/dist/cli/inbox.js.map +1 -1
- package/dist/cli/index.d.ts +1 -0
- package/dist/cli/index.js +10 -0
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/onboard.d.ts +0 -13
- package/dist/cli/onboard.js +20 -0
- package/dist/cli/onboard.js.map +1 -1
- package/dist/cli/resources.js +15 -0
- package/dist/cli/resources.js.map +1 -1
- package/dist/cli/run.js +1 -1
- package/dist/cli/run.js.map +1 -1
- package/dist/cli/sandbox.js +43 -0
- package/dist/cli/sandbox.js.map +1 -1
- package/dist/cli/tui.d.ts +0 -6
- package/dist/cli/tui.js +14 -0
- package/dist/cli/tui.js.map +1 -1
- package/dist/core/config.js +70 -0
- package/dist/core/config.js.map +1 -1
- package/dist/core/daemon/activation-permit.d.ts +268 -33
- package/dist/core/daemon/activation-permit.js +1111 -106
- package/dist/core/daemon/activation-permit.js.map +1 -1
- package/dist/core/daemon/loop.d.ts +13 -1
- package/dist/core/daemon/loop.js +192 -9
- package/dist/core/daemon/loop.js.map +1 -1
- package/dist/core/daemon/runtime-activation-resident-start-protocol.d.ts +141 -0
- package/dist/core/daemon/runtime-activation-resident-start-protocol.js +462 -0
- package/dist/core/daemon/runtime-activation-resident-start-protocol.js.map +1 -0
- package/dist/core/daemon/runtime-activation-resident-start-runtime.d.ts +27 -0
- package/dist/core/daemon/runtime-activation-resident-start-runtime.js +28 -0
- package/dist/core/daemon/runtime-activation-resident-start-runtime.js.map +1 -0
- package/dist/core/daemon/runtime-activation-stopped-consumer.d.ts +139 -0
- package/dist/core/daemon/runtime-activation-stopped-consumer.js +995 -0
- package/dist/core/daemon/runtime-activation-stopped-consumer.js.map +1 -0
- package/dist/core/daemon/runtime-activation-stopped-runtime.d.ts +36 -0
- package/dist/core/daemon/runtime-activation-stopped-runtime.js +134 -0
- package/dist/core/daemon/runtime-activation-stopped-runtime.js.map +1 -0
- package/dist/core/daemon/runtime-activation-transaction.d.ts +41 -17
- package/dist/core/daemon/runtime-activation-transaction.js +80 -11
- package/dist/core/daemon/runtime-activation-transaction.js.map +1 -1
- package/dist/core/daemon/runtime-release-dependency-inventory.js +1 -0
- package/dist/core/daemon/runtime-release-dependency-inventory.js.map +1 -1
- package/dist/core/daemon/service-install-authority.d.ts +0 -4
- package/dist/core/daemon/service-install-authority.js +18 -3
- package/dist/core/daemon/service-install-authority.js.map +1 -1
- package/dist/core/dashboard.d.ts +10 -0
- package/dist/core/dashboard.js +103 -28
- package/dist/core/dashboard.js.map +1 -1
- package/dist/core/effective-config.js +7 -0
- package/dist/core/effective-config.js.map +1 -1
- package/dist/core/fleet/automerge-pass.js +33 -0
- package/dist/core/fleet/automerge-pass.js.map +1 -1
- package/dist/core/fleet/decisions-ledger.js +35 -12
- package/dist/core/fleet/decisions-ledger.js.map +1 -1
- package/dist/core/fleet/external-skill-git-capture.js +9 -1
- package/dist/core/fleet/external-skill-git-capture.js.map +1 -1
- package/dist/core/fleet/judge-decision-metadata.d.ts +8 -1
- package/dist/core/fleet/judge-decision-metadata.js +14 -1
- package/dist/core/fleet/judge-decision-metadata.js.map +1 -1
- package/dist/core/fleet/manager.d.ts +15 -1
- package/dist/core/fleet/manager.js +108 -30
- package/dist/core/fleet/manager.js.map +1 -1
- package/dist/core/fleet/post-merge-credit.d.ts +134 -9
- package/dist/core/fleet/post-merge-credit.js +350 -10
- package/dist/core/fleet/post-merge-credit.js.map +1 -1
- package/dist/core/fleet/self-improve.d.ts +34 -1
- package/dist/core/fleet/self-improve.js +106 -1
- package/dist/core/fleet/self-improve.js.map +1 -1
- package/dist/core/fleet/skill-library.d.ts +21 -2
- package/dist/core/fleet/skill-library.js +54 -12
- package/dist/core/fleet/skill-library.js.map +1 -1
- package/dist/core/fleet/status.js +1 -0
- package/dist/core/fleet/status.js.map +1 -1
- package/dist/core/fleet/worked-ledger.d.ts +22 -0
- package/dist/core/fleet/worked-ledger.js +32 -0
- package/dist/core/fleet/worked-ledger.js.map +1 -1
- package/dist/core/foundry/automerge-scope-policy.d.ts +2 -2
- package/dist/core/foundry/automerge-scope-policy.js +9 -2
- package/dist/core/foundry/automerge-scope-policy.js.map +1 -1
- package/dist/core/goals/store.js +18 -4
- package/dist/core/goals/store.js.map +1 -1
- package/dist/core/inbox/apply.js +5 -2
- package/dist/core/inbox/apply.js.map +1 -1
- package/dist/core/inbox/merge.d.ts +94 -1
- package/dist/core/inbox/merge.js +349 -16
- package/dist/core/inbox/merge.js.map +1 -1
- package/dist/core/integrations/github.d.ts +2 -0
- package/dist/core/integrations/github.js +2 -1
- package/dist/core/integrations/github.js.map +1 -1
- package/dist/core/learn/reflect.d.ts +42 -4
- package/dist/core/learn/reflect.js +56 -5
- package/dist/core/learn/reflect.js.map +1 -1
- package/dist/core/run/engines.d.ts +8 -0
- package/dist/core/run/engines.js +9 -1
- package/dist/core/run/engines.js.map +1 -1
- package/dist/core/run/learned-router.js +28 -3
- package/dist/core/run/learned-router.js.map +1 -1
- package/dist/core/run/orchestrator.d.ts +7 -2
- package/dist/core/run/orchestrator.js +86 -60
- package/dist/core/run/orchestrator.js.map +1 -1
- package/dist/core/swarm/rollback.d.ts +11 -0
- package/dist/core/swarm/rollback.js +54 -0
- package/dist/core/swarm/rollback.js.map +1 -1
- package/dist/core/swarm/runner.js +94 -8
- package/dist/core/swarm/runner.js.map +1 -1
- package/dist/core/types.d.ts +100 -1
- package/dist/core/types.js.map +1 -1
- package/dist/core/web/api.js +105 -7
- package/dist/core/web/api.js.map +1 -1
- package/dist/core/web/control.d.ts +20 -0
- package/dist/core/web/control.js +44 -32
- package/dist/core/web/control.js.map +1 -1
- package/dist/core/web/fleet-status-cache.d.ts +68 -0
- package/dist/core/web/fleet-status-cache.js +120 -0
- package/dist/core/web/fleet-status-cache.js.map +1 -0
- package/dist/core/web/public/next/assets/index-CY0U241s.css +1 -0
- package/dist/core/web/public/next/assets/index-Ct0mf8n4.js +69 -0
- package/dist/core/web/public/next/assets/index-Ct0mf8n4.js.map +1 -0
- package/dist/core/web/public/next/index.html +15 -0
- package/dist/release-dependency-inventory.json +1 -1
- package/docs/RUNTIME_ACTIVATION_AUTHORITY.md +126 -21
- package/docs/contracts/CONTRACT-M521.md +102 -0
- package/package.json +20 -3
- package/schema/config.schema.json +800 -5
- package/node_modules/which/CHANGELOG.md +0 -166
|
@@ -1,15 +1,69 @@
|
|
|
1
|
-
import { createHash, createPrivateKey, createPublicKey, sign, verify, } from 'node:crypto';
|
|
2
|
-
import { chmodSync, closeSync, constants as fsConstants, fchmodSync, fstatSync, fsyncSync, existsSync, lstatSync, mkdirSync, openSync, readdirSync, readSync, realpathSync, unlinkSync, writeSync, } from 'node:fs';
|
|
1
|
+
import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync, randomBytes, sign, verify, } from 'node:crypto';
|
|
2
|
+
import { chmodSync, closeSync, constants as fsConstants, fchmodSync, fstatSync, fsyncSync, existsSync, lstatSync, mkdirSync, openSync, readdirSync, readSync, realpathSync, renameSync, unlinkSync, writeSync, } from 'node:fs';
|
|
3
3
|
import { homedir, hostname } from 'node:os';
|
|
4
4
|
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
5
5
|
import { readBuildIdentity } from '../build-identity.js';
|
|
6
6
|
import { acquireLocalStoreLock, releaseLocalStoreLock, } from '../fleet/local-store-lock.js';
|
|
7
|
+
import { audit } from '../sandbox/audit.js';
|
|
7
8
|
import { killSwitchOn, readEnrollmentRegistry } from '../sandbox/policy.js';
|
|
8
9
|
import { fsyncDirectory } from '../util/durability.js';
|
|
9
10
|
import { assurePrivateStoragePath } from '../util/private-storage.js';
|
|
10
11
|
import { diagnoseGuardHealth } from './guard-health.js';
|
|
12
|
+
/**
|
|
13
|
+
* M461 → M470 daemon activation authority.
|
|
14
|
+
*
|
|
15
|
+
* The default for anyone who has not explicitly run `ashlr activation init`
|
|
16
|
+
* is EXACTLY today's behavior: every guard denies, with the same reasons as
|
|
17
|
+
* before. Authority only exists once an operator:
|
|
18
|
+
*
|
|
19
|
+
* 1. Runs `ashlr activation init` — generates an Ed25519 keypair on this
|
|
20
|
+
* machine (private key never leaves it) and registers the public half
|
|
21
|
+
* as a trust root in ~/.ashlr/activation/trust-roots.json (0600, dir
|
|
22
|
+
* 0700, owned by the operator's uid — checked on every read).
|
|
23
|
+
* 2. Runs `ashlr activation grant <scope...>` — signs and mints either a
|
|
24
|
+
* single-use activation PERMIT (scopes: once, resident) consumed by
|
|
25
|
+
* `daemon start`, or a standing, expiring GRANT (scopes: residentStanding,
|
|
26
|
+
* conductor, install, automerge, repair, deploy) checked on every call to
|
|
27
|
+
* the corresponding guard.
|
|
28
|
+
*
|
|
29
|
+
* `resident` is grantable BOTH ways, and both forms remain live:
|
|
30
|
+
* - one-shot `resident` permit: fixed 2-minute window, requires a human to
|
|
31
|
+
* run `grant` immediately before `daemon start`. Unusable after a reboot
|
|
32
|
+
* with nobody present.
|
|
33
|
+
* - standing `residentStanding` grant: up to the normal 30-day cap, checked
|
|
34
|
+
* at resident-daemon startup instead of requiring a fresh permit — this is
|
|
35
|
+
* what lets a launchd/systemd/Task-Scheduler-triggered restart authorize
|
|
36
|
+
* itself with no human in the loop. `daemon start` (resident mode) tries
|
|
37
|
+
* the standing grant first and falls back to the one-shot permit path
|
|
38
|
+
* unchanged, so an operator who never grants `residentStanding` sees
|
|
39
|
+
* EXACTLY today's one-shot-only behavior.
|
|
40
|
+
*
|
|
41
|
+
* Both permits and grants are Ed25519-signed, scoped, expiring, and
|
|
42
|
+
* independently revocable (`ashlr activation revoke`). Every init / grant /
|
|
43
|
+
* revoke / consume / denial writes an audit row via ../sandbox/audit.js so
|
|
44
|
+
* an operator can answer "who activated this fleet, when, with what scope."
|
|
45
|
+
*
|
|
46
|
+
* Revocation of a standing grant is file-based: it stops the NEXT
|
|
47
|
+
* authorization check, immediately. For `residentStanding` specifically it is
|
|
48
|
+
* stronger than that — because standing grants are not single-use (unlike the
|
|
49
|
+
* one-shot capability, which a resident loop must consume exactly once, see
|
|
50
|
+
* `tick()`'s `activationScope` handling in ../loop.ts), a resident daemon that
|
|
51
|
+
* started off a standing grant re-checks it on every tick and stops at the
|
|
52
|
+
* next one if the grant is revoked, expired, or its trust root is revoked —
|
|
53
|
+
* not just at the next `daemon start`. It cannot interrupt a tick already in
|
|
54
|
+
* flight.
|
|
55
|
+
*
|
|
56
|
+
* File loading fails CLOSED on every ambiguity: missing, unreadable, wrong
|
|
57
|
+
* permissions, wrong owner, symlinked, malformed, or non-canonically
|
|
58
|
+
* encoded trust-root/grant/permit files are all treated as "no authority" —
|
|
59
|
+
* never as "trust everything." Windows is denied outright: node:fs chmod()
|
|
60
|
+
* on Windows does not restrict per-user read access on NTFS (it only
|
|
61
|
+
* toggles the read-only attribute), so the "reject world/group-readable
|
|
62
|
+
* private files" invariant this module depends on cannot be enforced there.
|
|
63
|
+
*/
|
|
11
64
|
const POLICY_VERSION = 'm461-proposal-once-v1';
|
|
12
65
|
const SIGNING_DOMAIN = 'ashlr:daemon-activation-permit:m461:v1\0';
|
|
66
|
+
const GRANT_SIGNING_DOMAIN = 'ashlr:daemon-activation-grant:v1\0';
|
|
13
67
|
const MAX_PERMIT_BYTES = 32 * 1024;
|
|
14
68
|
const MAX_VALIDITY_MS = 120_000;
|
|
15
69
|
const MAX_FUTURE_SKEW_MS = 30_000;
|
|
@@ -19,18 +73,51 @@ const PRIVATE_FILE_MODE = 0o600;
|
|
|
19
73
|
const DIGEST_RE = /^[a-f0-9]{64}$/u;
|
|
20
74
|
const ID_RE = /^[a-f0-9]{32}$/u;
|
|
21
75
|
const KEY_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/u;
|
|
76
|
+
const MAX_TRUST_STORE_BYTES = 256 * 1024;
|
|
77
|
+
const MAX_GRANTS_BYTES = 512 * 1024;
|
|
78
|
+
const MAX_LABEL_LENGTH = 200;
|
|
79
|
+
/** Hard cap on any standing grant's validity window (30 days). */
|
|
80
|
+
export const MAX_STANDING_GRANT_VALIDITY_MS = 30 * 24 * 60 * 60 * 1000;
|
|
81
|
+
/** Default TTL `ashlr activation grant` uses when --ttl is not given (24h). */
|
|
82
|
+
export const DEFAULT_STANDING_GRANT_VALIDITY_MS = 24 * 60 * 60 * 1000;
|
|
22
83
|
const capabilityBrand = Symbol('ashlr.daemon-activation-capability');
|
|
23
|
-
/**
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
84
|
+
/** Every independently grantable scope key, in canonical order. Used by the CLI to validate `grant`/status output. */
|
|
85
|
+
export const DAEMON_ACTIVATION_SCOPE_KEYS = [
|
|
86
|
+
'once',
|
|
87
|
+
'resident',
|
|
88
|
+
'residentStanding',
|
|
89
|
+
'conductor',
|
|
90
|
+
'automerge',
|
|
91
|
+
'repair',
|
|
92
|
+
'deploy',
|
|
93
|
+
'install',
|
|
94
|
+
'proposalOnly',
|
|
95
|
+
];
|
|
96
|
+
const SCOPE_KEYS = DAEMON_ACTIVATION_SCOPE_KEYS;
|
|
97
|
+
/** All-false scope — the base every CLI `grant` invocation flips bits on top of. */
|
|
98
|
+
export const EMPTY_DAEMON_ACTIVATION_SCOPE = Object.freeze({
|
|
99
|
+
once: false,
|
|
100
|
+
resident: false,
|
|
101
|
+
residentStanding: false,
|
|
102
|
+
conductor: false,
|
|
103
|
+
automerge: false,
|
|
104
|
+
repair: false,
|
|
105
|
+
deploy: false,
|
|
106
|
+
install: false,
|
|
107
|
+
proposalOnly: false,
|
|
108
|
+
});
|
|
109
|
+
/** Preserves pre-M470 test/caller behavior when `buildDaemonActivationPermitPayload` omits `scope`. */
|
|
110
|
+
const LEGACY_ONCE_PROPOSAL_SCOPE = Object.freeze({
|
|
111
|
+
...EMPTY_DAEMON_ACTIVATION_SCOPE,
|
|
112
|
+
once: true,
|
|
113
|
+
proposalOnly: true,
|
|
114
|
+
});
|
|
29
115
|
const validCapabilities = new WeakMap();
|
|
30
|
-
function mintCapability(permitId, validateAtClaim) {
|
|
116
|
+
function mintCapability(permitId, scope, validateAtClaim) {
|
|
31
117
|
const capability = Object.freeze({
|
|
32
118
|
kind: 'proposal-once',
|
|
33
119
|
permitId,
|
|
120
|
+
scope: Object.freeze({ ...scope }),
|
|
34
121
|
[capabilityBrand]: true,
|
|
35
122
|
});
|
|
36
123
|
validCapabilities.set(capability, validateAtClaim);
|
|
@@ -52,14 +139,6 @@ export function isDaemonActivationCapability(value) {
|
|
|
52
139
|
return false;
|
|
53
140
|
}
|
|
54
141
|
}
|
|
55
|
-
/**
|
|
56
|
-
* Goal and simple conductors have broader authority than M461's one-item
|
|
57
|
-
* proposal profile. They remain dormant until a separately reviewed permit
|
|
58
|
-
* contract exists for those entry points.
|
|
59
|
-
*/
|
|
60
|
-
export function liveConductorActivationAuthorized() {
|
|
61
|
-
return false;
|
|
62
|
-
}
|
|
63
142
|
function isRecord(value) {
|
|
64
143
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
65
144
|
}
|
|
@@ -166,33 +245,43 @@ function validFileBinding(value) {
|
|
|
166
245
|
&& DIGEST_RE.test(String(value['sha256']));
|
|
167
246
|
}
|
|
168
247
|
function validScope(value) {
|
|
169
|
-
if (!isRecord(value) || !hasExactKeys(value,
|
|
170
|
-
'action',
|
|
171
|
-
'once',
|
|
172
|
-
'dryRun',
|
|
173
|
-
'resident',
|
|
174
|
-
'drain',
|
|
175
|
-
'drainLimit',
|
|
176
|
-
'proposalOnly',
|
|
177
|
-
'automerge',
|
|
178
|
-
'repair',
|
|
179
|
-
'deploy',
|
|
180
|
-
'install',
|
|
181
|
-
'selfTarget',
|
|
182
|
-
]))
|
|
248
|
+
if (!isRecord(value) || !hasExactKeys(value, SCOPE_KEYS))
|
|
183
249
|
return false;
|
|
184
|
-
return value[
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
250
|
+
return SCOPE_KEYS.every((key) => typeof value[key] === 'boolean');
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Loading a STANDING GRANT record from disk needs a more forgiving check
|
|
254
|
+
* than minting a new one: `validScope` requires every currently-defined
|
|
255
|
+
* scope key to be present, but a standing grant can live for up to 30 days
|
|
256
|
+
* — long enough to span an `npm`/build upgrade that adds a new key to
|
|
257
|
+
* `DAEMON_ACTIVATION_SCOPE_KEYS` (exactly what `residentStanding` is). A
|
|
258
|
+
* strict exact-key check on LOAD would make `hasExactKeys` reject that one
|
|
259
|
+
* older-format record — and `loadGrantStore()` fails the WHOLE store, not
|
|
260
|
+
* just that record, on any single invalid one, which would silently deny
|
|
261
|
+
* every OTHER still-valid standing grant (conductor/install/etc.) too.
|
|
262
|
+
*
|
|
263
|
+
* This accepts any subset of the current scope keys (no unrecognized keys,
|
|
264
|
+
* every present key boolean) and leaves absent keys simply absent. Every
|
|
265
|
+
* read site treats `record.scope[key] !== true` / falsy the same way
|
|
266
|
+
* `undefined` would, so an old record's missing new-generation keys read as
|
|
267
|
+
* "not granted" — never widened, never silently trusted. Signature
|
|
268
|
+
* verification is unaffected: `grantSigningBytes` canonicalizes whatever
|
|
269
|
+
* `scope` object the record actually contains, i.e. exactly what was
|
|
270
|
+
* originally signed, not a backfilled/padded version — so an old record's
|
|
271
|
+
* signature still verifies against its original (smaller) scope shape.
|
|
272
|
+
*/
|
|
273
|
+
function validStoredScope(value) {
|
|
274
|
+
if (!isRecord(value))
|
|
275
|
+
return false;
|
|
276
|
+
for (const key of Object.keys(value)) {
|
|
277
|
+
if (!SCOPE_KEYS.includes(key))
|
|
278
|
+
return false;
|
|
279
|
+
}
|
|
280
|
+
for (const key of SCOPE_KEYS) {
|
|
281
|
+
if (key in value && typeof value[key] !== 'boolean')
|
|
282
|
+
return false;
|
|
283
|
+
}
|
|
284
|
+
return true;
|
|
196
285
|
}
|
|
197
286
|
function validBindings(value) {
|
|
198
287
|
if (!isRecord(value) || !hasExactKeys(value, [
|
|
@@ -285,6 +374,11 @@ function trustRootMap(roots) {
|
|
|
285
374
|
function equalCanonical(left, right) {
|
|
286
375
|
return canonicalizeDaemonActivationValue(left) === canonicalizeDaemonActivationValue(right);
|
|
287
376
|
}
|
|
377
|
+
/** Which top-level binding fields differ, field by field — never a single opaque yes/no. */
|
|
378
|
+
function bindingFieldMismatches(actual, expected) {
|
|
379
|
+
const keys = Object.keys(expected);
|
|
380
|
+
return keys.filter((key) => !equalCanonical(actual[key], expected[key]));
|
|
381
|
+
}
|
|
288
382
|
export function verifyDaemonActivationPermit(value, context, trustRoots) {
|
|
289
383
|
if (trustRoots.length === 0)
|
|
290
384
|
return { ok: false, reason: 'no-trusted-activation-roots' };
|
|
@@ -333,7 +427,11 @@ export function verifyDaemonActivationPermit(value, context, trustRoots) {
|
|
|
333
427
|
guardHealth: 'healthy',
|
|
334
428
|
};
|
|
335
429
|
if (!equalCanonical(envelope.payload.bindings, expectedBindings)) {
|
|
336
|
-
return {
|
|
430
|
+
return {
|
|
431
|
+
ok: false,
|
|
432
|
+
reason: 'permit-runtime-binding-mismatch',
|
|
433
|
+
mismatchedBindings: bindingFieldMismatches(envelope.payload.bindings, expectedBindings),
|
|
434
|
+
};
|
|
337
435
|
}
|
|
338
436
|
const publicKey = trusted.get(envelope.payload.keyId);
|
|
339
437
|
if (!publicKey)
|
|
@@ -357,6 +455,7 @@ export function verifyDaemonActivationPermit(value, context, trustRoots) {
|
|
|
357
455
|
};
|
|
358
456
|
}
|
|
359
457
|
export function buildDaemonActivationPermitPayload(input) {
|
|
458
|
+
const scope = input.scope ?? LEGACY_ONCE_PROPOSAL_SCOPE;
|
|
360
459
|
const payload = {
|
|
361
460
|
schemaVersion: 1,
|
|
362
461
|
policyVersion: POLICY_VERSION,
|
|
@@ -365,20 +464,7 @@ export function buildDaemonActivationPermitPayload(input) {
|
|
|
365
464
|
keyId: input.keyId,
|
|
366
465
|
issuedAt: input.issuedAt,
|
|
367
466
|
expiresAt: input.expiresAt,
|
|
368
|
-
scope: {
|
|
369
|
-
action: 'daemon-proposal-once',
|
|
370
|
-
once: true,
|
|
371
|
-
dryRun: false,
|
|
372
|
-
resident: false,
|
|
373
|
-
drain: false,
|
|
374
|
-
drainLimit: null,
|
|
375
|
-
proposalOnly: true,
|
|
376
|
-
automerge: false,
|
|
377
|
-
repair: false,
|
|
378
|
-
deploy: false,
|
|
379
|
-
install: false,
|
|
380
|
-
selfTarget: false,
|
|
381
|
-
},
|
|
467
|
+
scope: { ...scope },
|
|
382
468
|
bindings: {
|
|
383
469
|
configDigest: input.context.configDigest,
|
|
384
470
|
buildIdentity: { ...input.context.buildIdentity },
|
|
@@ -410,6 +496,12 @@ function safeOwnedFile(stat) {
|
|
|
410
496
|
const privateMode = process.platform === 'win32' || (stat.mode & 63n) === 0n;
|
|
411
497
|
return stat.isFile() && !stat.isSymbolicLink() && stat.nlink === 1n && owned && privateMode;
|
|
412
498
|
}
|
|
499
|
+
function safeOwnedDirectory(stat) {
|
|
500
|
+
const owned = process.platform === 'win32' || typeof process.getuid !== 'function'
|
|
501
|
+
|| stat.uid === BigInt(process.getuid());
|
|
502
|
+
const privateMode = process.platform === 'win32' || (stat.mode & 63n) === 0n;
|
|
503
|
+
return stat.isDirectory() && !stat.isSymbolicLink() && owned && privateMode;
|
|
504
|
+
}
|
|
413
505
|
function sameFileSnapshot(left, right) {
|
|
414
506
|
return left.dev === right.dev
|
|
415
507
|
&& left.ino === right.ino
|
|
@@ -620,7 +712,26 @@ function hashStableReleaseTree(entrypointPath) {
|
|
|
620
712
|
export function daemonActivationReleaseTreeBinding(entrypointPath) {
|
|
621
713
|
return hashStableReleaseTree(entrypointPath);
|
|
622
714
|
}
|
|
623
|
-
|
|
715
|
+
/**
|
|
716
|
+
* Fingerprints "this authority store instance" — the ~/.ashlr directory a
|
|
717
|
+
* permit is bound to — so a permit can't be replayed against a different
|
|
718
|
+
* store (e.g. copied to another machine, or after a full delete+recreate).
|
|
719
|
+
*
|
|
720
|
+
* Deliberately uses dev+ino, NOT mtime. ~/.ashlr's own mtime changes every
|
|
721
|
+
* time a direct child is created or removed — and ordinary `daemon start`
|
|
722
|
+
* does exactly that before it ever reaches this check: acquireDaemonLock()
|
|
723
|
+
* (src/core/daemon/state.ts) creates ~/.ashlr/daemon.lock via
|
|
724
|
+
* `writeFileSync(..., {flag:'wx'})`, a brand-new direct child of ~/.ashlr.
|
|
725
|
+
* Binding to mtime meant every freshly minted permit was invalidated by the
|
|
726
|
+
* daemon's own startup lock-file creation, 100% reproducibly, before it
|
|
727
|
+
* could ever be consumed — a self-inflicted permit-runtime-binding-mismatch,
|
|
728
|
+
* not a real "different store" signal. dev+ino uniquely and STABLY identify
|
|
729
|
+
* the directory's inode: they survive ordinary child churn (locks, control
|
|
730
|
+
* files, receipts) and change only if ~/.ashlr is deleted and recreated (or
|
|
731
|
+
* moved to a different filesystem) — exactly the case this binding exists
|
|
732
|
+
* to catch.
|
|
733
|
+
*/
|
|
734
|
+
export function daemonActivationAuthorityStateDigest() {
|
|
624
735
|
const home = realpathSync(resolve(homedir()));
|
|
625
736
|
const ashlrHome = realpathSync(join(home, '.ashlr'));
|
|
626
737
|
const state = lstatSync(ashlrHome, { bigint: true });
|
|
@@ -639,7 +750,6 @@ function currentAuthorityStateDigest() {
|
|
|
639
750
|
path: ashlrHome,
|
|
640
751
|
dev: state.dev.toString(),
|
|
641
752
|
ino: state.ino.toString(),
|
|
642
|
-
mtimeNs: state.mtimeNs.toString(),
|
|
643
753
|
},
|
|
644
754
|
enrollment: [...enrollment.repos].sort(),
|
|
645
755
|
}));
|
|
@@ -656,7 +766,7 @@ function collectRuntimeContext(cfg) {
|
|
|
656
766
|
executable: hashStableFile(process.execPath),
|
|
657
767
|
entrypoint: hashStableFile(entrypoint),
|
|
658
768
|
releaseTree: daemonActivationReleaseTreeBinding(entrypoint),
|
|
659
|
-
authorityStateDigest:
|
|
769
|
+
authorityStateDigest: daemonActivationAuthorityStateDigest(),
|
|
660
770
|
killSwitchOff: !killSwitchOn(),
|
|
661
771
|
guardHealthHealthy: !guard.blocked,
|
|
662
772
|
};
|
|
@@ -674,7 +784,734 @@ function daemonActivationNonceReceiptPath(nonceDigest) {
|
|
|
674
784
|
throw new Error('invalid daemon activation nonce digest');
|
|
675
785
|
return join(homedir(), '.ashlr', 'control', 'activation-receipts', 'by-nonce', `${nonceDigest}.json`);
|
|
676
786
|
}
|
|
677
|
-
|
|
787
|
+
// ---------------------------------------------------------------------------
|
|
788
|
+
// Operator authority store — trust roots + standing grants.
|
|
789
|
+
//
|
|
790
|
+
// Root of trust is OS-level file ownership/permissions on the operator's own
|
|
791
|
+
// machine (mirrors the one-shot permit's existing safety model), not a
|
|
792
|
+
// central registry. Every store read fails CLOSED to "no authority" on any
|
|
793
|
+
// ambiguity: missing file, unreadable, wrong owner, wrong permissions,
|
|
794
|
+
// symlinked path, malformed JSON, non-canonical encoding, or duplicate ids.
|
|
795
|
+
// ---------------------------------------------------------------------------
|
|
796
|
+
export function daemonActivationDirPath() {
|
|
797
|
+
return join(homedir(), '.ashlr', 'activation');
|
|
798
|
+
}
|
|
799
|
+
export function daemonActivationTrustRootsPath() {
|
|
800
|
+
return join(daemonActivationDirPath(), 'trust-roots.json');
|
|
801
|
+
}
|
|
802
|
+
export function daemonActivationGrantsPath() {
|
|
803
|
+
return join(daemonActivationDirPath(), 'grants.json');
|
|
804
|
+
}
|
|
805
|
+
export function daemonActivationOperatorKeyPath() {
|
|
806
|
+
return join(daemonActivationDirPath(), 'operator-private-key.pem');
|
|
807
|
+
}
|
|
808
|
+
function daemonActivationStoreLockPath() {
|
|
809
|
+
return join(daemonActivationDirPath(), 'activation-store.lock');
|
|
810
|
+
}
|
|
811
|
+
function pathEntryPresent(path) {
|
|
812
|
+
try {
|
|
813
|
+
lstatSync(path);
|
|
814
|
+
return true;
|
|
815
|
+
}
|
|
816
|
+
catch (error) {
|
|
817
|
+
if (error.code === 'ENOENT')
|
|
818
|
+
return false;
|
|
819
|
+
throw error;
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
/** Safe, non-consuming read of a canonical-JSON-array operator store file. */
|
|
823
|
+
function safeReadOwnedJsonArray(path, anchorPath, maxBytes) {
|
|
824
|
+
if (process.platform === 'win32') {
|
|
825
|
+
return { ok: false, reason: 'activation-permit-v1-unsupported-on-windows' };
|
|
826
|
+
}
|
|
827
|
+
let dirStat;
|
|
828
|
+
try {
|
|
829
|
+
dirStat = lstatSync(dirname(path), { bigint: true });
|
|
830
|
+
}
|
|
831
|
+
catch (error) {
|
|
832
|
+
return { ok: false, reason: error.code === 'ENOENT' ? 'missing' : 'stat-failed' };
|
|
833
|
+
}
|
|
834
|
+
if (!safeOwnedDirectory(dirStat))
|
|
835
|
+
return { ok: false, reason: 'unsafe-directory' };
|
|
836
|
+
const dirAssurance = assurePrivateStoragePath(dirname(path), 'directory', 'inspect-owned', { anchorPath });
|
|
837
|
+
if (!dirAssurance.ok)
|
|
838
|
+
return { ok: false, reason: `unsafe-directory:${dirAssurance.reason}` };
|
|
839
|
+
// Open first, then validate the fd — the check (owner/mode/regular-file/
|
|
840
|
+
// size) and the read below share one file description, so there is no
|
|
841
|
+
// window between "checked" and "used" for an attacker to swap the path.
|
|
842
|
+
const noFollow = typeof fsConstants.O_NOFOLLOW === 'number' ? fsConstants.O_NOFOLLOW : 0;
|
|
843
|
+
let fd;
|
|
844
|
+
try {
|
|
845
|
+
fd = openSync(path, fsConstants.O_RDONLY | noFollow);
|
|
846
|
+
}
|
|
847
|
+
catch (error) {
|
|
848
|
+
return { ok: false, reason: error.code === 'ENOENT' ? 'missing' : 'open-failed' };
|
|
849
|
+
}
|
|
850
|
+
try {
|
|
851
|
+
const opened = fstatSync(fd, { bigint: true });
|
|
852
|
+
if (!safeOwnedFile(opened) || opened.size <= 0n || opened.size > BigInt(maxBytes)) {
|
|
853
|
+
return { ok: false, reason: 'unsafe-file' };
|
|
854
|
+
}
|
|
855
|
+
const size = Number(opened.size);
|
|
856
|
+
const bytes = Buffer.alloc(size);
|
|
857
|
+
let offset = 0;
|
|
858
|
+
while (offset < size) {
|
|
859
|
+
const count = readSync(fd, bytes, offset, size - offset, offset);
|
|
860
|
+
if (count <= 0)
|
|
861
|
+
return { ok: false, reason: 'short-read' };
|
|
862
|
+
offset += count;
|
|
863
|
+
}
|
|
864
|
+
const after = fstatSync(fd, { bigint: true });
|
|
865
|
+
if (!sameFileSnapshot(opened, after))
|
|
866
|
+
return { ok: false, reason: 'changed-during-read' };
|
|
867
|
+
const text = bytes.toString('utf8');
|
|
868
|
+
let parsed;
|
|
869
|
+
try {
|
|
870
|
+
parsed = JSON.parse(text);
|
|
871
|
+
}
|
|
872
|
+
catch {
|
|
873
|
+
return { ok: false, reason: 'invalid-json' };
|
|
874
|
+
}
|
|
875
|
+
if (`${canonicalizeDaemonActivationValue(parsed)}\n` !== text) {
|
|
876
|
+
return { ok: false, reason: 'noncanonical-encoding' };
|
|
877
|
+
}
|
|
878
|
+
if (!Array.isArray(parsed))
|
|
879
|
+
return { ok: false, reason: 'not-an-array' };
|
|
880
|
+
const fileAssurance = assurePrivateStoragePath(path, 'file', 'inspect-owned', { anchorPath });
|
|
881
|
+
if (!fileAssurance.ok)
|
|
882
|
+
return { ok: false, reason: `unsafe-file:${fileAssurance.reason}` };
|
|
883
|
+
return { ok: true, value: parsed };
|
|
884
|
+
}
|
|
885
|
+
finally {
|
|
886
|
+
closeSync(fd);
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
/** Atomically replace a canonical-JSON-array operator store file (temp write + rename). */
|
|
890
|
+
function writeCanonicalJsonArrayFileAtomic(path, value, anchorPath) {
|
|
891
|
+
assurePrivateDirectory(dirname(path), anchorPath);
|
|
892
|
+
const tmpPath = `${path}.tmp-${randomBytes(8).toString('hex')}`;
|
|
893
|
+
const noFollow = typeof fsConstants.O_NOFOLLOW === 'number' ? fsConstants.O_NOFOLLOW : 0;
|
|
894
|
+
let fd;
|
|
895
|
+
try {
|
|
896
|
+
fd = openSync(tmpPath, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | noFollow, PRIVATE_FILE_MODE);
|
|
897
|
+
const initial = fstatSync(fd, { bigint: true });
|
|
898
|
+
if (!safeOwnedFile(initial) || initial.size !== 0n)
|
|
899
|
+
throw new Error('unsafe-activation-store-temp-file');
|
|
900
|
+
fchmodSync(fd, PRIVATE_FILE_MODE);
|
|
901
|
+
const bytes = Buffer.from(`${canonicalizeDaemonActivationValue(value)}\n`, 'utf8');
|
|
902
|
+
writeAll(fd, bytes);
|
|
903
|
+
fsyncSync(fd);
|
|
904
|
+
const written = fstatSync(fd, { bigint: true });
|
|
905
|
+
if (!safeOwnedFile(written) || written.size !== BigInt(bytes.length)) {
|
|
906
|
+
throw new Error('activation-store-temp-file-changed-during-write');
|
|
907
|
+
}
|
|
908
|
+
closeSync(fd);
|
|
909
|
+
fd = undefined;
|
|
910
|
+
renameSync(tmpPath, path);
|
|
911
|
+
const assurance = assurePrivateStoragePath(path, 'file', 'secure-created', { anchorPath });
|
|
912
|
+
if (!assurance.ok)
|
|
913
|
+
throw new Error(`unsafe-activation-store-path:${assurance.reason}`);
|
|
914
|
+
fsyncDirectory(dirname(path));
|
|
915
|
+
}
|
|
916
|
+
finally {
|
|
917
|
+
if (fd !== undefined) {
|
|
918
|
+
try {
|
|
919
|
+
closeSync(fd);
|
|
920
|
+
}
|
|
921
|
+
catch { /* best-effort */ }
|
|
922
|
+
}
|
|
923
|
+
try {
|
|
924
|
+
if (existsSync(tmpPath))
|
|
925
|
+
unlinkSync(tmpPath);
|
|
926
|
+
}
|
|
927
|
+
catch { /* best-effort cleanup */ }
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
function withActivationStoreLock(fn) {
|
|
931
|
+
const home = resolve(homedir());
|
|
932
|
+
// Two single-level creates: ~/.ashlr may not exist yet on a machine where
|
|
933
|
+
// `activation init` is the very first ashlr command ever run.
|
|
934
|
+
assurePrivateDirectory(join(homedir(), '.ashlr'), home);
|
|
935
|
+
assurePrivateDirectory(daemonActivationDirPath(), home);
|
|
936
|
+
const lock = acquireLocalStoreLock(daemonActivationStoreLockPath(), LOCK_WAIT_MS, {
|
|
937
|
+
anchorPath: home,
|
|
938
|
+
exactPrivateStorage: true,
|
|
939
|
+
});
|
|
940
|
+
if (!lock)
|
|
941
|
+
throw new Error('activation-store-lock-unavailable');
|
|
942
|
+
let result;
|
|
943
|
+
try {
|
|
944
|
+
result = fn();
|
|
945
|
+
}
|
|
946
|
+
catch (error) {
|
|
947
|
+
releaseLocalStoreLock(lock);
|
|
948
|
+
throw error;
|
|
949
|
+
}
|
|
950
|
+
if (!releaseLocalStoreLock(lock))
|
|
951
|
+
throw new Error('activation-store-lock-release-failed');
|
|
952
|
+
return result;
|
|
953
|
+
}
|
|
954
|
+
function readOperatorPrivateKeyPem() {
|
|
955
|
+
const path = daemonActivationOperatorKeyPath();
|
|
956
|
+
const home = resolve(homedir());
|
|
957
|
+
// Open first, then validate the fd. The owner/mode/size check and the read
|
|
958
|
+
// below run against the same file description, so the path cannot be
|
|
959
|
+
// swapped between "checked" and "used".
|
|
960
|
+
const noFollow = typeof fsConstants.O_NOFOLLOW === 'number' ? fsConstants.O_NOFOLLOW : 0;
|
|
961
|
+
const fd = openSync(path, fsConstants.O_RDONLY | noFollow);
|
|
962
|
+
try {
|
|
963
|
+
const opened = fstatSync(fd, { bigint: true });
|
|
964
|
+
if (!safeOwnedFile(opened) || opened.size <= 0n || opened.size > BigInt(MAX_PERMIT_BYTES)) {
|
|
965
|
+
throw new Error('unsafe-operator-key-file');
|
|
966
|
+
}
|
|
967
|
+
const size = Number(opened.size);
|
|
968
|
+
const bytes = Buffer.alloc(size);
|
|
969
|
+
let offset = 0;
|
|
970
|
+
while (offset < size) {
|
|
971
|
+
const count = readSync(fd, bytes, offset, size - offset, offset);
|
|
972
|
+
if (count <= 0)
|
|
973
|
+
throw new Error('short-operator-key-read');
|
|
974
|
+
offset += count;
|
|
975
|
+
}
|
|
976
|
+
const after = fstatSync(fd, { bigint: true });
|
|
977
|
+
if (!sameFileSnapshot(opened, after))
|
|
978
|
+
throw new Error('operator-key-changed-during-read');
|
|
979
|
+
const assurance = assurePrivateStoragePath(path, 'file', 'inspect-owned', { anchorPath: home });
|
|
980
|
+
if (!assurance.ok)
|
|
981
|
+
throw new Error(`unsafe-operator-key-path:${assurance.reason}`);
|
|
982
|
+
return bytes.toString('utf8');
|
|
983
|
+
}
|
|
984
|
+
finally {
|
|
985
|
+
closeSync(fd);
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
function validTrustRootRecord(value) {
|
|
989
|
+
if (!isRecord(value) || !hasExactKeys(value, ['keyId', 'publicKeyPem', 'createdAt', 'revokedAt', 'label'])) {
|
|
990
|
+
return false;
|
|
991
|
+
}
|
|
992
|
+
if (typeof value['keyId'] !== 'string' || !KEY_ID_RE.test(value['keyId']))
|
|
993
|
+
return false;
|
|
994
|
+
if (typeof value['publicKeyPem'] !== 'string')
|
|
995
|
+
return false;
|
|
996
|
+
try {
|
|
997
|
+
const key = createPublicKey(value['publicKeyPem']);
|
|
998
|
+
if (key.asymmetricKeyType !== 'ed25519')
|
|
999
|
+
return false;
|
|
1000
|
+
}
|
|
1001
|
+
catch {
|
|
1002
|
+
return false;
|
|
1003
|
+
}
|
|
1004
|
+
if (!validIsoTimestamp(value['createdAt']))
|
|
1005
|
+
return false;
|
|
1006
|
+
if (value['revokedAt'] !== null && !validIsoTimestamp(value['revokedAt']))
|
|
1007
|
+
return false;
|
|
1008
|
+
if (typeof value['label'] !== 'string' || value['label'].length > MAX_LABEL_LENGTH)
|
|
1009
|
+
return false;
|
|
1010
|
+
return true;
|
|
1011
|
+
}
|
|
1012
|
+
function loadTrustRootStore() {
|
|
1013
|
+
const read = safeReadOwnedJsonArray(daemonActivationTrustRootsPath(), resolve(homedir()), MAX_TRUST_STORE_BYTES);
|
|
1014
|
+
if (!read.ok) {
|
|
1015
|
+
if (read.reason === 'missing')
|
|
1016
|
+
return { ok: true, records: [] };
|
|
1017
|
+
return { ok: false, reason: read.reason };
|
|
1018
|
+
}
|
|
1019
|
+
const records = [];
|
|
1020
|
+
const seen = new Set();
|
|
1021
|
+
for (const entry of read.value) {
|
|
1022
|
+
if (!validTrustRootRecord(entry) || seen.has(entry.keyId)) {
|
|
1023
|
+
return { ok: false, reason: 'invalid-trust-root-store' };
|
|
1024
|
+
}
|
|
1025
|
+
seen.add(entry.keyId);
|
|
1026
|
+
records.push(entry);
|
|
1027
|
+
}
|
|
1028
|
+
return { ok: true, records };
|
|
1029
|
+
}
|
|
1030
|
+
/**
|
|
1031
|
+
* The ONLY source of production trust roots: an operator-owned, protected
|
|
1032
|
+
* file the operator populates via `ashlr activation init`. Missing, unsafe,
|
|
1033
|
+
* or malformed → [] — identical denial to before this file loading existed.
|
|
1034
|
+
*/
|
|
1035
|
+
export function loadDaemonActivationTrustRoots() {
|
|
1036
|
+
const loaded = loadTrustRootStore();
|
|
1037
|
+
if (!loaded.ok)
|
|
1038
|
+
return [];
|
|
1039
|
+
return loaded.records
|
|
1040
|
+
.filter((record) => record.revokedAt === null)
|
|
1041
|
+
.map((record) => ({ keyId: record.keyId, publicKeyPem: record.publicKeyPem }));
|
|
1042
|
+
}
|
|
1043
|
+
/** Full trust-root status (incl. revoked history) for `ashlr activation status`. */
|
|
1044
|
+
export function daemonActivationTrustRootStatus() {
|
|
1045
|
+
return loadTrustRootStore();
|
|
1046
|
+
}
|
|
1047
|
+
function activeTrustRootKeyMap() {
|
|
1048
|
+
const loaded = loadTrustRootStore();
|
|
1049
|
+
if (!loaded.ok)
|
|
1050
|
+
return null;
|
|
1051
|
+
const active = loaded.records
|
|
1052
|
+
.filter((record) => record.revokedAt === null)
|
|
1053
|
+
.map((record) => ({ keyId: record.keyId, publicKeyPem: record.publicKeyPem }));
|
|
1054
|
+
return trustRootMap(active);
|
|
1055
|
+
}
|
|
1056
|
+
function validGrantRecord(value) {
|
|
1057
|
+
if (!isRecord(value) || !hasExactKeys(value, [
|
|
1058
|
+
'schemaVersion', 'grantId', 'keyId', 'issuedAt', 'expiresAt', 'scope', 'signature', 'revokedAt',
|
|
1059
|
+
]))
|
|
1060
|
+
return false;
|
|
1061
|
+
if (value['schemaVersion'] !== 1)
|
|
1062
|
+
return false;
|
|
1063
|
+
if (typeof value['grantId'] !== 'string' || !ID_RE.test(value['grantId']))
|
|
1064
|
+
return false;
|
|
1065
|
+
if (typeof value['keyId'] !== 'string' || !KEY_ID_RE.test(value['keyId']))
|
|
1066
|
+
return false;
|
|
1067
|
+
if (!validIsoTimestamp(value['issuedAt']) || !validIsoTimestamp(value['expiresAt']))
|
|
1068
|
+
return false;
|
|
1069
|
+
// validStoredScope (not validScope): a persisted STANDING grant may
|
|
1070
|
+
// predate a newly-added scope key by up to 30 days — see its doc comment.
|
|
1071
|
+
if (!validStoredScope(value['scope']))
|
|
1072
|
+
return false;
|
|
1073
|
+
if (typeof value['signature'] !== 'string')
|
|
1074
|
+
return false;
|
|
1075
|
+
try {
|
|
1076
|
+
const sig = Buffer.from(value['signature'], 'base64');
|
|
1077
|
+
if (sig.length !== 64 || sig.toString('base64') !== value['signature'])
|
|
1078
|
+
return false;
|
|
1079
|
+
}
|
|
1080
|
+
catch {
|
|
1081
|
+
return false;
|
|
1082
|
+
}
|
|
1083
|
+
if (value['revokedAt'] !== null && !validIsoTimestamp(value['revokedAt']))
|
|
1084
|
+
return false;
|
|
1085
|
+
return true;
|
|
1086
|
+
}
|
|
1087
|
+
function loadGrantStore() {
|
|
1088
|
+
const read = safeReadOwnedJsonArray(daemonActivationGrantsPath(), resolve(homedir()), MAX_GRANTS_BYTES);
|
|
1089
|
+
if (!read.ok) {
|
|
1090
|
+
if (read.reason === 'missing')
|
|
1091
|
+
return { ok: true, records: [] };
|
|
1092
|
+
return { ok: false, reason: read.reason };
|
|
1093
|
+
}
|
|
1094
|
+
const records = [];
|
|
1095
|
+
const seen = new Set();
|
|
1096
|
+
for (const entry of read.value) {
|
|
1097
|
+
if (!validGrantRecord(entry) || seen.has(entry.grantId)) {
|
|
1098
|
+
return { ok: false, reason: 'invalid-grant-store' };
|
|
1099
|
+
}
|
|
1100
|
+
seen.add(entry.grantId);
|
|
1101
|
+
records.push(entry);
|
|
1102
|
+
}
|
|
1103
|
+
return { ok: true, records };
|
|
1104
|
+
}
|
|
1105
|
+
/** Full standing-grant status (incl. revoked/expired history) for `ashlr activation status`. */
|
|
1106
|
+
export function daemonActivationGrantStatus() {
|
|
1107
|
+
return loadGrantStore();
|
|
1108
|
+
}
|
|
1109
|
+
function grantSigningCore(record) {
|
|
1110
|
+
return {
|
|
1111
|
+
schemaVersion: record.schemaVersion,
|
|
1112
|
+
grantId: record.grantId,
|
|
1113
|
+
keyId: record.keyId,
|
|
1114
|
+
issuedAt: record.issuedAt,
|
|
1115
|
+
expiresAt: record.expiresAt,
|
|
1116
|
+
scope: record.scope,
|
|
1117
|
+
};
|
|
1118
|
+
}
|
|
1119
|
+
function grantSigningBytes(core) {
|
|
1120
|
+
return Buffer.from(`${GRANT_SIGNING_DOMAIN}${canonicalizeDaemonActivationValue(core)}`, 'utf8');
|
|
1121
|
+
}
|
|
1122
|
+
export function signDaemonActivationGrant(input) {
|
|
1123
|
+
const core = grantSigningCore({
|
|
1124
|
+
schemaVersion: 1,
|
|
1125
|
+
grantId: input.grantId,
|
|
1126
|
+
keyId: input.keyId,
|
|
1127
|
+
issuedAt: input.issuedAt,
|
|
1128
|
+
expiresAt: input.expiresAt,
|
|
1129
|
+
scope: { ...input.scope },
|
|
1130
|
+
});
|
|
1131
|
+
const key = typeof input.privateKey === 'string' ? createPrivateKey(input.privateKey) : input.privateKey;
|
|
1132
|
+
if (key.type !== 'private' || key.asymmetricKeyType !== 'ed25519') {
|
|
1133
|
+
throw new Error('daemon activation grants require an Ed25519 private key');
|
|
1134
|
+
}
|
|
1135
|
+
const signature = sign(null, grantSigningBytes(core), key).toString('base64');
|
|
1136
|
+
const record = { ...core, signature, revokedAt: null };
|
|
1137
|
+
if (!validGrantRecord(record))
|
|
1138
|
+
throw new Error('invalid daemon activation grant record');
|
|
1139
|
+
return record;
|
|
1140
|
+
}
|
|
1141
|
+
function auditActivationEvent(action, summary, result) {
|
|
1142
|
+
audit({ action, repo: null, sandboxId: null, summary, result });
|
|
1143
|
+
}
|
|
1144
|
+
/**
|
|
1145
|
+
* Checks whether ANY non-revoked, non-expired, validly-signed standing grant
|
|
1146
|
+
* (from a non-revoked trust root) currently grants `scopeKey`. Unlike the
|
|
1147
|
+
* one-shot permit, standing grants are NOT bound to exact executable/
|
|
1148
|
+
* entrypoint/release-tree hashes — they must remain valid across routine
|
|
1149
|
+
* rebuilds within their expiry window. They ARE bound to kill-switch-off,
|
|
1150
|
+
* guard-health-healthy, and a non-revoked trust root, and always expire.
|
|
1151
|
+
*/
|
|
1152
|
+
/**
|
|
1153
|
+
* Shared core of `daemonActivationScopeGranted`. Returns the matched grant
|
|
1154
|
+
* RECORD (not just a boolean) so callers that need to act on it — currently
|
|
1155
|
+
* only the resident-standing startup path — can read its scope and grantId
|
|
1156
|
+
* without a second, redundant store read. Same checks, same order, as the
|
|
1157
|
+
* pre-refactor inline body of `daemonActivationScopeGranted`.
|
|
1158
|
+
*/
|
|
1159
|
+
function findGrantedRecord(scopeKey) {
|
|
1160
|
+
if (process.platform === 'win32') {
|
|
1161
|
+
return { granted: false, reason: 'activation-permit-v1-unsupported-on-windows' };
|
|
1162
|
+
}
|
|
1163
|
+
if (killSwitchOn())
|
|
1164
|
+
return { granted: false, reason: 'kill-switch-is-on' };
|
|
1165
|
+
let guardHealthy;
|
|
1166
|
+
try {
|
|
1167
|
+
guardHealthy = !diagnoseGuardHealth().blocked;
|
|
1168
|
+
}
|
|
1169
|
+
catch {
|
|
1170
|
+
return { granted: false, reason: 'guard-health-check-failed' };
|
|
1171
|
+
}
|
|
1172
|
+
if (!guardHealthy)
|
|
1173
|
+
return { granted: false, reason: 'guard-health-degraded' };
|
|
1174
|
+
const trusted = activeTrustRootKeyMap();
|
|
1175
|
+
if (!trusted || trusted.size === 0)
|
|
1176
|
+
return { granted: false, reason: 'no-trusted-activation-roots' };
|
|
1177
|
+
const grants = loadGrantStore();
|
|
1178
|
+
if (!grants.ok)
|
|
1179
|
+
return { granted: false, reason: `activation-grant-store-${grants.reason}` };
|
|
1180
|
+
const nowMs = Date.now();
|
|
1181
|
+
for (const record of grants.records) {
|
|
1182
|
+
if (record.revokedAt !== null)
|
|
1183
|
+
continue;
|
|
1184
|
+
if (record.scope[scopeKey] !== true)
|
|
1185
|
+
continue;
|
|
1186
|
+
const publicKey = trusted.get(record.keyId);
|
|
1187
|
+
if (!publicKey)
|
|
1188
|
+
continue;
|
|
1189
|
+
if (Date.parse(record.expiresAt) <= nowMs)
|
|
1190
|
+
continue;
|
|
1191
|
+
let signature;
|
|
1192
|
+
try {
|
|
1193
|
+
signature = Buffer.from(record.signature, 'base64');
|
|
1194
|
+
}
|
|
1195
|
+
catch {
|
|
1196
|
+
continue;
|
|
1197
|
+
}
|
|
1198
|
+
if (!verify(null, grantSigningBytes(grantSigningCore(record)), publicKey, signature))
|
|
1199
|
+
continue;
|
|
1200
|
+
return { granted: true, record };
|
|
1201
|
+
}
|
|
1202
|
+
return { granted: false, reason: 'no-matching-standing-grant' };
|
|
1203
|
+
}
|
|
1204
|
+
export function daemonActivationScopeGranted(scopeKey) {
|
|
1205
|
+
const found = findGrantedRecord(scopeKey);
|
|
1206
|
+
return found.granted
|
|
1207
|
+
? { granted: true, reason: `granted-by-${found.record.grantId}` }
|
|
1208
|
+
: { granted: false, reason: found.reason };
|
|
1209
|
+
}
|
|
1210
|
+
/**
|
|
1211
|
+
* The goal and simple conductors consult this every cycle. Real, gated,
|
|
1212
|
+
* revocable authority: granted only after `ashlr activation init` +
|
|
1213
|
+
* `ashlr activation grant conductor`. Default (no grant) is denied, exactly
|
|
1214
|
+
* as before this mechanism existed. Every check is audited.
|
|
1215
|
+
*/
|
|
1216
|
+
export function liveConductorActivationAuthorized() {
|
|
1217
|
+
const check = daemonActivationScopeGranted('conductor');
|
|
1218
|
+
auditActivationEvent('daemon-activation:conductor-check', `conductor activation ${check.granted ? 'authorized' : 'denied'}: ${check.reason}`, check.granted ? 'ok' : 'refused');
|
|
1219
|
+
return check.granted;
|
|
1220
|
+
}
|
|
1221
|
+
// ---------------------------------------------------------------------------
|
|
1222
|
+
// Operator authority mutation — init / grant / revoke. Called from
|
|
1223
|
+
// `src/cli/activation.ts`; kept here so all signing/writing stays alongside
|
|
1224
|
+
// the verification it must satisfy.
|
|
1225
|
+
// ---------------------------------------------------------------------------
|
|
1226
|
+
export function generateDaemonActivationOperatorKeypair() {
|
|
1227
|
+
const pair = generateKeyPairSync('ed25519');
|
|
1228
|
+
return {
|
|
1229
|
+
privateKeyPem: pair.privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(),
|
|
1230
|
+
publicKeyPem: pair.publicKey.export({ type: 'spki', format: 'pem' }).toString(),
|
|
1231
|
+
};
|
|
1232
|
+
}
|
|
1233
|
+
function sanitizeKeyId(candidate) {
|
|
1234
|
+
const cleaned = candidate.replace(/[^A-Za-z0-9._-]/gu, '-').slice(0, 55);
|
|
1235
|
+
const seeded = cleaned.length > 0 && /^[A-Za-z0-9]/u.test(cleaned) ? cleaned : `k${cleaned}`;
|
|
1236
|
+
return seeded.slice(0, 63) || 'operator';
|
|
1237
|
+
}
|
|
1238
|
+
/**
|
|
1239
|
+
* Generates an Ed25519 keypair on THIS machine (private key never leaves it,
|
|
1240
|
+
* written 0600 under an 0700 directory) and registers the public half as a
|
|
1241
|
+
* new active trust root. Refuses to silently clobber an existing active
|
|
1242
|
+
* root or key file unless `force` is set (key rotation: the new root is
|
|
1243
|
+
* ADDED, not swapped in place, so grants signed by an older still-active
|
|
1244
|
+
* root keep working until explicitly revoked).
|
|
1245
|
+
*/
|
|
1246
|
+
export function daemonActivationInit(input) {
|
|
1247
|
+
if (process.platform === 'win32') {
|
|
1248
|
+
return { ok: false, reason: 'activation-permit-v1-unsupported-on-windows' };
|
|
1249
|
+
}
|
|
1250
|
+
return withActivationStoreLock(() => {
|
|
1251
|
+
const existing = loadTrustRootStore();
|
|
1252
|
+
if (!existing.ok)
|
|
1253
|
+
return { ok: false, reason: existing.reason };
|
|
1254
|
+
const activeCount = existing.records.filter((record) => record.revokedAt === null).length;
|
|
1255
|
+
if (activeCount > 0 && input.force !== true) {
|
|
1256
|
+
return { ok: false, reason: 'trust-root-already-initialized' };
|
|
1257
|
+
}
|
|
1258
|
+
const keyPath = daemonActivationOperatorKeyPath();
|
|
1259
|
+
if (pathEntryPresent(keyPath) && input.force !== true) {
|
|
1260
|
+
return { ok: false, reason: 'operator-key-file-already-exists' };
|
|
1261
|
+
}
|
|
1262
|
+
const { privateKeyPem, publicKeyPem } = generateDaemonActivationOperatorKeypair();
|
|
1263
|
+
const keyId = sanitizeKeyId(`${hostname()}-${randomBytes(4).toString('hex')}`);
|
|
1264
|
+
const now = new Date().toISOString();
|
|
1265
|
+
const record = {
|
|
1266
|
+
keyId,
|
|
1267
|
+
publicKeyPem,
|
|
1268
|
+
createdAt: now,
|
|
1269
|
+
revokedAt: null,
|
|
1270
|
+
label: (input.label ?? '').slice(0, MAX_LABEL_LENGTH),
|
|
1271
|
+
};
|
|
1272
|
+
const home = resolve(homedir());
|
|
1273
|
+
assurePrivateDirectory(daemonActivationDirPath(), home);
|
|
1274
|
+
const noFollow = typeof fsConstants.O_NOFOLLOW === 'number' ? fsConstants.O_NOFOLLOW : 0;
|
|
1275
|
+
let fd;
|
|
1276
|
+
try {
|
|
1277
|
+
if (pathEntryPresent(keyPath))
|
|
1278
|
+
unlinkSync(keyPath);
|
|
1279
|
+
fd = openSync(keyPath, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | noFollow, PRIVATE_FILE_MODE);
|
|
1280
|
+
const initial = fstatSync(fd, { bigint: true });
|
|
1281
|
+
if (!safeOwnedFile(initial) || initial.size !== 0n)
|
|
1282
|
+
throw new Error('unsafe-operator-key-file');
|
|
1283
|
+
fchmodSync(fd, PRIVATE_FILE_MODE);
|
|
1284
|
+
writeAll(fd, Buffer.from(privateKeyPem, 'utf8'));
|
|
1285
|
+
fsyncSync(fd);
|
|
1286
|
+
closeSync(fd);
|
|
1287
|
+
fd = undefined;
|
|
1288
|
+
const keyAssurance = assurePrivateStoragePath(keyPath, 'file', 'secure-created', { anchorPath: home });
|
|
1289
|
+
if (!keyAssurance.ok)
|
|
1290
|
+
throw new Error(`unsafe-operator-key-path:${keyAssurance.reason}`);
|
|
1291
|
+
fsyncDirectory(dirname(keyPath));
|
|
1292
|
+
}
|
|
1293
|
+
catch (error) {
|
|
1294
|
+
return { ok: false, reason: error.message || 'operator-key-write-failed' };
|
|
1295
|
+
}
|
|
1296
|
+
finally {
|
|
1297
|
+
if (fd !== undefined) {
|
|
1298
|
+
try {
|
|
1299
|
+
closeSync(fd);
|
|
1300
|
+
}
|
|
1301
|
+
catch { /* best-effort */ }
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
writeCanonicalJsonArrayFileAtomic(daemonActivationTrustRootsPath(), [...existing.records, record], home);
|
|
1305
|
+
auditActivationEvent('daemon-activation:init', `trust root registered: keyId=${keyId} label=${JSON.stringify(record.label)} rotated=${activeCount > 0}`, 'ok');
|
|
1306
|
+
return { ok: true, keyId, publicKeyPem, rotated: activeCount > 0 };
|
|
1307
|
+
});
|
|
1308
|
+
}
|
|
1309
|
+
export function daemonActivationRevokeTrustRoot(keyId) {
|
|
1310
|
+
if (!KEY_ID_RE.test(keyId))
|
|
1311
|
+
return { ok: false, reason: 'invalid-key-id' };
|
|
1312
|
+
return withActivationStoreLock(() => {
|
|
1313
|
+
const existing = loadTrustRootStore();
|
|
1314
|
+
if (!existing.ok)
|
|
1315
|
+
return { ok: false, reason: existing.reason };
|
|
1316
|
+
const index = existing.records.findIndex((record) => record.keyId === keyId);
|
|
1317
|
+
if (index === -1)
|
|
1318
|
+
return { ok: false, reason: 'trust-root-not-found' };
|
|
1319
|
+
if (existing.records[index].revokedAt !== null) {
|
|
1320
|
+
auditActivationEvent('daemon-activation:revoke-root', `trust root already revoked: keyId=${keyId}`, 'ok');
|
|
1321
|
+
return { ok: true };
|
|
1322
|
+
}
|
|
1323
|
+
const revokedAt = new Date().toISOString();
|
|
1324
|
+
const updated = existing.records.map((record, i) => (i === index ? { ...record, revokedAt } : record));
|
|
1325
|
+
writeCanonicalJsonArrayFileAtomic(daemonActivationTrustRootsPath(), updated, resolve(homedir()));
|
|
1326
|
+
auditActivationEvent('daemon-activation:revoke-root', `trust root revoked: keyId=${keyId}`, 'ok');
|
|
1327
|
+
return { ok: true };
|
|
1328
|
+
});
|
|
1329
|
+
}
|
|
1330
|
+
export function daemonActivationRevokeGrant(grantId) {
|
|
1331
|
+
if (!ID_RE.test(grantId))
|
|
1332
|
+
return { ok: false, reason: 'invalid-grant-id' };
|
|
1333
|
+
return withActivationStoreLock(() => {
|
|
1334
|
+
const existing = loadGrantStore();
|
|
1335
|
+
if (!existing.ok)
|
|
1336
|
+
return { ok: false, reason: existing.reason };
|
|
1337
|
+
const index = existing.records.findIndex((record) => record.grantId === grantId);
|
|
1338
|
+
if (index === -1)
|
|
1339
|
+
return { ok: false, reason: 'grant-not-found' };
|
|
1340
|
+
if (existing.records[index].revokedAt !== null) {
|
|
1341
|
+
auditActivationEvent('daemon-activation:revoke-grant', `standing grant already revoked: grantId=${grantId}`, 'ok');
|
|
1342
|
+
return { ok: true };
|
|
1343
|
+
}
|
|
1344
|
+
const revokedAt = new Date().toISOString();
|
|
1345
|
+
const updated = existing.records.map((record, i) => (i === index ? { ...record, revokedAt } : record));
|
|
1346
|
+
writeCanonicalJsonArrayFileAtomic(daemonActivationGrantsPath(), updated, resolve(homedir()));
|
|
1347
|
+
auditActivationEvent('daemon-activation:revoke-grant', `standing grant revoked: grantId=${grantId}`, 'ok');
|
|
1348
|
+
return { ok: true };
|
|
1349
|
+
});
|
|
1350
|
+
}
|
|
1351
|
+
/** Mints a signed, expiring standing grant (conductor/automerge/repair/deploy/install/proposalOnly). */
|
|
1352
|
+
export function daemonActivationMintStandingGrant(input) {
|
|
1353
|
+
if (process.platform === 'win32') {
|
|
1354
|
+
return { ok: false, reason: 'activation-permit-v1-unsupported-on-windows' };
|
|
1355
|
+
}
|
|
1356
|
+
if (!Number.isFinite(input.ttlMs) || input.ttlMs <= 0 || input.ttlMs > MAX_STANDING_GRANT_VALIDITY_MS) {
|
|
1357
|
+
return { ok: false, reason: 'invalid-grant-ttl' };
|
|
1358
|
+
}
|
|
1359
|
+
if (!validScope(input.scope))
|
|
1360
|
+
return { ok: false, reason: 'invalid-grant-scope' };
|
|
1361
|
+
return withActivationStoreLock(() => {
|
|
1362
|
+
const trustStore = loadTrustRootStore();
|
|
1363
|
+
if (!trustStore.ok)
|
|
1364
|
+
return { ok: false, reason: trustStore.reason };
|
|
1365
|
+
const active = trustStore.records.filter((record) => record.revokedAt === null);
|
|
1366
|
+
if (active.length === 0)
|
|
1367
|
+
return { ok: false, reason: 'no-trusted-activation-roots' };
|
|
1368
|
+
if (active.length > 1)
|
|
1369
|
+
return { ok: false, reason: 'ambiguous-signing-key-run-activation-status' };
|
|
1370
|
+
const root = active[0];
|
|
1371
|
+
let privateKeyPem;
|
|
1372
|
+
try {
|
|
1373
|
+
privateKeyPem = readOperatorPrivateKeyPem();
|
|
1374
|
+
}
|
|
1375
|
+
catch (error) {
|
|
1376
|
+
return { ok: false, reason: error.message || 'operator-key-unreadable' };
|
|
1377
|
+
}
|
|
1378
|
+
const existingGrants = loadGrantStore();
|
|
1379
|
+
if (!existingGrants.ok)
|
|
1380
|
+
return { ok: false, reason: existingGrants.reason };
|
|
1381
|
+
const now = Date.now();
|
|
1382
|
+
const record = signDaemonActivationGrant({
|
|
1383
|
+
grantId: randomBytes(16).toString('hex'),
|
|
1384
|
+
keyId: root.keyId,
|
|
1385
|
+
issuedAt: new Date(now).toISOString(),
|
|
1386
|
+
expiresAt: new Date(now + input.ttlMs).toISOString(),
|
|
1387
|
+
scope: input.scope,
|
|
1388
|
+
privateKey: privateKeyPem,
|
|
1389
|
+
});
|
|
1390
|
+
writeCanonicalJsonArrayFileAtomic(daemonActivationGrantsPath(), [...existingGrants.records, record], resolve(homedir()));
|
|
1391
|
+
auditActivationEvent('daemon-activation:grant', `standing grant minted: grantId=${record.grantId} keyId=${root.keyId} `
|
|
1392
|
+
+ `scope=${canonicalizeDaemonActivationValue(record.scope)} expiresAt=${record.expiresAt}`, 'ok');
|
|
1393
|
+
return { ok: true, record };
|
|
1394
|
+
});
|
|
1395
|
+
}
|
|
1396
|
+
/**
|
|
1397
|
+
* Mints and pins a single-use activation permit (once/resident scopes) to
|
|
1398
|
+
* daemonActivationPermitPath(), ready for `ashlr daemon start` to consume.
|
|
1399
|
+
* Signs against THIS exact machine's current build (executable/entrypoint/
|
|
1400
|
+
* release-tree hashes) — an operator runs this locally, immediately before
|
|
1401
|
+
* starting the daemon it authorizes.
|
|
1402
|
+
*/
|
|
1403
|
+
export function daemonActivationMintOneShotPermit(input) {
|
|
1404
|
+
if (process.platform === 'win32') {
|
|
1405
|
+
return { ok: false, reason: 'activation-permit-v1-unsupported-on-windows' };
|
|
1406
|
+
}
|
|
1407
|
+
if (!validScope(input.scope))
|
|
1408
|
+
return { ok: false, reason: 'invalid-grant-scope' };
|
|
1409
|
+
return withActivationStoreLock(() => {
|
|
1410
|
+
const trustStore = loadTrustRootStore();
|
|
1411
|
+
if (!trustStore.ok)
|
|
1412
|
+
return { ok: false, reason: trustStore.reason };
|
|
1413
|
+
const active = trustStore.records.filter((record) => record.revokedAt === null);
|
|
1414
|
+
if (active.length === 0)
|
|
1415
|
+
return { ok: false, reason: 'no-trusted-activation-roots' };
|
|
1416
|
+
if (active.length > 1)
|
|
1417
|
+
return { ok: false, reason: 'ambiguous-signing-key-run-activation-status' };
|
|
1418
|
+
const root = active[0];
|
|
1419
|
+
let privateKeyPem;
|
|
1420
|
+
try {
|
|
1421
|
+
privateKeyPem = readOperatorPrivateKeyPem();
|
|
1422
|
+
}
|
|
1423
|
+
catch (error) {
|
|
1424
|
+
return { ok: false, reason: error.message || 'operator-key-unreadable' };
|
|
1425
|
+
}
|
|
1426
|
+
let configSnapshot;
|
|
1427
|
+
try {
|
|
1428
|
+
configSnapshot = strictConfigSnapshot(input.cfg);
|
|
1429
|
+
}
|
|
1430
|
+
catch {
|
|
1431
|
+
return { ok: false, reason: 'activation-config-not-strict-json' };
|
|
1432
|
+
}
|
|
1433
|
+
// Cheap, context-independent check first: no point collecting a full
|
|
1434
|
+
// runtime context (file hashing, release-tree walk) if there is already
|
|
1435
|
+
// a pending permit occupying the single fixed path.
|
|
1436
|
+
const permitPath = daemonActivationPermitPath();
|
|
1437
|
+
if (pathEntryPresent(permitPath))
|
|
1438
|
+
return { ok: false, reason: 'activation-permit-already-pending' };
|
|
1439
|
+
let context;
|
|
1440
|
+
try {
|
|
1441
|
+
context = collectRuntimeContext(configSnapshot);
|
|
1442
|
+
}
|
|
1443
|
+
catch (error) {
|
|
1444
|
+
return { ok: false, reason: `runtime-context-unavailable:${error.message}` };
|
|
1445
|
+
}
|
|
1446
|
+
if (!context.killSwitchOff)
|
|
1447
|
+
return { ok: false, reason: 'kill-switch-is-on' };
|
|
1448
|
+
if (!context.guardHealthHealthy)
|
|
1449
|
+
return { ok: false, reason: 'guard-health-degraded' };
|
|
1450
|
+
if (!validBuildIdentity(context.buildIdentity)) {
|
|
1451
|
+
// Most commonly: a `git` provenance build with a dirty working tree
|
|
1452
|
+
// (dist/ was built from uncommitted changes). Fail closed — a permit
|
|
1453
|
+
// bound to an untrusted build identity would never verify anyway.
|
|
1454
|
+
return { ok: false, reason: 'runtime-build-identity-untrusted' };
|
|
1455
|
+
}
|
|
1456
|
+
const now = Date.now();
|
|
1457
|
+
const permitId = randomBytes(16).toString('hex');
|
|
1458
|
+
let payload;
|
|
1459
|
+
let envelope;
|
|
1460
|
+
try {
|
|
1461
|
+
payload = buildDaemonActivationPermitPayload({
|
|
1462
|
+
permitId,
|
|
1463
|
+
nonce: randomBytes(32).toString('hex'),
|
|
1464
|
+
keyId: root.keyId,
|
|
1465
|
+
issuedAt: new Date(now).toISOString(),
|
|
1466
|
+
expiresAt: new Date(now + MAX_VALIDITY_MS).toISOString(),
|
|
1467
|
+
scope: input.scope,
|
|
1468
|
+
context,
|
|
1469
|
+
});
|
|
1470
|
+
envelope = signDaemonActivationPermit(payload, privateKeyPem);
|
|
1471
|
+
}
|
|
1472
|
+
catch (error) {
|
|
1473
|
+
return { ok: false, reason: error.message || 'activation-permit-build-failed' };
|
|
1474
|
+
}
|
|
1475
|
+
const home = resolve(homedir());
|
|
1476
|
+
assurePrivateDirectory(dirname(permitPath), home);
|
|
1477
|
+
const noFollow = typeof fsConstants.O_NOFOLLOW === 'number' ? fsConstants.O_NOFOLLOW : 0;
|
|
1478
|
+
let fd;
|
|
1479
|
+
try {
|
|
1480
|
+
fd = openSync(permitPath, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | noFollow, PRIVATE_FILE_MODE);
|
|
1481
|
+
const initial = fstatSync(fd, { bigint: true });
|
|
1482
|
+
if (!safeOwnedFile(initial) || initial.size !== 0n)
|
|
1483
|
+
throw new Error('unsafe-activation-permit-file');
|
|
1484
|
+
fchmodSync(fd, PRIVATE_FILE_MODE);
|
|
1485
|
+
const bytes = Buffer.from(`${canonicalizeDaemonActivationValue(envelope)}\n`, 'utf8');
|
|
1486
|
+
writeAll(fd, bytes);
|
|
1487
|
+
fsyncSync(fd);
|
|
1488
|
+
closeSync(fd);
|
|
1489
|
+
fd = undefined;
|
|
1490
|
+
const assurance = assurePrivateStoragePath(permitPath, 'file', 'secure-created', { anchorPath: home });
|
|
1491
|
+
if (!assurance.ok)
|
|
1492
|
+
throw new Error(`unsafe-activation-permit-path:${assurance.reason}`);
|
|
1493
|
+
fsyncDirectory(dirname(permitPath));
|
|
1494
|
+
}
|
|
1495
|
+
catch (error) {
|
|
1496
|
+
return { ok: false, reason: error.message || 'activation-permit-write-failed' };
|
|
1497
|
+
}
|
|
1498
|
+
finally {
|
|
1499
|
+
if (fd !== undefined) {
|
|
1500
|
+
try {
|
|
1501
|
+
closeSync(fd);
|
|
1502
|
+
}
|
|
1503
|
+
catch { /* best-effort */ }
|
|
1504
|
+
}
|
|
1505
|
+
}
|
|
1506
|
+
auditActivationEvent('daemon-activation:grant', `one-shot permit minted: permitId=${permitId} keyId=${root.keyId} `
|
|
1507
|
+
+ `scope=${canonicalizeDaemonActivationValue(input.scope)} expiresAt=${payload.expiresAt}`, 'ok');
|
|
1508
|
+
return { ok: true, permitId, permitPath, expiresAt: payload.expiresAt };
|
|
1509
|
+
});
|
|
1510
|
+
}
|
|
1511
|
+
// ---------------------------------------------------------------------------
|
|
1512
|
+
// One-shot permit inspection / consumption — `ashlr daemon start` [--once].
|
|
1513
|
+
// ---------------------------------------------------------------------------
|
|
1514
|
+
function activationReadiness(state, reason, trustRootCount, extra) {
|
|
678
1515
|
return {
|
|
679
1516
|
schemaVersion: 1,
|
|
680
1517
|
policyVersion: POLICY_VERSION,
|
|
@@ -682,112 +1519,234 @@ function activationReadiness(state, reason, trustRootCount) {
|
|
|
682
1519
|
sourceState: state === 'degraded' ? 'degraded' : 'healthy',
|
|
683
1520
|
state,
|
|
684
1521
|
commandEligible: state === 'ready',
|
|
685
|
-
requestedShape: 'proposal-once',
|
|
1522
|
+
requestedShape: extra?.requestedShape ?? 'proposal-once',
|
|
686
1523
|
trustRootCount,
|
|
687
|
-
residentAuthorized: false,
|
|
688
|
-
|
|
689
|
-
|
|
1524
|
+
residentAuthorized: extra?.residentAuthorized ?? false,
|
|
1525
|
+
residentStandingAuthorized: extra?.residentStandingAuthorized ?? false,
|
|
1526
|
+
installAuthorized: extra?.installAuthorized ?? false,
|
|
1527
|
+
repairAuthorized: extra?.repairAuthorized ?? false,
|
|
690
1528
|
reason,
|
|
691
1529
|
};
|
|
692
1530
|
}
|
|
693
|
-
|
|
1531
|
+
/** Maps the internal scope-key shape to the DaemonActivationReadiness label. */
|
|
1532
|
+
function requestedShapeLabel(shape) {
|
|
1533
|
+
return shape === 'once' ? 'proposal-once' : 'resident';
|
|
1534
|
+
}
|
|
1535
|
+
/**
|
|
1536
|
+
* What activation shape (if any) `opts` is structurally eligible to have
|
|
1537
|
+
* authorized at all — independent of whether any permit/trust root exists.
|
|
1538
|
+
* `null` means "denied before ever reading a permit," matching the pre-M470
|
|
1539
|
+
* behavior for drain modes and any dry-run other than {once:true,dryRun:true}.
|
|
1540
|
+
*/
|
|
1541
|
+
function requestedActivationShape(opts) {
|
|
1542
|
+
if (opts.drain !== undefined || opts.drainLimit !== undefined)
|
|
1543
|
+
return null;
|
|
1544
|
+
if (opts.dryRun === true)
|
|
1545
|
+
return null;
|
|
1546
|
+
if (opts.once === true)
|
|
1547
|
+
return 'once';
|
|
1548
|
+
if (opts.once === false)
|
|
1549
|
+
return 'resident';
|
|
1550
|
+
return null;
|
|
1551
|
+
}
|
|
1552
|
+
function needsPermit(opts) {
|
|
1553
|
+
return !(opts.once === true && opts.dryRun === true);
|
|
1554
|
+
}
|
|
1555
|
+
/**
|
|
1556
|
+
* Read-only diagnostic (never authorizes, never consumes) for `ashlr
|
|
1557
|
+
* activation status`: peeks the pending one-shot permit, recomputes the
|
|
1558
|
+
* current runtime context, and reports exactly which binding field(s)
|
|
1559
|
+
* differ and what the two values were. `permit-runtime-binding-mismatch`
|
|
1560
|
+
* alone can't tell an operator what to fix — this can.
|
|
1561
|
+
*/
|
|
1562
|
+
export function daemonActivationDiagnosePendingPermitBindings(cfg) {
|
|
1563
|
+
const permitPath = daemonActivationPermitPath();
|
|
1564
|
+
if (process.platform === 'win32') {
|
|
1565
|
+
return { ok: false, reason: 'activation-permit-v1-unsupported-on-windows' };
|
|
1566
|
+
}
|
|
1567
|
+
let pinned;
|
|
694
1568
|
try {
|
|
695
|
-
|
|
696
|
-
return true;
|
|
1569
|
+
pinned = openPinnedPermit(permitPath, resolve(homedir()));
|
|
697
1570
|
}
|
|
698
1571
|
catch (error) {
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
1572
|
+
return {
|
|
1573
|
+
ok: false,
|
|
1574
|
+
reason: error.code === 'ENOENT'
|
|
1575
|
+
? 'activation-permit-missing'
|
|
1576
|
+
: 'activation-permit-inspection-failed',
|
|
1577
|
+
};
|
|
1578
|
+
}
|
|
1579
|
+
try {
|
|
1580
|
+
let envelope;
|
|
1581
|
+
try {
|
|
1582
|
+
envelope = JSON.parse(pinned.text);
|
|
1583
|
+
}
|
|
1584
|
+
catch {
|
|
1585
|
+
return { ok: false, reason: 'invalid-permit-json' };
|
|
1586
|
+
}
|
|
1587
|
+
const parsed = parseEnvelope(envelope);
|
|
1588
|
+
if (!parsed)
|
|
1589
|
+
return { ok: false, reason: 'invalid-permit-schema' };
|
|
1590
|
+
let configSnapshot;
|
|
1591
|
+
try {
|
|
1592
|
+
configSnapshot = strictConfigSnapshot(cfg);
|
|
1593
|
+
}
|
|
1594
|
+
catch {
|
|
1595
|
+
return { ok: false, reason: 'activation-config-not-strict-json' };
|
|
1596
|
+
}
|
|
1597
|
+
let context;
|
|
1598
|
+
try {
|
|
1599
|
+
context = collectRuntimeContext(configSnapshot);
|
|
1600
|
+
}
|
|
1601
|
+
catch (error) {
|
|
1602
|
+
return { ok: false, reason: `runtime-context-unavailable:${error.message}` };
|
|
1603
|
+
}
|
|
1604
|
+
if (!context.killSwitchOff)
|
|
1605
|
+
return { ok: false, reason: 'kill-switch-is-on' };
|
|
1606
|
+
if (!context.guardHealthHealthy)
|
|
1607
|
+
return { ok: false, reason: 'guard-health-degraded' };
|
|
1608
|
+
const expectedBindings = {
|
|
1609
|
+
configDigest: context.configDigest,
|
|
1610
|
+
buildIdentity: context.buildIdentity,
|
|
1611
|
+
executable: context.executable,
|
|
1612
|
+
entrypoint: context.entrypoint,
|
|
1613
|
+
releaseTree: context.releaseTree,
|
|
1614
|
+
authorityStateDigest: context.authorityStateDigest,
|
|
1615
|
+
killSwitch: 'off',
|
|
1616
|
+
guardHealth: 'healthy',
|
|
1617
|
+
};
|
|
1618
|
+
const mismatchedKeys = bindingFieldMismatches(parsed.payload.bindings, expectedBindings);
|
|
1619
|
+
if (mismatchedKeys.length === 0)
|
|
1620
|
+
return { ok: true, reason: 'bindings-match' };
|
|
1621
|
+
const mismatches = {};
|
|
1622
|
+
for (const key of mismatchedKeys) {
|
|
1623
|
+
mismatches[key] = { signed: parsed.payload.bindings[key], current: expectedBindings[key] };
|
|
1624
|
+
}
|
|
1625
|
+
return { ok: false, reason: 'permit-runtime-binding-mismatch', mismatches };
|
|
1626
|
+
}
|
|
1627
|
+
finally {
|
|
1628
|
+
try {
|
|
1629
|
+
closeSync(pinned.fd);
|
|
1630
|
+
}
|
|
1631
|
+
catch { /* diagnostic-only; best-effort close */ }
|
|
702
1632
|
}
|
|
703
1633
|
}
|
|
704
|
-
function inspectPinnedActivationPermit(pinned, permitPath, cfg, trustRoots, suppliedContext) {
|
|
1634
|
+
function inspectPinnedActivationPermit(pinned, permitPath, cfg, trustRoots, suppliedContext, requestedShape, standingAuth) {
|
|
1635
|
+
const shapeLabel = requestedShapeLabel(requestedShape);
|
|
1636
|
+
const extraBase = {
|
|
1637
|
+
requestedShape: shapeLabel,
|
|
1638
|
+
installAuthorized: standingAuth.install,
|
|
1639
|
+
repairAuthorized: standingAuth.repair,
|
|
1640
|
+
residentStandingAuthorized: standingAuth.residentStanding,
|
|
1641
|
+
};
|
|
705
1642
|
let envelope;
|
|
706
1643
|
try {
|
|
707
1644
|
envelope = JSON.parse(pinned.text);
|
|
708
1645
|
}
|
|
709
1646
|
catch {
|
|
710
|
-
return activationReadiness('degraded', 'invalid-permit-json', trustRoots.length);
|
|
1647
|
+
return activationReadiness('degraded', 'invalid-permit-json', trustRoots.length, extraBase);
|
|
711
1648
|
}
|
|
712
1649
|
if (`${canonicalizeDaemonActivationValue(envelope)}\n` !== pinned.text) {
|
|
713
|
-
return activationReadiness('degraded', 'noncanonical-permit-encoding', trustRoots.length);
|
|
1650
|
+
return activationReadiness('degraded', 'noncanonical-permit-encoding', trustRoots.length, extraBase);
|
|
714
1651
|
}
|
|
715
1652
|
const parsed = parseEnvelope(envelope);
|
|
716
1653
|
if (!parsed) {
|
|
717
|
-
return activationReadiness('degraded', 'invalid-permit-schema', trustRoots.length);
|
|
1654
|
+
return activationReadiness('degraded', 'invalid-permit-schema', trustRoots.length, extraBase);
|
|
718
1655
|
}
|
|
719
1656
|
let configSnapshot;
|
|
720
1657
|
try {
|
|
721
1658
|
configSnapshot = strictConfigSnapshot(cfg);
|
|
722
1659
|
}
|
|
723
1660
|
catch {
|
|
724
|
-
return activationReadiness('degraded', 'activation-config-not-strict-json', trustRoots.length);
|
|
1661
|
+
return activationReadiness('degraded', 'activation-config-not-strict-json', trustRoots.length, extraBase);
|
|
725
1662
|
}
|
|
726
1663
|
const context = suppliedContext ?? collectRuntimeContext(configSnapshot);
|
|
727
1664
|
if (context.configDigest !== daemonActivationConfigDigest(configSnapshot)) {
|
|
728
|
-
return activationReadiness('blocked', 'runtime-config-digest-mismatch', trustRoots.length);
|
|
1665
|
+
return activationReadiness('blocked', 'runtime-config-digest-mismatch', trustRoots.length, extraBase);
|
|
729
1666
|
}
|
|
730
1667
|
const verification = verifyDaemonActivationPermit(parsed, context, trustRoots);
|
|
731
1668
|
if (!verification.ok || !verification.permitId) {
|
|
732
|
-
return activationReadiness('blocked', verification.reason, trustRoots.length);
|
|
1669
|
+
return activationReadiness('blocked', verification.reason, trustRoots.length, extraBase);
|
|
1670
|
+
}
|
|
1671
|
+
const residentAuthorized = parsed.payload.scope.resident === true;
|
|
1672
|
+
if (parsed.payload.scope[requestedShape] !== true) {
|
|
1673
|
+
return activationReadiness('blocked', 'activation-permit-scope-does-not-grant-requested-shape', trustRoots.length, { ...extraBase, residentAuthorized });
|
|
733
1674
|
}
|
|
734
1675
|
const nonceDigest = sha256(parsed.payload.nonce);
|
|
735
1676
|
if (pathEntryPresent(daemonActivationReceiptPath(verification.permitId))
|
|
736
1677
|
|| pathEntryPresent(daemonActivationNonceReceiptPath(nonceDigest))) {
|
|
737
|
-
return activationReadiness('blocked', 'activation-permit-already-consumed', trustRoots.length);
|
|
1678
|
+
return activationReadiness('blocked', 'activation-permit-already-consumed', trustRoots.length, { ...extraBase, residentAuthorized });
|
|
738
1679
|
}
|
|
739
1680
|
const openedAfterInspection = fstatSync(pinned.fd, { bigint: true });
|
|
740
1681
|
const namedAfterInspection = lstatSync(permitPath, { bigint: true });
|
|
741
1682
|
if (!sameFileSnapshot(pinned.stat, openedAfterInspection)
|
|
742
1683
|
|| !sameFileSnapshot(openedAfterInspection, namedAfterInspection)) {
|
|
743
|
-
return activationReadiness('degraded', 'activation-permit-changed-during-inspection', trustRoots.length);
|
|
1684
|
+
return activationReadiness('degraded', 'activation-permit-changed-during-inspection', trustRoots.length, { ...extraBase, residentAuthorized });
|
|
744
1685
|
}
|
|
745
|
-
|
|
1686
|
+
const readyReason = requestedShape === 'once' ? 'valid-proposal-once-permit' : 'valid-resident-permit';
|
|
1687
|
+
return activationReadiness('ready', readyReason, trustRoots.length, { ...extraBase, residentAuthorized });
|
|
746
1688
|
}
|
|
747
1689
|
function inspectWithAuthority(cfg, opts, trustRoots, suppliedContext, platform = process.platform) {
|
|
1690
|
+
const standingAuth = {
|
|
1691
|
+
install: daemonActivationScopeGranted('install').granted,
|
|
1692
|
+
repair: daemonActivationScopeGranted('repair').granted,
|
|
1693
|
+
residentStanding: daemonActivationScopeGranted('residentStanding').granted,
|
|
1694
|
+
};
|
|
1695
|
+
const standingAuthorizedFields = {
|
|
1696
|
+
installAuthorized: standingAuth.install,
|
|
1697
|
+
repairAuthorized: standingAuth.repair,
|
|
1698
|
+
residentStandingAuthorized: standingAuth.residentStanding,
|
|
1699
|
+
};
|
|
748
1700
|
if (!needsPermit(opts)) {
|
|
749
|
-
return activationReadiness('ready', 'dry-run-once-does-not-require-activation-permit', trustRoots.length);
|
|
1701
|
+
return activationReadiness('ready', 'dry-run-once-does-not-require-activation-permit', trustRoots.length, standingAuthorizedFields);
|
|
750
1702
|
}
|
|
751
|
-
|
|
752
|
-
|
|
1703
|
+
const requestedShape = requestedActivationShape(opts);
|
|
1704
|
+
if (!requestedShape) {
|
|
1705
|
+
return activationReadiness('blocked', 'activation-permit-cannot-authorize-requested-start-shape', trustRoots.length, standingAuthorizedFields);
|
|
753
1706
|
}
|
|
1707
|
+
const shapeLabel = requestedShapeLabel(requestedShape);
|
|
754
1708
|
if (trustRoots.length === 0) {
|
|
755
|
-
return activationReadiness('blocked', 'no-trusted-activation-roots', 0
|
|
1709
|
+
return activationReadiness('blocked', 'no-trusted-activation-roots', 0, {
|
|
1710
|
+
requestedShape: shapeLabel,
|
|
1711
|
+
...standingAuthorizedFields,
|
|
1712
|
+
});
|
|
756
1713
|
}
|
|
757
1714
|
if (platform === 'win32') {
|
|
758
|
-
return activationReadiness('blocked', 'activation-permit-v1-unsupported-on-windows', trustRoots.length);
|
|
1715
|
+
return activationReadiness('blocked', 'activation-permit-v1-unsupported-on-windows', trustRoots.length, { requestedShape: shapeLabel, ...standingAuthorizedFields });
|
|
1716
|
+
}
|
|
1717
|
+
// A valid standing `residentStanding` grant means `daemon start` (resident)
|
|
1718
|
+
// would succeed RIGHT NOW without ever touching the one-shot permit file —
|
|
1719
|
+
// report that directly instead of inspecting a possibly-stale/unreadable
|
|
1720
|
+
// one-shot permit that resident no longer depends on. This is the fix for
|
|
1721
|
+
// status previously showing `resident: degraded
|
|
1722
|
+
// (activation-permit-inspection-failed)` when a stale one-shot permit file
|
|
1723
|
+
// was lying around: that inspection is simply skipped once a standing grant
|
|
1724
|
+
// covers it.
|
|
1725
|
+
if (requestedShape === 'resident' && standingAuth.residentStanding) {
|
|
1726
|
+
return activationReadiness('ready', 'ready-via-standing-resident-grant', trustRoots.length, { requestedShape: shapeLabel, residentAuthorized: true, ...standingAuthorizedFields });
|
|
759
1727
|
}
|
|
760
1728
|
const permitPath = daemonActivationPermitPath();
|
|
761
1729
|
let pinned;
|
|
762
1730
|
let result;
|
|
763
1731
|
try {
|
|
764
1732
|
pinned = openPinnedPermit(permitPath, resolve(homedir()));
|
|
765
|
-
result = inspectPinnedActivationPermit(pinned, permitPath, cfg, trustRoots, suppliedContext);
|
|
1733
|
+
result = inspectPinnedActivationPermit(pinned, permitPath, cfg, trustRoots, suppliedContext, requestedShape, standingAuth);
|
|
766
1734
|
}
|
|
767
1735
|
catch (error) {
|
|
768
1736
|
result = activationReadiness(error.code === 'ENOENT' ? 'blocked' : 'degraded', error.code === 'ENOENT'
|
|
769
1737
|
? 'activation-permit-missing'
|
|
770
|
-
: 'activation-permit-inspection-failed', trustRoots.length);
|
|
1738
|
+
: 'activation-permit-inspection-failed', trustRoots.length, { requestedShape: shapeLabel, installAuthorized: standingAuth.install, repairAuthorized: standingAuth.repair });
|
|
771
1739
|
}
|
|
772
1740
|
if (pinned) {
|
|
773
1741
|
try {
|
|
774
1742
|
closeSync(pinned.fd);
|
|
775
1743
|
}
|
|
776
1744
|
catch {
|
|
777
|
-
return activationReadiness('degraded', 'activation-permit-close-failed', trustRoots.length);
|
|
1745
|
+
return activationReadiness('degraded', 'activation-permit-close-failed', trustRoots.length, { requestedShape: shapeLabel, installAuthorized: standingAuth.install, repairAuthorized: standingAuth.repair });
|
|
778
1746
|
}
|
|
779
1747
|
}
|
|
780
1748
|
return result;
|
|
781
1749
|
}
|
|
782
|
-
function needsPermit(opts) {
|
|
783
|
-
return !(opts.once === true && opts.dryRun === true);
|
|
784
|
-
}
|
|
785
|
-
function supportedProposalOnceShape(opts) {
|
|
786
|
-
return opts.once === true
|
|
787
|
-
&& opts.dryRun !== true
|
|
788
|
-
&& opts.drain === undefined
|
|
789
|
-
&& opts.drainLimit === undefined;
|
|
790
|
-
}
|
|
791
1750
|
function consumeWithAuthority(cfg, opts, trustRoots, suppliedContext, afterReceiptPersisted, mayMintCapability) {
|
|
792
1751
|
if (!needsPermit(opts)) {
|
|
793
1752
|
return {
|
|
@@ -796,7 +1755,8 @@ function consumeWithAuthority(cfg, opts, trustRoots, suppliedContext, afterRecei
|
|
|
796
1755
|
reason: 'dry-run-once-does-not-require-activation-permit',
|
|
797
1756
|
};
|
|
798
1757
|
}
|
|
799
|
-
|
|
1758
|
+
const requestedShape = requestedActivationShape(opts);
|
|
1759
|
+
if (!requestedShape) {
|
|
800
1760
|
return {
|
|
801
1761
|
authorized: false,
|
|
802
1762
|
required: true,
|
|
@@ -868,6 +1828,15 @@ function consumeWithAuthority(cfg, opts, trustRoots, suppliedContext, afterRecei
|
|
|
868
1828
|
result = { authorized: false, required: true, reason: 'invalid-permit-schema' };
|
|
869
1829
|
return result;
|
|
870
1830
|
}
|
|
1831
|
+
if (parsed.payload.scope[requestedShape] !== true) {
|
|
1832
|
+
result = {
|
|
1833
|
+
authorized: false,
|
|
1834
|
+
required: true,
|
|
1835
|
+
reason: 'activation-permit-scope-does-not-grant-requested-shape',
|
|
1836
|
+
permitId: verification.permitId,
|
|
1837
|
+
};
|
|
1838
|
+
return result;
|
|
1839
|
+
}
|
|
871
1840
|
const receiptsDir = dirname(daemonActivationReceiptPath(verification.permitId));
|
|
872
1841
|
assurePrivateDirectory(receiptsDir, home);
|
|
873
1842
|
const receiptPath = daemonActivationReceiptPath(verification.permitId);
|
|
@@ -949,12 +1918,12 @@ function consumeWithAuthority(cfg, opts, trustRoots, suppliedContext, afterRecei
|
|
|
949
1918
|
result = {
|
|
950
1919
|
authorized: true,
|
|
951
1920
|
required: true,
|
|
952
|
-
reason: 'proposal-once-activation-authorized',
|
|
1921
|
+
reason: requestedShape === 'once' ? 'proposal-once-activation-authorized' : 'resident-activation-authorized',
|
|
953
1922
|
permitId: verification.permitId,
|
|
954
1923
|
receiptPath,
|
|
955
1924
|
...(mayMintCapability
|
|
956
1925
|
? {
|
|
957
|
-
capability: mintCapability(verification.permitId, () => {
|
|
1926
|
+
capability: mintCapability(verification.permitId, parsed.payload.scope, () => {
|
|
958
1927
|
const claimContext = collectRuntimeContext(configSnapshot);
|
|
959
1928
|
return verifyDaemonActivationPermit(parsed, claimContext, trustRoots).ok;
|
|
960
1929
|
}),
|
|
@@ -992,18 +1961,54 @@ function consumeWithAuthority(cfg, opts, trustRoots, suppliedContext, afterRecei
|
|
|
992
1961
|
}
|
|
993
1962
|
}
|
|
994
1963
|
/**
|
|
995
|
-
* Runtime entrypoint.
|
|
996
|
-
*
|
|
1964
|
+
* Runtime entrypoint. Trust roots come ONLY from the operator-owned store
|
|
1965
|
+
* (see loadDaemonActivationTrustRoots); an npm consumer who never runs
|
|
1966
|
+
* `ashlr activation init` sees the store as empty and gets today's exact
|
|
1967
|
+
* denial. Every consume attempt — authorized or refused — is audited.
|
|
997
1968
|
*/
|
|
998
1969
|
export function consumeDaemonActivationPermit(cfg, opts) {
|
|
999
|
-
|
|
1970
|
+
// A resident start tries the standing `residentStanding` grant FIRST. This
|
|
1971
|
+
// is what makes an unattended, launchd/systemd/Task-Scheduler-triggered
|
|
1972
|
+
// restart after reboot possible: there is nobody present to mint a fresh
|
|
1973
|
+
// one-shot permit, but a still-valid, still-signed, non-revoked standing
|
|
1974
|
+
// grant needs no human in the loop. It goes through every check
|
|
1975
|
+
// `daemonActivationScopeGranted` does (platform, kill switch, guard health,
|
|
1976
|
+
// trust root, expiry, revocation, signature) — deliberately NOT the
|
|
1977
|
+
// one-shot permit's build-identity binding, matching every other standing
|
|
1978
|
+
// grant (conductor/install/automerge/repair/deploy), because a standing
|
|
1979
|
+
// grant must keep working across routine rebuilds within its expiry
|
|
1980
|
+
// window. If no standing grant matches, this falls through to the
|
|
1981
|
+
// one-shot permit path unchanged — an operator who has only ever granted
|
|
1982
|
+
// the one-shot form (or nothing) sees EXACTLY today's behavior.
|
|
1983
|
+
if (requestedActivationShape(opts) === 'resident') {
|
|
1984
|
+
const standing = findGrantedRecord('residentStanding');
|
|
1985
|
+
if (standing.granted) {
|
|
1986
|
+
const result = {
|
|
1987
|
+
authorized: true,
|
|
1988
|
+
required: true,
|
|
1989
|
+
reason: `resident-standing-activation-authorized:${standing.record.grantId}`,
|
|
1990
|
+
scope: standing.record.scope,
|
|
1991
|
+
grantId: standing.record.grantId,
|
|
1992
|
+
};
|
|
1993
|
+
auditActivationEvent('daemon-activation:consume', `daemon activation consume authorized: ${result.reason}`, 'ok');
|
|
1994
|
+
return result;
|
|
1995
|
+
}
|
|
1996
|
+
auditActivationEvent('daemon-activation:resident-standing-check', `resident standing grant unavailable (${standing.reason}); falling back to one-shot permit`, 'refused');
|
|
1997
|
+
}
|
|
1998
|
+
const result = consumeWithAuthority(cfg, opts, loadDaemonActivationTrustRoots(), undefined, undefined, true);
|
|
1999
|
+
// The dry-run-once bypass (`required: false`) never touches a permit or
|
|
2000
|
+
// any authority state — it is not a consume attempt, so nothing is audited.
|
|
2001
|
+
if (result.required) {
|
|
2002
|
+
auditActivationEvent('daemon-activation:consume', `daemon activation consume ${result.authorized ? 'authorized' : 'refused'}: ${result.reason}`, result.authorized ? 'ok' : 'refused');
|
|
2003
|
+
}
|
|
2004
|
+
return result;
|
|
1000
2005
|
}
|
|
1001
2006
|
/**
|
|
1002
2007
|
* Read-only advisory inspection. A ready result is never authority: daemon
|
|
1003
2008
|
* start must still reopen, revalidate, durably consume, and mint a capability.
|
|
1004
2009
|
*/
|
|
1005
2010
|
export function inspectDaemonActivationPermit(cfg, opts) {
|
|
1006
|
-
return inspectWithAuthority(cfg, opts,
|
|
2011
|
+
return inspectWithAuthority(cfg, opts, loadDaemonActivationTrustRoots(), undefined);
|
|
1007
2012
|
}
|
|
1008
2013
|
/** Test-only inspection with injected roots/context; it cannot mint authority. */
|
|
1009
2014
|
export function inspectDaemonActivationPermitForVerification(cfg, opts, options) {
|