@ashlr/hub 3.2.6 → 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 +290 -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 +37 -11
- package/dist/cli/completions.js.map +1 -1
- package/dist/cli/conductor-permit.d.ts +1 -0
- package/dist/cli/conductor-permit.js +122 -0
- package/dist/cli/conductor-permit.js.map +1 -0
- 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/goals.d.ts +2 -0
- package/dist/cli/goals.js +90 -5
- package/dist/cli/goals.js.map +1 -1
- package/dist/cli/help.js +21 -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 +16 -0
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/loop.js +26 -5
- package/dist/cli/loop.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/comms/elon-dialogue.js +16 -7
- package/dist/core/comms/elon-dialogue.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 +357 -33
- package/dist/core/daemon/activation-permit.js +1585 -183
- package/dist/core/daemon/activation-permit.js.map +1 -1
- package/dist/core/daemon/goal-conductor-permit-operator.d.ts +75 -0
- package/dist/core/daemon/goal-conductor-permit-operator.js +842 -0
- package/dist/core/daemon/goal-conductor-permit-operator.js.map +1 -0
- package/dist/core/daemon/loop.d.ts +13 -1
- package/dist/core/daemon/loop.js +294 -29
- 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/quota.d.ts +74 -7
- package/dist/core/fleet/quota.js +688 -34
- package/dist/core/fleet/quota.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 +7 -5
- 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/advance.js +34 -5
- package/dist/core/goals/advance.js.map +1 -1
- package/dist/core/goals/conductor-quota.d.ts +38 -0
- package/dist/core/goals/conductor-quota.js +110 -0
- package/dist/core/goals/conductor-quota.js.map +1 -0
- package/dist/core/goals/conductor-target.d.ts +24 -0
- package/dist/core/goals/conductor-target.js +35 -0
- package/dist/core/goals/conductor-target.js.map +1 -0
- package/dist/core/goals/conductor.d.ts +15 -0
- package/dist/core/goals/conductor.js +116 -9
- package/dist/core/goals/conductor.js.map +1 -1
- package/dist/core/goals/store.d.ts +29 -5
- package/dist/core/goals/store.js +197 -30
- package/dist/core/goals/store.js.map +1 -1
- package/dist/core/goals/timestamp-repair.d.ts +50 -0
- package/dist/core/goals/timestamp-repair.js +740 -0
- package/dist/core/goals/timestamp-repair.js.map +1 -0
- 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/integrations/pulse-sync.js +13 -4
- package/dist/core/integrations/pulse-sync.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/best-of-n.d.ts +18 -0
- package/dist/core/run/best-of-n.js +163 -18
- package/dist/core/run/best-of-n.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 +140 -60
- package/dist/core/run/orchestrator.js.map +1 -1
- package/dist/core/strategy/goal-planner.js +7 -1
- package/dist/core/strategy/goal-planner.js.map +1 -1
- package/dist/core/swarm/planner.d.ts +2 -2
- package/dist/core/swarm/planner.js +52 -5
- package/dist/core/swarm/planner.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 +123 -11
- package/dist/core/swarm/runner.js.map +1 -1
- package/dist/core/types.d.ts +139 -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 +49 -34
- 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/dist/tui/app.js +2 -2
- package/dist/tui/app.js.map +1 -1
- package/dist/tui/render.d.ts +2 -0
- package/dist/tui/render.js +162 -4
- package/dist/tui/render.js.map +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,21 +784,29 @@ 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
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
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');
|
|
692
810
|
}
|
|
693
811
|
function pathEntryPresent(path) {
|
|
694
812
|
try {
|
|
@@ -701,139 +819,981 @@ function pathEntryPresent(path) {
|
|
|
701
819
|
throw error;
|
|
702
820
|
}
|
|
703
821
|
}
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
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' };
|
|
708
826
|
}
|
|
709
|
-
|
|
710
|
-
|
|
827
|
+
let dirStat;
|
|
828
|
+
try {
|
|
829
|
+
dirStat = lstatSync(dirname(path), { bigint: true });
|
|
711
830
|
}
|
|
712
|
-
|
|
713
|
-
return
|
|
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);
|
|
714
846
|
}
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
return activationReadiness('degraded', 'invalid-permit-schema', trustRoots.length);
|
|
847
|
+
catch (error) {
|
|
848
|
+
return { ok: false, reason: error.code === 'ENOENT' ? 'missing' : 'open-failed' };
|
|
718
849
|
}
|
|
719
|
-
let configSnapshot;
|
|
720
850
|
try {
|
|
721
|
-
|
|
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 };
|
|
722
884
|
}
|
|
723
|
-
|
|
724
|
-
|
|
885
|
+
finally {
|
|
886
|
+
closeSync(fd);
|
|
725
887
|
}
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
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));
|
|
729
915
|
}
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
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 */ }
|
|
733
928
|
}
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
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();
|
|
738
945
|
}
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|| !sameFileSnapshot(openedAfterInspection, namedAfterInspection)) {
|
|
743
|
-
return activationReadiness('degraded', 'activation-permit-changed-during-inspection', trustRoots.length);
|
|
946
|
+
catch (error) {
|
|
947
|
+
releaseLocalStoreLock(lock);
|
|
948
|
+
throw error;
|
|
744
949
|
}
|
|
745
|
-
|
|
950
|
+
if (!releaseLocalStoreLock(lock))
|
|
951
|
+
throw new Error('activation-store-lock-release-failed');
|
|
952
|
+
return result;
|
|
746
953
|
}
|
|
747
|
-
function
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
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');
|
|
753
983
|
}
|
|
754
|
-
|
|
755
|
-
|
|
984
|
+
finally {
|
|
985
|
+
closeSync(fd);
|
|
756
986
|
}
|
|
757
|
-
|
|
758
|
-
|
|
987
|
+
}
|
|
988
|
+
function validTrustRootRecord(value) {
|
|
989
|
+
if (!isRecord(value) || !hasExactKeys(value, ['keyId', 'publicKeyPem', 'createdAt', 'revokedAt', 'label'])) {
|
|
990
|
+
return false;
|
|
759
991
|
}
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
992
|
+
if (typeof value['keyId'] !== 'string' || !KEY_ID_RE.test(value['keyId']))
|
|
993
|
+
return false;
|
|
994
|
+
if (typeof value['publicKeyPem'] !== 'string')
|
|
995
|
+
return false;
|
|
763
996
|
try {
|
|
764
|
-
|
|
765
|
-
|
|
997
|
+
const key = createPublicKey(value['publicKeyPem']);
|
|
998
|
+
if (key.asymmetricKeyType !== 'ed25519')
|
|
999
|
+
return false;
|
|
766
1000
|
}
|
|
767
|
-
catch
|
|
768
|
-
|
|
769
|
-
? 'activation-permit-missing'
|
|
770
|
-
: 'activation-permit-inspection-failed', trustRoots.length);
|
|
1001
|
+
catch {
|
|
1002
|
+
return false;
|
|
771
1003
|
}
|
|
772
|
-
if (
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
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' };
|
|
778
1024
|
}
|
|
1025
|
+
seen.add(entry.keyId);
|
|
1026
|
+
records.push(entry);
|
|
779
1027
|
}
|
|
780
|
-
return
|
|
1028
|
+
return { ok: true, records };
|
|
781
1029
|
}
|
|
782
|
-
|
|
783
|
-
|
|
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 }));
|
|
784
1042
|
}
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
&& opts.drain === undefined
|
|
789
|
-
&& opts.drainLimit === undefined;
|
|
1043
|
+
/** Full trust-root status (incl. revoked history) for `ashlr activation status`. */
|
|
1044
|
+
export function daemonActivationTrustRootStatus() {
|
|
1045
|
+
return loadTrustRootStore();
|
|
790
1046
|
}
|
|
791
|
-
function
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
};
|
|
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;
|
|
798
1079
|
}
|
|
799
|
-
|
|
800
|
-
return
|
|
801
|
-
authorized: false,
|
|
802
|
-
required: true,
|
|
803
|
-
reason: 'activation-permit-cannot-authorize-requested-start-shape',
|
|
804
|
-
};
|
|
1080
|
+
catch {
|
|
1081
|
+
return false;
|
|
805
1082
|
}
|
|
806
|
-
if (
|
|
807
|
-
return
|
|
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');
|
|
808
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) {
|
|
809
1160
|
if (process.platform === 'win32') {
|
|
810
|
-
return {
|
|
811
|
-
authorized: false,
|
|
812
|
-
required: true,
|
|
813
|
-
reason: 'activation-permit-v1-unsupported-on-windows',
|
|
814
|
-
};
|
|
1161
|
+
return { granted: false, reason: 'activation-permit-v1-unsupported-on-windows' };
|
|
815
1162
|
}
|
|
816
|
-
|
|
1163
|
+
if (killSwitchOn())
|
|
1164
|
+
return { granted: false, reason: 'kill-switch-is-on' };
|
|
1165
|
+
let guardHealthy;
|
|
817
1166
|
try {
|
|
818
|
-
|
|
1167
|
+
guardHealthy = !diagnoseGuardHealth().blocked;
|
|
819
1168
|
}
|
|
820
1169
|
catch {
|
|
821
|
-
return {
|
|
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 };
|
|
822
1201
|
}
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
const
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
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) {
|
|
1515
|
+
return {
|
|
1516
|
+
schemaVersion: 1,
|
|
1517
|
+
policyVersion: POLICY_VERSION,
|
|
1518
|
+
authority: 'observation-only',
|
|
1519
|
+
sourceState: state === 'degraded' ? 'degraded' : 'healthy',
|
|
1520
|
+
state,
|
|
1521
|
+
commandEligible: state === 'ready',
|
|
1522
|
+
requestedShape: extra?.requestedShape ?? 'proposal-once',
|
|
1523
|
+
trustRootCount,
|
|
1524
|
+
residentAuthorized: extra?.residentAuthorized ?? false,
|
|
1525
|
+
residentStandingAuthorized: extra?.residentStandingAuthorized ?? false,
|
|
1526
|
+
installAuthorized: extra?.installAuthorized ?? false,
|
|
1527
|
+
repairAuthorized: extra?.repairAuthorized ?? false,
|
|
1528
|
+
reason,
|
|
1529
|
+
};
|
|
1530
|
+
}
|
|
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;
|
|
1568
|
+
try {
|
|
1569
|
+
pinned = openPinnedPermit(permitPath, resolve(homedir()));
|
|
1570
|
+
}
|
|
1571
|
+
catch (error) {
|
|
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 */ }
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
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
|
+
};
|
|
1642
|
+
let envelope;
|
|
1643
|
+
try {
|
|
1644
|
+
envelope = JSON.parse(pinned.text);
|
|
1645
|
+
}
|
|
1646
|
+
catch {
|
|
1647
|
+
return activationReadiness('degraded', 'invalid-permit-json', trustRoots.length, extraBase);
|
|
1648
|
+
}
|
|
1649
|
+
if (`${canonicalizeDaemonActivationValue(envelope)}\n` !== pinned.text) {
|
|
1650
|
+
return activationReadiness('degraded', 'noncanonical-permit-encoding', trustRoots.length, extraBase);
|
|
1651
|
+
}
|
|
1652
|
+
const parsed = parseEnvelope(envelope);
|
|
1653
|
+
if (!parsed) {
|
|
1654
|
+
return activationReadiness('degraded', 'invalid-permit-schema', trustRoots.length, extraBase);
|
|
1655
|
+
}
|
|
1656
|
+
let configSnapshot;
|
|
1657
|
+
try {
|
|
1658
|
+
configSnapshot = strictConfigSnapshot(cfg);
|
|
1659
|
+
}
|
|
1660
|
+
catch {
|
|
1661
|
+
return activationReadiness('degraded', 'activation-config-not-strict-json', trustRoots.length, extraBase);
|
|
1662
|
+
}
|
|
1663
|
+
const context = suppliedContext ?? collectRuntimeContext(configSnapshot);
|
|
1664
|
+
if (context.configDigest !== daemonActivationConfigDigest(configSnapshot)) {
|
|
1665
|
+
return activationReadiness('blocked', 'runtime-config-digest-mismatch', trustRoots.length, extraBase);
|
|
1666
|
+
}
|
|
1667
|
+
const verification = verifyDaemonActivationPermit(parsed, context, trustRoots);
|
|
1668
|
+
if (!verification.ok || !verification.permitId) {
|
|
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 });
|
|
1674
|
+
}
|
|
1675
|
+
const nonceDigest = sha256(parsed.payload.nonce);
|
|
1676
|
+
if (pathEntryPresent(daemonActivationReceiptPath(verification.permitId))
|
|
1677
|
+
|| pathEntryPresent(daemonActivationNonceReceiptPath(nonceDigest))) {
|
|
1678
|
+
return activationReadiness('blocked', 'activation-permit-already-consumed', trustRoots.length, { ...extraBase, residentAuthorized });
|
|
1679
|
+
}
|
|
1680
|
+
const openedAfterInspection = fstatSync(pinned.fd, { bigint: true });
|
|
1681
|
+
const namedAfterInspection = lstatSync(permitPath, { bigint: true });
|
|
1682
|
+
if (!sameFileSnapshot(pinned.stat, openedAfterInspection)
|
|
1683
|
+
|| !sameFileSnapshot(openedAfterInspection, namedAfterInspection)) {
|
|
1684
|
+
return activationReadiness('degraded', 'activation-permit-changed-during-inspection', trustRoots.length, { ...extraBase, residentAuthorized });
|
|
1685
|
+
}
|
|
1686
|
+
const readyReason = requestedShape === 'once' ? 'valid-proposal-once-permit' : 'valid-resident-permit';
|
|
1687
|
+
return activationReadiness('ready', readyReason, trustRoots.length, { ...extraBase, residentAuthorized });
|
|
1688
|
+
}
|
|
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
|
+
};
|
|
1700
|
+
if (!needsPermit(opts)) {
|
|
1701
|
+
return activationReadiness('ready', 'dry-run-once-does-not-require-activation-permit', trustRoots.length, standingAuthorizedFields);
|
|
1702
|
+
}
|
|
1703
|
+
const requestedShape = requestedActivationShape(opts);
|
|
1704
|
+
if (!requestedShape) {
|
|
1705
|
+
return activationReadiness('blocked', 'activation-permit-cannot-authorize-requested-start-shape', trustRoots.length, standingAuthorizedFields);
|
|
1706
|
+
}
|
|
1707
|
+
const shapeLabel = requestedShapeLabel(requestedShape);
|
|
1708
|
+
if (trustRoots.length === 0) {
|
|
1709
|
+
return activationReadiness('blocked', 'no-trusted-activation-roots', 0, {
|
|
1710
|
+
requestedShape: shapeLabel,
|
|
1711
|
+
...standingAuthorizedFields,
|
|
1712
|
+
});
|
|
1713
|
+
}
|
|
1714
|
+
if (platform === 'win32') {
|
|
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 });
|
|
1727
|
+
}
|
|
1728
|
+
const permitPath = daemonActivationPermitPath();
|
|
1729
|
+
let pinned;
|
|
1730
|
+
let result;
|
|
1731
|
+
try {
|
|
1732
|
+
pinned = openPinnedPermit(permitPath, resolve(homedir()));
|
|
1733
|
+
result = inspectPinnedActivationPermit(pinned, permitPath, cfg, trustRoots, suppliedContext, requestedShape, standingAuth);
|
|
1734
|
+
}
|
|
1735
|
+
catch (error) {
|
|
1736
|
+
result = activationReadiness(error.code === 'ENOENT' ? 'blocked' : 'degraded', error.code === 'ENOENT'
|
|
1737
|
+
? 'activation-permit-missing'
|
|
1738
|
+
: 'activation-permit-inspection-failed', trustRoots.length, { requestedShape: shapeLabel, installAuthorized: standingAuth.install, repairAuthorized: standingAuth.repair });
|
|
1739
|
+
}
|
|
1740
|
+
if (pinned) {
|
|
1741
|
+
try {
|
|
1742
|
+
closeSync(pinned.fd);
|
|
1743
|
+
}
|
|
1744
|
+
catch {
|
|
1745
|
+
return activationReadiness('degraded', 'activation-permit-close-failed', trustRoots.length, { requestedShape: shapeLabel, installAuthorized: standingAuth.install, repairAuthorized: standingAuth.repair });
|
|
1746
|
+
}
|
|
1747
|
+
}
|
|
1748
|
+
return result;
|
|
1749
|
+
}
|
|
1750
|
+
function consumeWithAuthority(cfg, opts, trustRoots, suppliedContext, afterReceiptPersisted, mayMintCapability) {
|
|
1751
|
+
if (!needsPermit(opts)) {
|
|
1752
|
+
return {
|
|
1753
|
+
authorized: true,
|
|
1754
|
+
required: false,
|
|
1755
|
+
reason: 'dry-run-once-does-not-require-activation-permit',
|
|
1756
|
+
};
|
|
1757
|
+
}
|
|
1758
|
+
const requestedShape = requestedActivationShape(opts);
|
|
1759
|
+
if (!requestedShape) {
|
|
1760
|
+
return {
|
|
1761
|
+
authorized: false,
|
|
1762
|
+
required: true,
|
|
1763
|
+
reason: 'activation-permit-cannot-authorize-requested-start-shape',
|
|
1764
|
+
};
|
|
1765
|
+
}
|
|
1766
|
+
if (trustRoots.length === 0) {
|
|
1767
|
+
return { authorized: false, required: true, reason: 'no-trusted-activation-roots' };
|
|
1768
|
+
}
|
|
1769
|
+
if (process.platform === 'win32') {
|
|
1770
|
+
return {
|
|
1771
|
+
authorized: false,
|
|
1772
|
+
required: true,
|
|
1773
|
+
reason: 'activation-permit-v1-unsupported-on-windows',
|
|
1774
|
+
};
|
|
1775
|
+
}
|
|
1776
|
+
let configSnapshot;
|
|
1777
|
+
try {
|
|
1778
|
+
configSnapshot = strictConfigSnapshot(cfg);
|
|
1779
|
+
}
|
|
1780
|
+
catch {
|
|
1781
|
+
return { authorized: false, required: true, reason: 'activation-config-not-strict-json' };
|
|
1782
|
+
}
|
|
1783
|
+
const home = resolve(homedir());
|
|
1784
|
+
const permitPath = daemonActivationPermitPath();
|
|
1785
|
+
const lockPath = join(dirname(permitPath), 'daemon-activation-permit.lock');
|
|
1786
|
+
const lock = acquireLocalStoreLock(lockPath, LOCK_WAIT_MS, {
|
|
1787
|
+
anchorPath: home,
|
|
1788
|
+
exactPrivateStorage: true,
|
|
1789
|
+
});
|
|
1790
|
+
if (!lock)
|
|
1791
|
+
return { authorized: false, required: true, reason: 'activation-permit-lock-unavailable' };
|
|
1792
|
+
let pinned;
|
|
1793
|
+
let result = {
|
|
1794
|
+
authorized: false,
|
|
1795
|
+
required: true,
|
|
1796
|
+
reason: 'activation-permit-consumption-failed',
|
|
837
1797
|
};
|
|
838
1798
|
try {
|
|
839
1799
|
pinned = openPinnedPermit(permitPath, home);
|
|
@@ -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) {
|
|
@@ -1016,4 +2021,401 @@ export function inspectDaemonActivationPermitForVerification(cfg, opts, options)
|
|
|
1016
2021
|
export function consumeDaemonActivationPermitForVerification(cfg, opts, options) {
|
|
1017
2022
|
return consumeWithAuthority(cfg, opts, options.trustRoots, options.context, options.afterReceiptPersisted, false);
|
|
1018
2023
|
}
|
|
2024
|
+
// ---------------------------------------------------------------------------
|
|
2025
|
+
// Dormant signed one-shot goal-conductor authority.
|
|
2026
|
+
//
|
|
2027
|
+
// This is deliberately a separate protocol from M461. A daemon permit can
|
|
2028
|
+
// never authorize a goal advance, and a conductor permit can never authorize a
|
|
2029
|
+
// daemon tick. Production roots remain empty; only verification tests inject a
|
|
2030
|
+
// root. A reviewed source change is therefore still required before live use.
|
|
2031
|
+
// ---------------------------------------------------------------------------
|
|
2032
|
+
const GOAL_CONDUCTOR_POLICY_VERSION = 'goal-conductor-proposal-once-v1';
|
|
2033
|
+
const GOAL_CONDUCTOR_SIGNING_DOMAIN = 'ashlr:goal-conductor-activation-permit:v1\0';
|
|
2034
|
+
export const GOAL_CONDUCTOR_ONCE_MAX_TOKENS = 50_000;
|
|
2035
|
+
export const GOAL_CONDUCTOR_ONCE_MAX_STEPS = 12;
|
|
2036
|
+
const goalConductorCapabilityBrand = Symbol('ashlr.goal-conductor-activation-capability');
|
|
2037
|
+
/** Immutable repository-owned roots. Intentionally empty in production. */
|
|
2038
|
+
export const GOAL_CONDUCTOR_ACTIVATION_TRUST_ROOTS = Object.freeze([]);
|
|
2039
|
+
const validGoalConductorCapabilities = new WeakMap();
|
|
2040
|
+
function validGoalConductorTarget(value) {
|
|
2041
|
+
return isRecord(value)
|
|
2042
|
+
&& hasExactKeys(value, ['goalId', 'milestoneId', 'goalDigest', 'projectPath'])
|
|
2043
|
+
&& typeof value['goalId'] === 'string'
|
|
2044
|
+
&& value['goalId'].length > 0
|
|
2045
|
+
&& value['goalId'].length <= 256
|
|
2046
|
+
&& typeof value['milestoneId'] === 'string'
|
|
2047
|
+
&& value['milestoneId'].length > 0
|
|
2048
|
+
&& value['milestoneId'].length <= 320
|
|
2049
|
+
&& DIGEST_RE.test(String(value['goalDigest']))
|
|
2050
|
+
&& typeof value['projectPath'] === 'string'
|
|
2051
|
+
&& isAbsolute(value['projectPath']);
|
|
2052
|
+
}
|
|
2053
|
+
function validGoalConductorScope(value) {
|
|
2054
|
+
if (!isRecord(value) || !hasExactKeys(value, [
|
|
2055
|
+
'action', 'once', 'dryRun', 'resident', 'maxGoals', 'maxMilestones',
|
|
2056
|
+
'maxRetries', 'allowCloud', 'allowAnyRepo', 'daemonFallback',
|
|
2057
|
+
'proposalOnly', 'automerge', 'repair', 'deploy', 'install', 'selfTarget',
|
|
2058
|
+
'maxTokens', 'maxSteps',
|
|
2059
|
+
]))
|
|
2060
|
+
return false;
|
|
2061
|
+
return value['action'] === 'goal-conductor-proposal-once'
|
|
2062
|
+
&& value['once'] === true
|
|
2063
|
+
&& value['dryRun'] === false
|
|
2064
|
+
&& value['resident'] === false
|
|
2065
|
+
&& value['maxGoals'] === 1
|
|
2066
|
+
&& value['maxMilestones'] === 1
|
|
2067
|
+
&& value['maxRetries'] === 0
|
|
2068
|
+
&& value['allowCloud'] === false
|
|
2069
|
+
&& value['allowAnyRepo'] === false
|
|
2070
|
+
&& value['daemonFallback'] === false
|
|
2071
|
+
&& value['proposalOnly'] === true
|
|
2072
|
+
&& value['automerge'] === false
|
|
2073
|
+
&& value['repair'] === false
|
|
2074
|
+
&& value['deploy'] === false
|
|
2075
|
+
&& value['install'] === false
|
|
2076
|
+
&& value['selfTarget'] === false
|
|
2077
|
+
&& value['maxTokens'] === GOAL_CONDUCTOR_ONCE_MAX_TOKENS
|
|
2078
|
+
&& value['maxSteps'] === GOAL_CONDUCTOR_ONCE_MAX_STEPS;
|
|
2079
|
+
}
|
|
2080
|
+
function parseGoalConductorPayload(value) {
|
|
2081
|
+
if (!isRecord(value) || !hasExactKeys(value, [
|
|
2082
|
+
'schemaVersion', 'policyVersion', 'permitId', 'nonce', 'keyId',
|
|
2083
|
+
'issuedAt', 'expiresAt', 'scope', 'bindings',
|
|
2084
|
+
]))
|
|
2085
|
+
return null;
|
|
2086
|
+
if (value['schemaVersion'] !== 1
|
|
2087
|
+
|| value['policyVersion'] !== GOAL_CONDUCTOR_POLICY_VERSION
|
|
2088
|
+
|| typeof value['permitId'] !== 'string'
|
|
2089
|
+
|| !ID_RE.test(value['permitId'])
|
|
2090
|
+
|| typeof value['nonce'] !== 'string'
|
|
2091
|
+
|| !DIGEST_RE.test(value['nonce'])
|
|
2092
|
+
|| typeof value['keyId'] !== 'string'
|
|
2093
|
+
|| !KEY_ID_RE.test(value['keyId'])
|
|
2094
|
+
|| !validIsoTimestamp(value['issuedAt'])
|
|
2095
|
+
|| !validIsoTimestamp(value['expiresAt'])
|
|
2096
|
+
|| !validGoalConductorScope(value['scope'])
|
|
2097
|
+
|| !isRecord(value['bindings'])
|
|
2098
|
+
|| !hasExactKeys(value['bindings'], [
|
|
2099
|
+
'configDigest', 'buildIdentity', 'executable', 'entrypoint', 'releaseTree',
|
|
2100
|
+
'authorityStateDigest', 'killSwitch', 'guardHealth', 'target',
|
|
2101
|
+
])
|
|
2102
|
+
|| !validBindings({
|
|
2103
|
+
configDigest: value['bindings']['configDigest'],
|
|
2104
|
+
buildIdentity: value['bindings']['buildIdentity'],
|
|
2105
|
+
executable: value['bindings']['executable'],
|
|
2106
|
+
entrypoint: value['bindings']['entrypoint'],
|
|
2107
|
+
releaseTree: value['bindings']['releaseTree'],
|
|
2108
|
+
authorityStateDigest: value['bindings']['authorityStateDigest'],
|
|
2109
|
+
killSwitch: value['bindings']['killSwitch'],
|
|
2110
|
+
guardHealth: value['bindings']['guardHealth'],
|
|
2111
|
+
})
|
|
2112
|
+
|| !validGoalConductorTarget(value['bindings']['target']))
|
|
2113
|
+
return null;
|
|
2114
|
+
return value;
|
|
2115
|
+
}
|
|
2116
|
+
export function parseGoalConductorActivationPermitEnvelope(value) {
|
|
2117
|
+
if (!isRecord(value) || !hasExactKeys(value, ['payload', 'signature']))
|
|
2118
|
+
return null;
|
|
2119
|
+
const payload = parseGoalConductorPayload(value['payload']);
|
|
2120
|
+
if (!payload || typeof value['signature'] !== 'string')
|
|
2121
|
+
return null;
|
|
2122
|
+
const bytes = Buffer.from(value['signature'], 'base64');
|
|
2123
|
+
if (bytes.length !== 64 || bytes.toString('base64') !== value['signature'])
|
|
2124
|
+
return null;
|
|
2125
|
+
return { payload, signature: value['signature'] };
|
|
2126
|
+
}
|
|
2127
|
+
function goalConductorSigningBytes(payload) {
|
|
2128
|
+
return Buffer.from(`${GOAL_CONDUCTOR_SIGNING_DOMAIN}${canonicalizeDaemonActivationValue(payload)}`, 'utf8');
|
|
2129
|
+
}
|
|
2130
|
+
export function buildGoalConductorActivationPermitPayload(input) {
|
|
2131
|
+
const payload = {
|
|
2132
|
+
schemaVersion: 1,
|
|
2133
|
+
policyVersion: GOAL_CONDUCTOR_POLICY_VERSION,
|
|
2134
|
+
permitId: input.permitId,
|
|
2135
|
+
nonce: input.nonce,
|
|
2136
|
+
keyId: input.keyId,
|
|
2137
|
+
issuedAt: input.issuedAt,
|
|
2138
|
+
expiresAt: input.expiresAt,
|
|
2139
|
+
scope: {
|
|
2140
|
+
action: 'goal-conductor-proposal-once', once: true, dryRun: false,
|
|
2141
|
+
resident: false, maxGoals: 1, maxMilestones: 1, maxRetries: 0,
|
|
2142
|
+
allowCloud: false, allowAnyRepo: false, daemonFallback: false,
|
|
2143
|
+
proposalOnly: true, automerge: false, repair: false, deploy: false,
|
|
2144
|
+
install: false, selfTarget: false,
|
|
2145
|
+
maxTokens: GOAL_CONDUCTOR_ONCE_MAX_TOKENS,
|
|
2146
|
+
maxSteps: GOAL_CONDUCTOR_ONCE_MAX_STEPS,
|
|
2147
|
+
},
|
|
2148
|
+
bindings: {
|
|
2149
|
+
configDigest: input.context.configDigest,
|
|
2150
|
+
buildIdentity: { ...input.context.buildIdentity },
|
|
2151
|
+
executable: { ...input.context.executable },
|
|
2152
|
+
entrypoint: { ...input.context.entrypoint },
|
|
2153
|
+
releaseTree: { ...input.context.releaseTree },
|
|
2154
|
+
authorityStateDigest: input.context.authorityStateDigest,
|
|
2155
|
+
killSwitch: 'off', guardHealth: 'healthy',
|
|
2156
|
+
target: { ...input.context.target },
|
|
2157
|
+
},
|
|
2158
|
+
};
|
|
2159
|
+
if (!parseGoalConductorPayload(payload))
|
|
2160
|
+
throw new Error('invalid goal conductor permit payload input');
|
|
2161
|
+
return payload;
|
|
2162
|
+
}
|
|
2163
|
+
export function signGoalConductorActivationPermit(payload, privateKey) {
|
|
2164
|
+
if (!parseGoalConductorPayload(payload))
|
|
2165
|
+
throw new Error('invalid goal conductor permit payload');
|
|
2166
|
+
const key = typeof privateKey === 'string' ? createPrivateKey(privateKey) : privateKey;
|
|
2167
|
+
if (key.type !== 'private' || key.asymmetricKeyType !== 'ed25519') {
|
|
2168
|
+
throw new Error('goal conductor activation permits require an Ed25519 private key');
|
|
2169
|
+
}
|
|
2170
|
+
return { payload, signature: sign(null, goalConductorSigningBytes(payload), key).toString('base64') };
|
|
2171
|
+
}
|
|
2172
|
+
export function verifyGoalConductorActivationPermit(value, context, roots) {
|
|
2173
|
+
if (roots.length === 0)
|
|
2174
|
+
return { ok: false, reason: 'no-trusted-goal-conductor-activation-roots' };
|
|
2175
|
+
const envelope = parseGoalConductorActivationPermitEnvelope(value);
|
|
2176
|
+
if (!envelope)
|
|
2177
|
+
return { ok: false, reason: 'invalid-goal-conductor-permit-schema' };
|
|
2178
|
+
if (!Number.isSafeInteger(context.nowMs) || context.nowMs < 0
|
|
2179
|
+
|| !validGoalConductorTarget(context.target)
|
|
2180
|
+
|| !context.killSwitchOff || !context.guardHealthHealthy
|
|
2181
|
+
|| !validBuildIdentity(context.buildIdentity)
|
|
2182
|
+
|| !validFileBinding(context.executable)
|
|
2183
|
+
|| !validFileBinding(context.entrypoint)
|
|
2184
|
+
|| !validFileBinding(context.releaseTree)
|
|
2185
|
+
|| !DIGEST_RE.test(context.configDigest)
|
|
2186
|
+
|| !DIGEST_RE.test(context.authorityStateDigest)) {
|
|
2187
|
+
return { ok: false, reason: 'invalid-runtime-goal-conductor-context' };
|
|
2188
|
+
}
|
|
2189
|
+
const issuedAt = Date.parse(envelope.payload.issuedAt);
|
|
2190
|
+
const expiresAt = Date.parse(envelope.payload.expiresAt);
|
|
2191
|
+
if (expiresAt <= issuedAt || expiresAt - issuedAt > MAX_VALIDITY_MS) {
|
|
2192
|
+
return { ok: false, reason: 'invalid-goal-conductor-permit-validity-window' };
|
|
2193
|
+
}
|
|
2194
|
+
if (issuedAt > context.nowMs + MAX_FUTURE_SKEW_MS)
|
|
2195
|
+
return { ok: false, reason: 'permit-issued-too-far-in-future' };
|
|
2196
|
+
if (expiresAt <= context.nowMs)
|
|
2197
|
+
return { ok: false, reason: 'permit-expired' };
|
|
2198
|
+
const expectedBindings = {
|
|
2199
|
+
configDigest: context.configDigest,
|
|
2200
|
+
buildIdentity: context.buildIdentity,
|
|
2201
|
+
executable: context.executable,
|
|
2202
|
+
entrypoint: context.entrypoint,
|
|
2203
|
+
releaseTree: context.releaseTree,
|
|
2204
|
+
authorityStateDigest: context.authorityStateDigest,
|
|
2205
|
+
killSwitch: 'off', guardHealth: 'healthy', target: context.target,
|
|
2206
|
+
};
|
|
2207
|
+
if (!equalCanonical(envelope.payload.bindings, expectedBindings)) {
|
|
2208
|
+
return { ok: false, reason: 'goal-conductor-permit-runtime-binding-mismatch' };
|
|
2209
|
+
}
|
|
2210
|
+
const trusted = trustRootMap(roots);
|
|
2211
|
+
const publicKey = trusted?.get(envelope.payload.keyId);
|
|
2212
|
+
if (!publicKey)
|
|
2213
|
+
return { ok: false, reason: trusted ? 'permit-key-not-trusted' : 'invalid-trust-root-set' };
|
|
2214
|
+
if (!verify(null, goalConductorSigningBytes(envelope.payload), publicKey, Buffer.from(envelope.signature, 'base64'))) {
|
|
2215
|
+
return { ok: false, reason: 'invalid-permit-signature' };
|
|
2216
|
+
}
|
|
2217
|
+
return {
|
|
2218
|
+
ok: true,
|
|
2219
|
+
reason: 'valid-goal-conductor-proposal-once-permit',
|
|
2220
|
+
permitId: envelope.payload.permitId,
|
|
2221
|
+
payloadDigest: sha256(canonicalizeDaemonActivationValue(envelope.payload)),
|
|
2222
|
+
};
|
|
2223
|
+
}
|
|
2224
|
+
export function goalConductorActivationPermitPath() {
|
|
2225
|
+
return join(homedir(), '.ashlr', 'control', 'goal-conductor-permit-publication', 'records', 'goal-conductor-activation-permit.json');
|
|
2226
|
+
}
|
|
2227
|
+
export function goalConductorActivationReceiptPath(permitId) {
|
|
2228
|
+
if (!ID_RE.test(permitId))
|
|
2229
|
+
throw new Error('invalid goal conductor activation permit id');
|
|
2230
|
+
return join(homedir(), '.ashlr', 'control', 'activation-receipts', 'goal-conductor', `${permitId}.json`);
|
|
2231
|
+
}
|
|
2232
|
+
export function goalConductorActivationNonceReceiptPath(nonceDigest) {
|
|
2233
|
+
if (!DIGEST_RE.test(nonceDigest))
|
|
2234
|
+
throw new Error('invalid goal conductor nonce digest');
|
|
2235
|
+
return join(homedir(), '.ashlr', 'control', 'activation-receipts', 'goal-conductor', 'by-nonce', `${nonceDigest}.json`);
|
|
2236
|
+
}
|
|
2237
|
+
/** Collect the same read-only runtime binding used at permit consumption. */
|
|
2238
|
+
export function collectGoalConductorActivationContext(cfg, target) {
|
|
2239
|
+
if (!validGoalConductorTarget(target)) {
|
|
2240
|
+
throw new Error('invalid goal conductor target');
|
|
2241
|
+
}
|
|
2242
|
+
return { ...collectRuntimeContext(strictConfigSnapshot(cfg)), target: { ...target } };
|
|
2243
|
+
}
|
|
2244
|
+
function mintGoalConductorCapability(permitId, target, validateAtClaim) {
|
|
2245
|
+
const capability = Object.freeze({
|
|
2246
|
+
kind: 'goal-conductor-proposal-once',
|
|
2247
|
+
permitId,
|
|
2248
|
+
target: Object.freeze({ ...target }),
|
|
2249
|
+
[goalConductorCapabilityBrand]: true,
|
|
2250
|
+
});
|
|
2251
|
+
validGoalConductorCapabilities.set(capability, validateAtClaim);
|
|
2252
|
+
return capability;
|
|
2253
|
+
}
|
|
2254
|
+
export function isGoalConductorActivationCapability(value, target) {
|
|
2255
|
+
if (typeof value !== 'object' || value === null)
|
|
2256
|
+
return false;
|
|
2257
|
+
const validate = validGoalConductorCapabilities.get(value);
|
|
2258
|
+
if (!validate)
|
|
2259
|
+
return false;
|
|
2260
|
+
validGoalConductorCapabilities.delete(value);
|
|
2261
|
+
const capability = value;
|
|
2262
|
+
try {
|
|
2263
|
+
return equalCanonical(capability.target, target) && validate();
|
|
2264
|
+
}
|
|
2265
|
+
catch {
|
|
2266
|
+
return false;
|
|
2267
|
+
}
|
|
2268
|
+
}
|
|
2269
|
+
function consumeGoalConductorWithAuthority(cfg, target, roots, suppliedContext, afterReceiptPersisted, mayMintCapability = false) {
|
|
2270
|
+
if (roots.length === 0)
|
|
2271
|
+
return { authorized: false, reason: 'no-trusted-goal-conductor-activation-roots' };
|
|
2272
|
+
if (process.platform === 'win32')
|
|
2273
|
+
return { authorized: false, reason: 'goal-conductor-activation-v1-unsupported-on-windows' };
|
|
2274
|
+
let configSnapshot;
|
|
2275
|
+
try {
|
|
2276
|
+
configSnapshot = strictConfigSnapshot(cfg);
|
|
2277
|
+
}
|
|
2278
|
+
catch {
|
|
2279
|
+
return { authorized: false, reason: 'activation-config-not-strict-json' };
|
|
2280
|
+
}
|
|
2281
|
+
const home = resolve(homedir());
|
|
2282
|
+
const permitPath = goalConductorActivationPermitPath();
|
|
2283
|
+
const lock = acquireLocalStoreLock(`${permitPath}.lock`, LOCK_WAIT_MS, {
|
|
2284
|
+
anchorPath: home, exactPrivateStorage: true,
|
|
2285
|
+
});
|
|
2286
|
+
if (!lock)
|
|
2287
|
+
return { authorized: false, reason: 'goal-conductor-permit-lock-unavailable' };
|
|
2288
|
+
let pinned;
|
|
2289
|
+
let result = {
|
|
2290
|
+
authorized: false, reason: 'goal-conductor-permit-consumption-failed',
|
|
2291
|
+
};
|
|
2292
|
+
try {
|
|
2293
|
+
pinned = openPinnedPermit(permitPath, home);
|
|
2294
|
+
const envelope = JSON.parse(pinned.text);
|
|
2295
|
+
if (`${canonicalizeDaemonActivationValue(envelope)}\n` !== pinned.text) {
|
|
2296
|
+
result = { authorized: false, reason: 'noncanonical-goal-conductor-permit-encoding' };
|
|
2297
|
+
return result;
|
|
2298
|
+
}
|
|
2299
|
+
const parsed = parseGoalConductorActivationPermitEnvelope(envelope);
|
|
2300
|
+
if (!parsed) {
|
|
2301
|
+
result = { authorized: false, reason: 'invalid-goal-conductor-permit-schema' };
|
|
2302
|
+
return result;
|
|
2303
|
+
}
|
|
2304
|
+
const context = suppliedContext ?? { ...collectRuntimeContext(configSnapshot), target };
|
|
2305
|
+
if (context.configDigest !== daemonActivationConfigDigest(configSnapshot)) {
|
|
2306
|
+
result = { authorized: false, reason: 'runtime-config-digest-mismatch' };
|
|
2307
|
+
return result;
|
|
2308
|
+
}
|
|
2309
|
+
if (!equalCanonical(context.target, target)) {
|
|
2310
|
+
result = { authorized: false, reason: 'goal-conductor-target-context-mismatch' };
|
|
2311
|
+
return result;
|
|
2312
|
+
}
|
|
2313
|
+
const checked = verifyGoalConductorActivationPermit(parsed, context, roots);
|
|
2314
|
+
if (!checked.ok || !checked.permitId || !checked.payloadDigest) {
|
|
2315
|
+
result = { authorized: false, reason: checked.reason };
|
|
2316
|
+
return result;
|
|
2317
|
+
}
|
|
2318
|
+
const receiptPath = goalConductorActivationReceiptPath(checked.permitId);
|
|
2319
|
+
assurePrivateDirectory(dirname(dirname(receiptPath)), home);
|
|
2320
|
+
assurePrivateDirectory(dirname(receiptPath), home);
|
|
2321
|
+
const nonceDigest = sha256(parsed.payload.nonce);
|
|
2322
|
+
const nonceReceiptPath = goalConductorActivationNonceReceiptPath(nonceDigest);
|
|
2323
|
+
assurePrivateDirectory(dirname(nonceReceiptPath), home);
|
|
2324
|
+
const receipt = {
|
|
2325
|
+
schemaVersion: 1,
|
|
2326
|
+
state: 'consumed-before-permit-unlink',
|
|
2327
|
+
action: 'goal-conductor-proposal-once',
|
|
2328
|
+
permitId: checked.permitId,
|
|
2329
|
+
keyId: parsed.payload.keyId,
|
|
2330
|
+
payloadDigest: checked.payloadDigest,
|
|
2331
|
+
configDigest: context.configDigest,
|
|
2332
|
+
goalId: target.goalId,
|
|
2333
|
+
milestoneId: target.milestoneId,
|
|
2334
|
+
goalDigest: target.goalDigest,
|
|
2335
|
+
consumedAt: new Date(context.nowMs).toISOString(),
|
|
2336
|
+
};
|
|
2337
|
+
try {
|
|
2338
|
+
persistReceipt(nonceReceiptPath, { ...receipt, index: 'nonce', nonceDigest }, home);
|
|
2339
|
+
persistReceipt(receiptPath, receipt, home);
|
|
2340
|
+
}
|
|
2341
|
+
catch (error) {
|
|
2342
|
+
result = {
|
|
2343
|
+
authorized: false,
|
|
2344
|
+
reason: error.code === 'EEXIST'
|
|
2345
|
+
? 'goal-conductor-permit-already-consumed'
|
|
2346
|
+
: 'goal-conductor-receipt-persistence-failed',
|
|
2347
|
+
permitId: checked.permitId,
|
|
2348
|
+
receiptPath,
|
|
2349
|
+
};
|
|
2350
|
+
return result;
|
|
2351
|
+
}
|
|
2352
|
+
try {
|
|
2353
|
+
afterReceiptPersisted?.();
|
|
2354
|
+
}
|
|
2355
|
+
catch {
|
|
2356
|
+
result = {
|
|
2357
|
+
authorized: false, reason: 'goal-conductor-activation-interrupted-after-durable-receipt',
|
|
2358
|
+
permitId: checked.permitId, receiptPath,
|
|
2359
|
+
};
|
|
2360
|
+
return result;
|
|
2361
|
+
}
|
|
2362
|
+
const opened = fstatSync(pinned.fd, { bigint: true });
|
|
2363
|
+
const named = lstatSync(permitPath, { bigint: true });
|
|
2364
|
+
if (!sameFileSnapshot(pinned.stat, opened) || !sameFileSnapshot(opened, named)) {
|
|
2365
|
+
result = {
|
|
2366
|
+
authorized: false, reason: 'goal-conductor-permit-changed-before-unlink',
|
|
2367
|
+
permitId: checked.permitId, receiptPath,
|
|
2368
|
+
};
|
|
2369
|
+
return result;
|
|
2370
|
+
}
|
|
2371
|
+
unlinkSync(permitPath);
|
|
2372
|
+
fsyncDirectory(dirname(permitPath));
|
|
2373
|
+
result = {
|
|
2374
|
+
authorized: true,
|
|
2375
|
+
reason: 'goal-conductor-proposal-once-activation-authorized',
|
|
2376
|
+
permitId: checked.permitId,
|
|
2377
|
+
receiptPath,
|
|
2378
|
+
...(mayMintCapability ? {
|
|
2379
|
+
capability: mintGoalConductorCapability(checked.permitId, target, () => {
|
|
2380
|
+
const claimContext = { ...collectRuntimeContext(configSnapshot), target };
|
|
2381
|
+
return verifyGoalConductorActivationPermit(parsed, claimContext, roots).ok;
|
|
2382
|
+
}),
|
|
2383
|
+
configSnapshot,
|
|
2384
|
+
} : {}),
|
|
2385
|
+
};
|
|
2386
|
+
return result;
|
|
2387
|
+
}
|
|
2388
|
+
catch (error) {
|
|
2389
|
+
result = {
|
|
2390
|
+
authorized: false,
|
|
2391
|
+
reason: error.code === 'ENOENT'
|
|
2392
|
+
? 'goal-conductor-activation-permit-missing'
|
|
2393
|
+
: 'goal-conductor-permit-consumption-failed',
|
|
2394
|
+
};
|
|
2395
|
+
return result;
|
|
2396
|
+
}
|
|
2397
|
+
finally {
|
|
2398
|
+
if (pinned) {
|
|
2399
|
+
try {
|
|
2400
|
+
closeSync(pinned.fd);
|
|
2401
|
+
}
|
|
2402
|
+
catch {
|
|
2403
|
+
result.authorized = false;
|
|
2404
|
+
result.reason = 'goal-conductor-permit-close-failed';
|
|
2405
|
+
delete result.capability;
|
|
2406
|
+
}
|
|
2407
|
+
}
|
|
2408
|
+
if (!releaseLocalStoreLock(lock)) {
|
|
2409
|
+
result.authorized = false;
|
|
2410
|
+
result.reason = 'goal-conductor-permit-lock-release-failed';
|
|
2411
|
+
delete result.capability;
|
|
2412
|
+
}
|
|
2413
|
+
}
|
|
2414
|
+
}
|
|
2415
|
+
export function consumeGoalConductorActivationPermit(cfg, target) {
|
|
2416
|
+
return consumeGoalConductorWithAuthority(cfg, target, GOAL_CONDUCTOR_ACTIVATION_TRUST_ROOTS, undefined, undefined, true);
|
|
2417
|
+
}
|
|
2418
|
+
export function consumeGoalConductorActivationPermitForVerification(cfg, target, options) {
|
|
2419
|
+
return consumeGoalConductorWithAuthority(cfg, target, options.trustRoots, options.context, options.afterReceiptPersisted, false);
|
|
2420
|
+
}
|
|
1019
2421
|
//# sourceMappingURL=activation-permit.js.map
|