@ashlr/hub 3.3.0 → 3.3.2

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.
Files changed (123) hide show
  1. package/CHANGELOG.md +46 -2
  2. package/README.md +51 -38
  3. package/dist/build-identity.json +2 -2
  4. package/dist/cli/completions.js +1 -2
  5. package/dist/cli/completions.js.map +1 -1
  6. package/dist/cli/dashboard.js +5 -4
  7. package/dist/cli/dashboard.js.map +1 -1
  8. package/dist/cli/help.js +7 -10
  9. package/dist/cli/help.js.map +1 -1
  10. package/dist/cli/index.d.ts +0 -1
  11. package/dist/cli/index.js +0 -10
  12. package/dist/cli/index.js.map +1 -1
  13. package/dist/cli/serve.js +7 -3
  14. package/dist/cli/serve.js.map +1 -1
  15. package/dist/cli/setup.d.ts +5 -3
  16. package/dist/cli/setup.js +14 -7
  17. package/dist/cli/setup.js.map +1 -1
  18. package/dist/cli/update.js +3 -1
  19. package/dist/cli/update.js.map +1 -1
  20. package/dist/core/daemon/activation-permit.d.ts +33 -268
  21. package/dist/core/daemon/activation-permit.js +106 -1111
  22. package/dist/core/daemon/activation-permit.js.map +1 -1
  23. package/dist/core/daemon/loop.d.ts +1 -13
  24. package/dist/core/daemon/loop.js +10 -113
  25. package/dist/core/daemon/loop.js.map +1 -1
  26. package/dist/core/daemon/service-install-authority.d.ts +5 -1
  27. package/dist/core/daemon/service-install-authority.js +7 -20
  28. package/dist/core/daemon/service-install-authority.js.map +1 -1
  29. package/dist/core/fleet/automerge-pass.js +28 -77
  30. package/dist/core/fleet/automerge-pass.js.map +1 -1
  31. package/dist/core/fleet/decisions-ledger.js +1 -1
  32. package/dist/core/fleet/decisions-ledger.js.map +1 -1
  33. package/dist/core/fleet/judge-trace.js +2 -1
  34. package/dist/core/fleet/judge-trace.js.map +1 -1
  35. package/dist/core/fleet/manager.d.ts +6 -0
  36. package/dist/core/fleet/manager.js +35 -17
  37. package/dist/core/fleet/manager.js.map +1 -1
  38. package/dist/core/fleet/post-merge-credit-label.d.ts +10 -0
  39. package/dist/core/fleet/post-merge-credit-label.js +13 -0
  40. package/dist/core/fleet/post-merge-credit-label.js.map +1 -0
  41. package/dist/core/fleet/post-merge-credit.d.ts +20 -38
  42. package/dist/core/fleet/post-merge-credit.js +38 -39
  43. package/dist/core/fleet/post-merge-credit.js.map +1 -1
  44. package/dist/core/fleet/self-improve.d.ts +7 -7
  45. package/dist/core/fleet/self-improve.js +104 -20
  46. package/dist/core/fleet/self-improve.js.map +1 -1
  47. package/dist/core/fleet/status.js +0 -1
  48. package/dist/core/fleet/status.js.map +1 -1
  49. package/dist/core/foundry/automerge-scope-policy.d.ts +2 -2
  50. package/dist/core/foundry/automerge-scope-policy.js +2 -9
  51. package/dist/core/foundry/automerge-scope-policy.js.map +1 -1
  52. package/dist/core/goals/store.js +60 -20
  53. package/dist/core/goals/store.js.map +1 -1
  54. package/dist/core/inbox/apply.js +3 -5
  55. package/dist/core/inbox/apply.js.map +1 -1
  56. package/dist/core/inbox/merge.d.ts +0 -84
  57. package/dist/core/inbox/merge.js +23 -277
  58. package/dist/core/inbox/merge.js.map +1 -1
  59. package/dist/core/learn/reflect.js +10 -0
  60. package/dist/core/learn/reflect.js.map +1 -1
  61. package/dist/core/onboard.d.ts +2 -0
  62. package/dist/core/onboard.js +9 -3
  63. package/dist/core/onboard.js.map +1 -1
  64. package/dist/core/run/orchestrator.js +2 -0
  65. package/dist/core/run/orchestrator.js.map +1 -1
  66. package/dist/core/types.d.ts +5 -4
  67. package/dist/core/types.js.map +1 -1
  68. package/dist/core/web/api.d.ts +6 -0
  69. package/dist/core/web/api.js +69 -11
  70. package/dist/core/web/api.js.map +1 -1
  71. package/dist/core/web/public/app.js +5 -1
  72. package/dist/core/web/public/next/assets/index-CDHQraxS.css +1 -0
  73. package/dist/core/web/public/next/assets/index-zVKD9krw.js +69 -0
  74. package/dist/core/web/public/next/assets/{index-Ct0mf8n4.js.map → index-zVKD9krw.js.map} +1 -1
  75. package/dist/core/web/public/next/index.html +2 -2
  76. package/dist/core/web/read-session-revocations.d.ts +26 -0
  77. package/dist/core/web/read-session-revocations.js +48 -0
  78. package/dist/core/web/read-session-revocations.js.map +1 -0
  79. package/dist/core/web/server.js +73 -27
  80. package/dist/core/web/server.js.map +1 -1
  81. package/dist/core/web/static.d.ts +1 -1
  82. package/dist/core/web/static.js +54 -20
  83. package/dist/core/web/static.js.map +1 -1
  84. package/dist/release-dependency-inventory.json +1 -1
  85. package/docs/RUNTIME_ACTIVATION_AUTHORITY.md +0 -11
  86. package/node_modules/fast-uri/index.js +201 -48
  87. package/node_modules/fast-uri/lib/schemes.js +9 -4
  88. package/node_modules/fast-uri/lib/utils.js +388 -91
  89. package/node_modules/fast-uri/package.json +1 -1
  90. package/node_modules/fast-uri/test/component-safe-serialization.test.js +105 -0
  91. package/node_modules/fast-uri/test/equal.test.js +31 -3
  92. package/node_modules/fast-uri/test/fixtures/uri-js-parse.json +2 -0
  93. package/node_modules/fast-uri/test/ipv6-canonical.test.js +34 -0
  94. package/node_modules/fast-uri/test/ipv6-validation.test.js +90 -0
  95. package/node_modules/fast-uri/test/malformed-percent.test.js +77 -0
  96. package/node_modules/fast-uri/test/malformed-urn.test.js +61 -0
  97. package/node_modules/fast-uri/test/parse.test.js +7 -3
  98. package/node_modules/fast-uri/test/query-fragment-normalization.test.js +33 -0
  99. package/node_modules/fast-uri/test/reserved-path-normalization.test.js +109 -0
  100. package/node_modules/fast-uri/test/scheme-validation.test.js +124 -0
  101. package/node_modules/fast-uri/test/security-normalization.test.js +101 -0
  102. package/node_modules/fast-uri/test/security.test.js +75 -3
  103. package/node_modules/fast-uri/test/urn-full-input.test.js +29 -0
  104. package/node_modules/fast-uri/test/websocket-query-preservation.test.js +24 -0
  105. package/node_modules/qs/.github/THREAT_MODEL.md +3 -3
  106. package/node_modules/qs/CHANGELOG.md +26 -0
  107. package/node_modules/qs/README.md +19 -1
  108. package/node_modules/qs/dist/qs.js +25 -25
  109. package/node_modules/qs/eslint.config.mjs +1 -1
  110. package/node_modules/qs/lib/parse.js +16 -6
  111. package/node_modules/qs/lib/stringify.js +23 -8
  112. package/node_modules/qs/lib/utils.js +57 -11
  113. package/node_modules/qs/package.json +8 -7
  114. package/node_modules/qs/test/parse.js +419 -0
  115. package/node_modules/qs/test/stringify.js +317 -0
  116. package/node_modules/qs/test/utils.js +206 -0
  117. package/package.json +5 -4
  118. package/schema/config.schema.json +7 -7
  119. package/dist/cli/activation.d.ts +0 -41
  120. package/dist/cli/activation.js +0 -485
  121. package/dist/cli/activation.js.map +0 -1
  122. package/dist/core/web/public/next/assets/index-CY0U241s.css +0 -1
  123. package/dist/core/web/public/next/assets/index-Ct0mf8n4.js +0 -69
@@ -1,69 +1,15 @@
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';
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';
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';
8
7
  import { killSwitchOn, readEnrollmentRegistry } from '../sandbox/policy.js';
9
8
  import { fsyncDirectory } from '../util/durability.js';
10
9
  import { assurePrivateStoragePath } from '../util/private-storage.js';
11
10
  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
- */
64
11
  const POLICY_VERSION = 'm461-proposal-once-v1';
65
12
  const SIGNING_DOMAIN = 'ashlr:daemon-activation-permit:m461:v1\0';
66
- const GRANT_SIGNING_DOMAIN = 'ashlr:daemon-activation-grant:v1\0';
67
13
  const MAX_PERMIT_BYTES = 32 * 1024;
68
14
  const MAX_VALIDITY_MS = 120_000;
69
15
  const MAX_FUTURE_SKEW_MS = 30_000;
@@ -73,51 +19,18 @@ const PRIVATE_FILE_MODE = 0o600;
73
19
  const DIGEST_RE = /^[a-f0-9]{64}$/u;
74
20
  const ID_RE = /^[a-f0-9]{32}$/u;
75
21
  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;
83
22
  const capabilityBrand = Symbol('ashlr.daemon-activation-capability');
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
- });
23
+ /**
24
+ * Provisioning a production trust root requires a reviewed source change.
25
+ * No environment variable, config field, CLI argument, or writable trust file
26
+ * can add authority at runtime.
27
+ */
28
+ export const DAEMON_ACTIVATION_TRUST_ROOTS = Object.freeze([]);
115
29
  const validCapabilities = new WeakMap();
116
- function mintCapability(permitId, scope, validateAtClaim) {
30
+ function mintCapability(permitId, validateAtClaim) {
117
31
  const capability = Object.freeze({
118
32
  kind: 'proposal-once',
119
33
  permitId,
120
- scope: Object.freeze({ ...scope }),
121
34
  [capabilityBrand]: true,
122
35
  });
123
36
  validCapabilities.set(capability, validateAtClaim);
@@ -139,6 +52,14 @@ export function isDaemonActivationCapability(value) {
139
52
  return false;
140
53
  }
141
54
  }
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
+ }
142
63
  function isRecord(value) {
143
64
  return typeof value === 'object' && value !== null && !Array.isArray(value);
144
65
  }
@@ -245,43 +166,33 @@ function validFileBinding(value) {
245
166
  && DIGEST_RE.test(String(value['sha256']));
246
167
  }
247
168
  function validScope(value) {
248
- if (!isRecord(value) || !hasExactKeys(value, SCOPE_KEYS))
249
- return false;
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))
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
+ ]))
275
183
  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;
184
+ return value['action'] === 'daemon-proposal-once'
185
+ && value['once'] === true
186
+ && value['dryRun'] === false
187
+ && value['resident'] === false
188
+ && value['drain'] === false
189
+ && value['drainLimit'] === null
190
+ && value['proposalOnly'] === true
191
+ && value['automerge'] === false
192
+ && value['repair'] === false
193
+ && value['deploy'] === false
194
+ && value['install'] === false
195
+ && value['selfTarget'] === false;
285
196
  }
286
197
  function validBindings(value) {
287
198
  if (!isRecord(value) || !hasExactKeys(value, [
@@ -374,11 +285,6 @@ function trustRootMap(roots) {
374
285
  function equalCanonical(left, right) {
375
286
  return canonicalizeDaemonActivationValue(left) === canonicalizeDaemonActivationValue(right);
376
287
  }
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
- }
382
288
  export function verifyDaemonActivationPermit(value, context, trustRoots) {
383
289
  if (trustRoots.length === 0)
384
290
  return { ok: false, reason: 'no-trusted-activation-roots' };
@@ -427,11 +333,7 @@ export function verifyDaemonActivationPermit(value, context, trustRoots) {
427
333
  guardHealth: 'healthy',
428
334
  };
429
335
  if (!equalCanonical(envelope.payload.bindings, expectedBindings)) {
430
- return {
431
- ok: false,
432
- reason: 'permit-runtime-binding-mismatch',
433
- mismatchedBindings: bindingFieldMismatches(envelope.payload.bindings, expectedBindings),
434
- };
336
+ return { ok: false, reason: 'permit-runtime-binding-mismatch' };
435
337
  }
436
338
  const publicKey = trusted.get(envelope.payload.keyId);
437
339
  if (!publicKey)
@@ -455,7 +357,6 @@ export function verifyDaemonActivationPermit(value, context, trustRoots) {
455
357
  };
456
358
  }
457
359
  export function buildDaemonActivationPermitPayload(input) {
458
- const scope = input.scope ?? LEGACY_ONCE_PROPOSAL_SCOPE;
459
360
  const payload = {
460
361
  schemaVersion: 1,
461
362
  policyVersion: POLICY_VERSION,
@@ -464,7 +365,20 @@ export function buildDaemonActivationPermitPayload(input) {
464
365
  keyId: input.keyId,
465
366
  issuedAt: input.issuedAt,
466
367
  expiresAt: input.expiresAt,
467
- scope: { ...scope },
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
+ },
468
382
  bindings: {
469
383
  configDigest: input.context.configDigest,
470
384
  buildIdentity: { ...input.context.buildIdentity },
@@ -496,12 +410,6 @@ function safeOwnedFile(stat) {
496
410
  const privateMode = process.platform === 'win32' || (stat.mode & 63n) === 0n;
497
411
  return stat.isFile() && !stat.isSymbolicLink() && stat.nlink === 1n && owned && privateMode;
498
412
  }
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
- }
505
413
  function sameFileSnapshot(left, right) {
506
414
  return left.dev === right.dev
507
415
  && left.ino === right.ino
@@ -712,26 +620,7 @@ function hashStableReleaseTree(entrypointPath) {
712
620
  export function daemonActivationReleaseTreeBinding(entrypointPath) {
713
621
  return hashStableReleaseTree(entrypointPath);
714
622
  }
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() {
623
+ function currentAuthorityStateDigest() {
735
624
  const home = realpathSync(resolve(homedir()));
736
625
  const ashlrHome = realpathSync(join(home, '.ashlr'));
737
626
  const state = lstatSync(ashlrHome, { bigint: true });
@@ -750,6 +639,7 @@ export function daemonActivationAuthorityStateDigest() {
750
639
  path: ashlrHome,
751
640
  dev: state.dev.toString(),
752
641
  ino: state.ino.toString(),
642
+ mtimeNs: state.mtimeNs.toString(),
753
643
  },
754
644
  enrollment: [...enrollment.repos].sort(),
755
645
  }));
@@ -766,7 +656,7 @@ function collectRuntimeContext(cfg) {
766
656
  executable: hashStableFile(process.execPath),
767
657
  entrypoint: hashStableFile(entrypoint),
768
658
  releaseTree: daemonActivationReleaseTreeBinding(entrypoint),
769
- authorityStateDigest: daemonActivationAuthorityStateDigest(),
659
+ authorityStateDigest: currentAuthorityStateDigest(),
770
660
  killSwitchOff: !killSwitchOn(),
771
661
  guardHealthHealthy: !guard.blocked,
772
662
  };
@@ -784,734 +674,7 @@ function daemonActivationNonceReceiptPath(nonceDigest) {
784
674
  throw new Error('invalid daemon activation nonce digest');
785
675
  return join(homedir(), '.ashlr', 'control', 'activation-receipts', 'by-nonce', `${nonceDigest}.json`);
786
676
  }
787
- // ---------------------------------------------------------------------------
788
- // Operator authority store — trust roots + standing grants.
789
- //
790
- // Root of trust is OS-level file ownership/permissions on the operator's own
791
- // machine (mirrors the one-shot permit's existing safety model), not a
792
- // central registry. Every store read fails CLOSED to "no authority" on any
793
- // ambiguity: missing file, unreadable, wrong owner, wrong permissions,
794
- // symlinked path, malformed JSON, non-canonical encoding, or duplicate ids.
795
- // ---------------------------------------------------------------------------
796
- export function daemonActivationDirPath() {
797
- return join(homedir(), '.ashlr', 'activation');
798
- }
799
- export function daemonActivationTrustRootsPath() {
800
- return join(daemonActivationDirPath(), 'trust-roots.json');
801
- }
802
- export function daemonActivationGrantsPath() {
803
- return join(daemonActivationDirPath(), 'grants.json');
804
- }
805
- export function daemonActivationOperatorKeyPath() {
806
- return join(daemonActivationDirPath(), 'operator-private-key.pem');
807
- }
808
- function daemonActivationStoreLockPath() {
809
- return join(daemonActivationDirPath(), 'activation-store.lock');
810
- }
811
- function pathEntryPresent(path) {
812
- try {
813
- lstatSync(path);
814
- return true;
815
- }
816
- catch (error) {
817
- if (error.code === 'ENOENT')
818
- return false;
819
- throw error;
820
- }
821
- }
822
- /** Safe, non-consuming read of a canonical-JSON-array operator store file. */
823
- function safeReadOwnedJsonArray(path, anchorPath, maxBytes) {
824
- if (process.platform === 'win32') {
825
- return { ok: false, reason: 'activation-permit-v1-unsupported-on-windows' };
826
- }
827
- let dirStat;
828
- try {
829
- dirStat = lstatSync(dirname(path), { bigint: true });
830
- }
831
- catch (error) {
832
- return { ok: false, reason: error.code === 'ENOENT' ? 'missing' : 'stat-failed' };
833
- }
834
- if (!safeOwnedDirectory(dirStat))
835
- return { ok: false, reason: 'unsafe-directory' };
836
- const dirAssurance = assurePrivateStoragePath(dirname(path), 'directory', 'inspect-owned', { anchorPath });
837
- if (!dirAssurance.ok)
838
- return { ok: false, reason: `unsafe-directory:${dirAssurance.reason}` };
839
- // Open first, then validate the fd — the check (owner/mode/regular-file/
840
- // size) and the read below share one file description, so there is no
841
- // window between "checked" and "used" for an attacker to swap the path.
842
- const noFollow = typeof fsConstants.O_NOFOLLOW === 'number' ? fsConstants.O_NOFOLLOW : 0;
843
- let fd;
844
- try {
845
- fd = openSync(path, fsConstants.O_RDONLY | noFollow);
846
- }
847
- catch (error) {
848
- return { ok: false, reason: error.code === 'ENOENT' ? 'missing' : 'open-failed' };
849
- }
850
- try {
851
- const opened = fstatSync(fd, { bigint: true });
852
- if (!safeOwnedFile(opened) || opened.size <= 0n || opened.size > BigInt(maxBytes)) {
853
- return { ok: false, reason: 'unsafe-file' };
854
- }
855
- const size = Number(opened.size);
856
- const bytes = Buffer.alloc(size);
857
- let offset = 0;
858
- while (offset < size) {
859
- const count = readSync(fd, bytes, offset, size - offset, offset);
860
- if (count <= 0)
861
- return { ok: false, reason: 'short-read' };
862
- offset += count;
863
- }
864
- const after = fstatSync(fd, { bigint: true });
865
- if (!sameFileSnapshot(opened, after))
866
- return { ok: false, reason: 'changed-during-read' };
867
- const text = bytes.toString('utf8');
868
- let parsed;
869
- try {
870
- parsed = JSON.parse(text);
871
- }
872
- catch {
873
- return { ok: false, reason: 'invalid-json' };
874
- }
875
- if (`${canonicalizeDaemonActivationValue(parsed)}\n` !== text) {
876
- return { ok: false, reason: 'noncanonical-encoding' };
877
- }
878
- if (!Array.isArray(parsed))
879
- return { ok: false, reason: 'not-an-array' };
880
- const fileAssurance = assurePrivateStoragePath(path, 'file', 'inspect-owned', { anchorPath });
881
- if (!fileAssurance.ok)
882
- return { ok: false, reason: `unsafe-file:${fileAssurance.reason}` };
883
- return { ok: true, value: parsed };
884
- }
885
- finally {
886
- closeSync(fd);
887
- }
888
- }
889
- /** Atomically replace a canonical-JSON-array operator store file (temp write + rename). */
890
- function writeCanonicalJsonArrayFileAtomic(path, value, anchorPath) {
891
- assurePrivateDirectory(dirname(path), anchorPath);
892
- const tmpPath = `${path}.tmp-${randomBytes(8).toString('hex')}`;
893
- const noFollow = typeof fsConstants.O_NOFOLLOW === 'number' ? fsConstants.O_NOFOLLOW : 0;
894
- let fd;
895
- try {
896
- fd = openSync(tmpPath, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | noFollow, PRIVATE_FILE_MODE);
897
- const initial = fstatSync(fd, { bigint: true });
898
- if (!safeOwnedFile(initial) || initial.size !== 0n)
899
- throw new Error('unsafe-activation-store-temp-file');
900
- fchmodSync(fd, PRIVATE_FILE_MODE);
901
- const bytes = Buffer.from(`${canonicalizeDaemonActivationValue(value)}\n`, 'utf8');
902
- writeAll(fd, bytes);
903
- fsyncSync(fd);
904
- const written = fstatSync(fd, { bigint: true });
905
- if (!safeOwnedFile(written) || written.size !== BigInt(bytes.length)) {
906
- throw new Error('activation-store-temp-file-changed-during-write');
907
- }
908
- closeSync(fd);
909
- fd = undefined;
910
- renameSync(tmpPath, path);
911
- const assurance = assurePrivateStoragePath(path, 'file', 'secure-created', { anchorPath });
912
- if (!assurance.ok)
913
- throw new Error(`unsafe-activation-store-path:${assurance.reason}`);
914
- fsyncDirectory(dirname(path));
915
- }
916
- finally {
917
- if (fd !== undefined) {
918
- try {
919
- closeSync(fd);
920
- }
921
- catch { /* best-effort */ }
922
- }
923
- try {
924
- if (existsSync(tmpPath))
925
- unlinkSync(tmpPath);
926
- }
927
- catch { /* best-effort cleanup */ }
928
- }
929
- }
930
- function withActivationStoreLock(fn) {
931
- const home = resolve(homedir());
932
- // Two single-level creates: ~/.ashlr may not exist yet on a machine where
933
- // `activation init` is the very first ashlr command ever run.
934
- assurePrivateDirectory(join(homedir(), '.ashlr'), home);
935
- assurePrivateDirectory(daemonActivationDirPath(), home);
936
- const lock = acquireLocalStoreLock(daemonActivationStoreLockPath(), LOCK_WAIT_MS, {
937
- anchorPath: home,
938
- exactPrivateStorage: true,
939
- });
940
- if (!lock)
941
- throw new Error('activation-store-lock-unavailable');
942
- let result;
943
- try {
944
- result = fn();
945
- }
946
- catch (error) {
947
- releaseLocalStoreLock(lock);
948
- throw error;
949
- }
950
- if (!releaseLocalStoreLock(lock))
951
- throw new Error('activation-store-lock-release-failed');
952
- return result;
953
- }
954
- function readOperatorPrivateKeyPem() {
955
- const path = daemonActivationOperatorKeyPath();
956
- const home = resolve(homedir());
957
- // Open first, then validate the fd. The owner/mode/size check and the read
958
- // below run against the same file description, so the path cannot be
959
- // swapped between "checked" and "used".
960
- const noFollow = typeof fsConstants.O_NOFOLLOW === 'number' ? fsConstants.O_NOFOLLOW : 0;
961
- const fd = openSync(path, fsConstants.O_RDONLY | noFollow);
962
- try {
963
- const opened = fstatSync(fd, { bigint: true });
964
- if (!safeOwnedFile(opened) || opened.size <= 0n || opened.size > BigInt(MAX_PERMIT_BYTES)) {
965
- throw new Error('unsafe-operator-key-file');
966
- }
967
- const size = Number(opened.size);
968
- const bytes = Buffer.alloc(size);
969
- let offset = 0;
970
- while (offset < size) {
971
- const count = readSync(fd, bytes, offset, size - offset, offset);
972
- if (count <= 0)
973
- throw new Error('short-operator-key-read');
974
- offset += count;
975
- }
976
- const after = fstatSync(fd, { bigint: true });
977
- if (!sameFileSnapshot(opened, after))
978
- throw new Error('operator-key-changed-during-read');
979
- const assurance = assurePrivateStoragePath(path, 'file', 'inspect-owned', { anchorPath: home });
980
- if (!assurance.ok)
981
- throw new Error(`unsafe-operator-key-path:${assurance.reason}`);
982
- return bytes.toString('utf8');
983
- }
984
- finally {
985
- closeSync(fd);
986
- }
987
- }
988
- function validTrustRootRecord(value) {
989
- if (!isRecord(value) || !hasExactKeys(value, ['keyId', 'publicKeyPem', 'createdAt', 'revokedAt', 'label'])) {
990
- return false;
991
- }
992
- if (typeof value['keyId'] !== 'string' || !KEY_ID_RE.test(value['keyId']))
993
- return false;
994
- if (typeof value['publicKeyPem'] !== 'string')
995
- return false;
996
- try {
997
- const key = createPublicKey(value['publicKeyPem']);
998
- if (key.asymmetricKeyType !== 'ed25519')
999
- return false;
1000
- }
1001
- catch {
1002
- return false;
1003
- }
1004
- if (!validIsoTimestamp(value['createdAt']))
1005
- return false;
1006
- if (value['revokedAt'] !== null && !validIsoTimestamp(value['revokedAt']))
1007
- return false;
1008
- if (typeof value['label'] !== 'string' || value['label'].length > MAX_LABEL_LENGTH)
1009
- return false;
1010
- return true;
1011
- }
1012
- function loadTrustRootStore() {
1013
- const read = safeReadOwnedJsonArray(daemonActivationTrustRootsPath(), resolve(homedir()), MAX_TRUST_STORE_BYTES);
1014
- if (!read.ok) {
1015
- if (read.reason === 'missing')
1016
- return { ok: true, records: [] };
1017
- return { ok: false, reason: read.reason };
1018
- }
1019
- const records = [];
1020
- const seen = new Set();
1021
- for (const entry of read.value) {
1022
- if (!validTrustRootRecord(entry) || seen.has(entry.keyId)) {
1023
- return { ok: false, reason: 'invalid-trust-root-store' };
1024
- }
1025
- seen.add(entry.keyId);
1026
- records.push(entry);
1027
- }
1028
- return { ok: true, records };
1029
- }
1030
- /**
1031
- * The ONLY source of production trust roots: an operator-owned, protected
1032
- * file the operator populates via `ashlr activation init`. Missing, unsafe,
1033
- * or malformed → [] — identical denial to before this file loading existed.
1034
- */
1035
- export function loadDaemonActivationTrustRoots() {
1036
- const loaded = loadTrustRootStore();
1037
- if (!loaded.ok)
1038
- return [];
1039
- return loaded.records
1040
- .filter((record) => record.revokedAt === null)
1041
- .map((record) => ({ keyId: record.keyId, publicKeyPem: record.publicKeyPem }));
1042
- }
1043
- /** Full trust-root status (incl. revoked history) for `ashlr activation status`. */
1044
- export function daemonActivationTrustRootStatus() {
1045
- return loadTrustRootStore();
1046
- }
1047
- function activeTrustRootKeyMap() {
1048
- const loaded = loadTrustRootStore();
1049
- if (!loaded.ok)
1050
- return null;
1051
- const active = loaded.records
1052
- .filter((record) => record.revokedAt === null)
1053
- .map((record) => ({ keyId: record.keyId, publicKeyPem: record.publicKeyPem }));
1054
- return trustRootMap(active);
1055
- }
1056
- function validGrantRecord(value) {
1057
- if (!isRecord(value) || !hasExactKeys(value, [
1058
- 'schemaVersion', 'grantId', 'keyId', 'issuedAt', 'expiresAt', 'scope', 'signature', 'revokedAt',
1059
- ]))
1060
- return false;
1061
- if (value['schemaVersion'] !== 1)
1062
- return false;
1063
- if (typeof value['grantId'] !== 'string' || !ID_RE.test(value['grantId']))
1064
- return false;
1065
- if (typeof value['keyId'] !== 'string' || !KEY_ID_RE.test(value['keyId']))
1066
- return false;
1067
- if (!validIsoTimestamp(value['issuedAt']) || !validIsoTimestamp(value['expiresAt']))
1068
- return false;
1069
- // validStoredScope (not validScope): a persisted STANDING grant may
1070
- // predate a newly-added scope key by up to 30 days — see its doc comment.
1071
- if (!validStoredScope(value['scope']))
1072
- return false;
1073
- if (typeof value['signature'] !== 'string')
1074
- return false;
1075
- try {
1076
- const sig = Buffer.from(value['signature'], 'base64');
1077
- if (sig.length !== 64 || sig.toString('base64') !== value['signature'])
1078
- return false;
1079
- }
1080
- catch {
1081
- return false;
1082
- }
1083
- if (value['revokedAt'] !== null && !validIsoTimestamp(value['revokedAt']))
1084
- return false;
1085
- return true;
1086
- }
1087
- function loadGrantStore() {
1088
- const read = safeReadOwnedJsonArray(daemonActivationGrantsPath(), resolve(homedir()), MAX_GRANTS_BYTES);
1089
- if (!read.ok) {
1090
- if (read.reason === 'missing')
1091
- return { ok: true, records: [] };
1092
- return { ok: false, reason: read.reason };
1093
- }
1094
- const records = [];
1095
- const seen = new Set();
1096
- for (const entry of read.value) {
1097
- if (!validGrantRecord(entry) || seen.has(entry.grantId)) {
1098
- return { ok: false, reason: 'invalid-grant-store' };
1099
- }
1100
- seen.add(entry.grantId);
1101
- records.push(entry);
1102
- }
1103
- return { ok: true, records };
1104
- }
1105
- /** Full standing-grant status (incl. revoked/expired history) for `ashlr activation status`. */
1106
- export function daemonActivationGrantStatus() {
1107
- return loadGrantStore();
1108
- }
1109
- function grantSigningCore(record) {
1110
- return {
1111
- schemaVersion: record.schemaVersion,
1112
- grantId: record.grantId,
1113
- keyId: record.keyId,
1114
- issuedAt: record.issuedAt,
1115
- expiresAt: record.expiresAt,
1116
- scope: record.scope,
1117
- };
1118
- }
1119
- function grantSigningBytes(core) {
1120
- return Buffer.from(`${GRANT_SIGNING_DOMAIN}${canonicalizeDaemonActivationValue(core)}`, 'utf8');
1121
- }
1122
- export function signDaemonActivationGrant(input) {
1123
- const core = grantSigningCore({
1124
- schemaVersion: 1,
1125
- grantId: input.grantId,
1126
- keyId: input.keyId,
1127
- issuedAt: input.issuedAt,
1128
- expiresAt: input.expiresAt,
1129
- scope: { ...input.scope },
1130
- });
1131
- const key = typeof input.privateKey === 'string' ? createPrivateKey(input.privateKey) : input.privateKey;
1132
- if (key.type !== 'private' || key.asymmetricKeyType !== 'ed25519') {
1133
- throw new Error('daemon activation grants require an Ed25519 private key');
1134
- }
1135
- const signature = sign(null, grantSigningBytes(core), key).toString('base64');
1136
- const record = { ...core, signature, revokedAt: null };
1137
- if (!validGrantRecord(record))
1138
- throw new Error('invalid daemon activation grant record');
1139
- return record;
1140
- }
1141
- function auditActivationEvent(action, summary, result) {
1142
- audit({ action, repo: null, sandboxId: null, summary, result });
1143
- }
1144
- /**
1145
- * Checks whether ANY non-revoked, non-expired, validly-signed standing grant
1146
- * (from a non-revoked trust root) currently grants `scopeKey`. Unlike the
1147
- * one-shot permit, standing grants are NOT bound to exact executable/
1148
- * entrypoint/release-tree hashes — they must remain valid across routine
1149
- * rebuilds within their expiry window. They ARE bound to kill-switch-off,
1150
- * guard-health-healthy, and a non-revoked trust root, and always expire.
1151
- */
1152
- /**
1153
- * Shared core of `daemonActivationScopeGranted`. Returns the matched grant
1154
- * RECORD (not just a boolean) so callers that need to act on it — currently
1155
- * only the resident-standing startup path — can read its scope and grantId
1156
- * without a second, redundant store read. Same checks, same order, as the
1157
- * pre-refactor inline body of `daemonActivationScopeGranted`.
1158
- */
1159
- function findGrantedRecord(scopeKey) {
1160
- if (process.platform === 'win32') {
1161
- return { granted: false, reason: 'activation-permit-v1-unsupported-on-windows' };
1162
- }
1163
- if (killSwitchOn())
1164
- return { granted: false, reason: 'kill-switch-is-on' };
1165
- let guardHealthy;
1166
- try {
1167
- guardHealthy = !diagnoseGuardHealth().blocked;
1168
- }
1169
- catch {
1170
- return { granted: false, reason: 'guard-health-check-failed' };
1171
- }
1172
- if (!guardHealthy)
1173
- return { granted: false, reason: 'guard-health-degraded' };
1174
- const trusted = activeTrustRootKeyMap();
1175
- if (!trusted || trusted.size === 0)
1176
- return { granted: false, reason: 'no-trusted-activation-roots' };
1177
- const grants = loadGrantStore();
1178
- if (!grants.ok)
1179
- return { granted: false, reason: `activation-grant-store-${grants.reason}` };
1180
- const nowMs = Date.now();
1181
- for (const record of grants.records) {
1182
- if (record.revokedAt !== null)
1183
- continue;
1184
- if (record.scope[scopeKey] !== true)
1185
- continue;
1186
- const publicKey = trusted.get(record.keyId);
1187
- if (!publicKey)
1188
- continue;
1189
- if (Date.parse(record.expiresAt) <= nowMs)
1190
- continue;
1191
- let signature;
1192
- try {
1193
- signature = Buffer.from(record.signature, 'base64');
1194
- }
1195
- catch {
1196
- continue;
1197
- }
1198
- if (!verify(null, grantSigningBytes(grantSigningCore(record)), publicKey, signature))
1199
- continue;
1200
- return { granted: true, record };
1201
- }
1202
- return { granted: false, reason: 'no-matching-standing-grant' };
1203
- }
1204
- export function daemonActivationScopeGranted(scopeKey) {
1205
- const found = findGrantedRecord(scopeKey);
1206
- return found.granted
1207
- ? { granted: true, reason: `granted-by-${found.record.grantId}` }
1208
- : { granted: false, reason: found.reason };
1209
- }
1210
- /**
1211
- * The goal and simple conductors consult this every cycle. Real, gated,
1212
- * revocable authority: granted only after `ashlr activation init` +
1213
- * `ashlr activation grant conductor`. Default (no grant) is denied, exactly
1214
- * as before this mechanism existed. Every check is audited.
1215
- */
1216
- export function liveConductorActivationAuthorized() {
1217
- const check = daemonActivationScopeGranted('conductor');
1218
- auditActivationEvent('daemon-activation:conductor-check', `conductor activation ${check.granted ? 'authorized' : 'denied'}: ${check.reason}`, check.granted ? 'ok' : 'refused');
1219
- return check.granted;
1220
- }
1221
- // ---------------------------------------------------------------------------
1222
- // Operator authority mutation — init / grant / revoke. Called from
1223
- // `src/cli/activation.ts`; kept here so all signing/writing stays alongside
1224
- // the verification it must satisfy.
1225
- // ---------------------------------------------------------------------------
1226
- export function generateDaemonActivationOperatorKeypair() {
1227
- const pair = generateKeyPairSync('ed25519');
1228
- return {
1229
- privateKeyPem: pair.privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(),
1230
- publicKeyPem: pair.publicKey.export({ type: 'spki', format: 'pem' }).toString(),
1231
- };
1232
- }
1233
- function sanitizeKeyId(candidate) {
1234
- const cleaned = candidate.replace(/[^A-Za-z0-9._-]/gu, '-').slice(0, 55);
1235
- const seeded = cleaned.length > 0 && /^[A-Za-z0-9]/u.test(cleaned) ? cleaned : `k${cleaned}`;
1236
- return seeded.slice(0, 63) || 'operator';
1237
- }
1238
- /**
1239
- * Generates an Ed25519 keypair on THIS machine (private key never leaves it,
1240
- * written 0600 under an 0700 directory) and registers the public half as a
1241
- * new active trust root. Refuses to silently clobber an existing active
1242
- * root or key file unless `force` is set (key rotation: the new root is
1243
- * ADDED, not swapped in place, so grants signed by an older still-active
1244
- * root keep working until explicitly revoked).
1245
- */
1246
- export function daemonActivationInit(input) {
1247
- if (process.platform === 'win32') {
1248
- return { ok: false, reason: 'activation-permit-v1-unsupported-on-windows' };
1249
- }
1250
- return withActivationStoreLock(() => {
1251
- const existing = loadTrustRootStore();
1252
- if (!existing.ok)
1253
- return { ok: false, reason: existing.reason };
1254
- const activeCount = existing.records.filter((record) => record.revokedAt === null).length;
1255
- if (activeCount > 0 && input.force !== true) {
1256
- return { ok: false, reason: 'trust-root-already-initialized' };
1257
- }
1258
- const keyPath = daemonActivationOperatorKeyPath();
1259
- if (pathEntryPresent(keyPath) && input.force !== true) {
1260
- return { ok: false, reason: 'operator-key-file-already-exists' };
1261
- }
1262
- const { privateKeyPem, publicKeyPem } = generateDaemonActivationOperatorKeypair();
1263
- const keyId = sanitizeKeyId(`${hostname()}-${randomBytes(4).toString('hex')}`);
1264
- const now = new Date().toISOString();
1265
- const record = {
1266
- keyId,
1267
- publicKeyPem,
1268
- createdAt: now,
1269
- revokedAt: null,
1270
- label: (input.label ?? '').slice(0, MAX_LABEL_LENGTH),
1271
- };
1272
- const home = resolve(homedir());
1273
- assurePrivateDirectory(daemonActivationDirPath(), home);
1274
- const noFollow = typeof fsConstants.O_NOFOLLOW === 'number' ? fsConstants.O_NOFOLLOW : 0;
1275
- let fd;
1276
- try {
1277
- if (pathEntryPresent(keyPath))
1278
- unlinkSync(keyPath);
1279
- fd = openSync(keyPath, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | noFollow, PRIVATE_FILE_MODE);
1280
- const initial = fstatSync(fd, { bigint: true });
1281
- if (!safeOwnedFile(initial) || initial.size !== 0n)
1282
- throw new Error('unsafe-operator-key-file');
1283
- fchmodSync(fd, PRIVATE_FILE_MODE);
1284
- writeAll(fd, Buffer.from(privateKeyPem, 'utf8'));
1285
- fsyncSync(fd);
1286
- closeSync(fd);
1287
- fd = undefined;
1288
- const keyAssurance = assurePrivateStoragePath(keyPath, 'file', 'secure-created', { anchorPath: home });
1289
- if (!keyAssurance.ok)
1290
- throw new Error(`unsafe-operator-key-path:${keyAssurance.reason}`);
1291
- fsyncDirectory(dirname(keyPath));
1292
- }
1293
- catch (error) {
1294
- return { ok: false, reason: error.message || 'operator-key-write-failed' };
1295
- }
1296
- finally {
1297
- if (fd !== undefined) {
1298
- try {
1299
- closeSync(fd);
1300
- }
1301
- catch { /* best-effort */ }
1302
- }
1303
- }
1304
- writeCanonicalJsonArrayFileAtomic(daemonActivationTrustRootsPath(), [...existing.records, record], home);
1305
- auditActivationEvent('daemon-activation:init', `trust root registered: keyId=${keyId} label=${JSON.stringify(record.label)} rotated=${activeCount > 0}`, 'ok');
1306
- return { ok: true, keyId, publicKeyPem, rotated: activeCount > 0 };
1307
- });
1308
- }
1309
- export function daemonActivationRevokeTrustRoot(keyId) {
1310
- if (!KEY_ID_RE.test(keyId))
1311
- return { ok: false, reason: 'invalid-key-id' };
1312
- return withActivationStoreLock(() => {
1313
- const existing = loadTrustRootStore();
1314
- if (!existing.ok)
1315
- return { ok: false, reason: existing.reason };
1316
- const index = existing.records.findIndex((record) => record.keyId === keyId);
1317
- if (index === -1)
1318
- return { ok: false, reason: 'trust-root-not-found' };
1319
- if (existing.records[index].revokedAt !== null) {
1320
- auditActivationEvent('daemon-activation:revoke-root', `trust root already revoked: keyId=${keyId}`, 'ok');
1321
- return { ok: true };
1322
- }
1323
- const revokedAt = new Date().toISOString();
1324
- const updated = existing.records.map((record, i) => (i === index ? { ...record, revokedAt } : record));
1325
- writeCanonicalJsonArrayFileAtomic(daemonActivationTrustRootsPath(), updated, resolve(homedir()));
1326
- auditActivationEvent('daemon-activation:revoke-root', `trust root revoked: keyId=${keyId}`, 'ok');
1327
- return { ok: true };
1328
- });
1329
- }
1330
- export function daemonActivationRevokeGrant(grantId) {
1331
- if (!ID_RE.test(grantId))
1332
- return { ok: false, reason: 'invalid-grant-id' };
1333
- return withActivationStoreLock(() => {
1334
- const existing = loadGrantStore();
1335
- if (!existing.ok)
1336
- return { ok: false, reason: existing.reason };
1337
- const index = existing.records.findIndex((record) => record.grantId === grantId);
1338
- if (index === -1)
1339
- return { ok: false, reason: 'grant-not-found' };
1340
- if (existing.records[index].revokedAt !== null) {
1341
- auditActivationEvent('daemon-activation:revoke-grant', `standing grant already revoked: grantId=${grantId}`, 'ok');
1342
- return { ok: true };
1343
- }
1344
- const revokedAt = new Date().toISOString();
1345
- const updated = existing.records.map((record, i) => (i === index ? { ...record, revokedAt } : record));
1346
- writeCanonicalJsonArrayFileAtomic(daemonActivationGrantsPath(), updated, resolve(homedir()));
1347
- auditActivationEvent('daemon-activation:revoke-grant', `standing grant revoked: grantId=${grantId}`, 'ok');
1348
- return { ok: true };
1349
- });
1350
- }
1351
- /** Mints a signed, expiring standing grant (conductor/automerge/repair/deploy/install/proposalOnly). */
1352
- export function daemonActivationMintStandingGrant(input) {
1353
- if (process.platform === 'win32') {
1354
- return { ok: false, reason: 'activation-permit-v1-unsupported-on-windows' };
1355
- }
1356
- if (!Number.isFinite(input.ttlMs) || input.ttlMs <= 0 || input.ttlMs > MAX_STANDING_GRANT_VALIDITY_MS) {
1357
- return { ok: false, reason: 'invalid-grant-ttl' };
1358
- }
1359
- if (!validScope(input.scope))
1360
- return { ok: false, reason: 'invalid-grant-scope' };
1361
- return withActivationStoreLock(() => {
1362
- const trustStore = loadTrustRootStore();
1363
- if (!trustStore.ok)
1364
- return { ok: false, reason: trustStore.reason };
1365
- const active = trustStore.records.filter((record) => record.revokedAt === null);
1366
- if (active.length === 0)
1367
- return { ok: false, reason: 'no-trusted-activation-roots' };
1368
- if (active.length > 1)
1369
- return { ok: false, reason: 'ambiguous-signing-key-run-activation-status' };
1370
- const root = active[0];
1371
- let privateKeyPem;
1372
- try {
1373
- privateKeyPem = readOperatorPrivateKeyPem();
1374
- }
1375
- catch (error) {
1376
- return { ok: false, reason: error.message || 'operator-key-unreadable' };
1377
- }
1378
- const existingGrants = loadGrantStore();
1379
- if (!existingGrants.ok)
1380
- return { ok: false, reason: existingGrants.reason };
1381
- const now = Date.now();
1382
- const record = signDaemonActivationGrant({
1383
- grantId: randomBytes(16).toString('hex'),
1384
- keyId: root.keyId,
1385
- issuedAt: new Date(now).toISOString(),
1386
- expiresAt: new Date(now + input.ttlMs).toISOString(),
1387
- scope: input.scope,
1388
- privateKey: privateKeyPem,
1389
- });
1390
- writeCanonicalJsonArrayFileAtomic(daemonActivationGrantsPath(), [...existingGrants.records, record], resolve(homedir()));
1391
- auditActivationEvent('daemon-activation:grant', `standing grant minted: grantId=${record.grantId} keyId=${root.keyId} `
1392
- + `scope=${canonicalizeDaemonActivationValue(record.scope)} expiresAt=${record.expiresAt}`, 'ok');
1393
- return { ok: true, record };
1394
- });
1395
- }
1396
- /**
1397
- * Mints and pins a single-use activation permit (once/resident scopes) to
1398
- * daemonActivationPermitPath(), ready for `ashlr daemon start` to consume.
1399
- * Signs against THIS exact machine's current build (executable/entrypoint/
1400
- * release-tree hashes) — an operator runs this locally, immediately before
1401
- * starting the daemon it authorizes.
1402
- */
1403
- export function daemonActivationMintOneShotPermit(input) {
1404
- if (process.platform === 'win32') {
1405
- return { ok: false, reason: 'activation-permit-v1-unsupported-on-windows' };
1406
- }
1407
- if (!validScope(input.scope))
1408
- return { ok: false, reason: 'invalid-grant-scope' };
1409
- return withActivationStoreLock(() => {
1410
- const trustStore = loadTrustRootStore();
1411
- if (!trustStore.ok)
1412
- return { ok: false, reason: trustStore.reason };
1413
- const active = trustStore.records.filter((record) => record.revokedAt === null);
1414
- if (active.length === 0)
1415
- return { ok: false, reason: 'no-trusted-activation-roots' };
1416
- if (active.length > 1)
1417
- return { ok: false, reason: 'ambiguous-signing-key-run-activation-status' };
1418
- const root = active[0];
1419
- let privateKeyPem;
1420
- try {
1421
- privateKeyPem = readOperatorPrivateKeyPem();
1422
- }
1423
- catch (error) {
1424
- return { ok: false, reason: error.message || 'operator-key-unreadable' };
1425
- }
1426
- let configSnapshot;
1427
- try {
1428
- configSnapshot = strictConfigSnapshot(input.cfg);
1429
- }
1430
- catch {
1431
- return { ok: false, reason: 'activation-config-not-strict-json' };
1432
- }
1433
- // Cheap, context-independent check first: no point collecting a full
1434
- // runtime context (file hashing, release-tree walk) if there is already
1435
- // a pending permit occupying the single fixed path.
1436
- const permitPath = daemonActivationPermitPath();
1437
- if (pathEntryPresent(permitPath))
1438
- return { ok: false, reason: 'activation-permit-already-pending' };
1439
- let context;
1440
- try {
1441
- context = collectRuntimeContext(configSnapshot);
1442
- }
1443
- catch (error) {
1444
- return { ok: false, reason: `runtime-context-unavailable:${error.message}` };
1445
- }
1446
- if (!context.killSwitchOff)
1447
- return { ok: false, reason: 'kill-switch-is-on' };
1448
- if (!context.guardHealthHealthy)
1449
- return { ok: false, reason: 'guard-health-degraded' };
1450
- if (!validBuildIdentity(context.buildIdentity)) {
1451
- // Most commonly: a `git` provenance build with a dirty working tree
1452
- // (dist/ was built from uncommitted changes). Fail closed — a permit
1453
- // bound to an untrusted build identity would never verify anyway.
1454
- return { ok: false, reason: 'runtime-build-identity-untrusted' };
1455
- }
1456
- const now = Date.now();
1457
- const permitId = randomBytes(16).toString('hex');
1458
- let payload;
1459
- let envelope;
1460
- try {
1461
- payload = buildDaemonActivationPermitPayload({
1462
- permitId,
1463
- nonce: randomBytes(32).toString('hex'),
1464
- keyId: root.keyId,
1465
- issuedAt: new Date(now).toISOString(),
1466
- expiresAt: new Date(now + MAX_VALIDITY_MS).toISOString(),
1467
- scope: input.scope,
1468
- context,
1469
- });
1470
- envelope = signDaemonActivationPermit(payload, privateKeyPem);
1471
- }
1472
- catch (error) {
1473
- return { ok: false, reason: error.message || 'activation-permit-build-failed' };
1474
- }
1475
- const home = resolve(homedir());
1476
- assurePrivateDirectory(dirname(permitPath), home);
1477
- const noFollow = typeof fsConstants.O_NOFOLLOW === 'number' ? fsConstants.O_NOFOLLOW : 0;
1478
- let fd;
1479
- try {
1480
- fd = openSync(permitPath, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | noFollow, PRIVATE_FILE_MODE);
1481
- const initial = fstatSync(fd, { bigint: true });
1482
- if (!safeOwnedFile(initial) || initial.size !== 0n)
1483
- throw new Error('unsafe-activation-permit-file');
1484
- fchmodSync(fd, PRIVATE_FILE_MODE);
1485
- const bytes = Buffer.from(`${canonicalizeDaemonActivationValue(envelope)}\n`, 'utf8');
1486
- writeAll(fd, bytes);
1487
- fsyncSync(fd);
1488
- closeSync(fd);
1489
- fd = undefined;
1490
- const assurance = assurePrivateStoragePath(permitPath, 'file', 'secure-created', { anchorPath: home });
1491
- if (!assurance.ok)
1492
- throw new Error(`unsafe-activation-permit-path:${assurance.reason}`);
1493
- fsyncDirectory(dirname(permitPath));
1494
- }
1495
- catch (error) {
1496
- return { ok: false, reason: error.message || 'activation-permit-write-failed' };
1497
- }
1498
- finally {
1499
- if (fd !== undefined) {
1500
- try {
1501
- closeSync(fd);
1502
- }
1503
- catch { /* best-effort */ }
1504
- }
1505
- }
1506
- auditActivationEvent('daemon-activation:grant', `one-shot permit minted: permitId=${permitId} keyId=${root.keyId} `
1507
- + `scope=${canonicalizeDaemonActivationValue(input.scope)} expiresAt=${payload.expiresAt}`, 'ok');
1508
- return { ok: true, permitId, permitPath, expiresAt: payload.expiresAt };
1509
- });
1510
- }
1511
- // ---------------------------------------------------------------------------
1512
- // One-shot permit inspection / consumption — `ashlr daemon start` [--once].
1513
- // ---------------------------------------------------------------------------
1514
- function activationReadiness(state, reason, trustRootCount, extra) {
677
+ function activationReadiness(state, reason, trustRootCount) {
1515
678
  return {
1516
679
  schemaVersion: 1,
1517
680
  policyVersion: POLICY_VERSION,
@@ -1519,234 +682,112 @@ function activationReadiness(state, reason, trustRootCount, extra) {
1519
682
  sourceState: state === 'degraded' ? 'degraded' : 'healthy',
1520
683
  state,
1521
684
  commandEligible: state === 'ready',
1522
- requestedShape: extra?.requestedShape ?? 'proposal-once',
685
+ requestedShape: 'proposal-once',
1523
686
  trustRootCount,
1524
- residentAuthorized: extra?.residentAuthorized ?? false,
1525
- residentStandingAuthorized: extra?.residentStandingAuthorized ?? false,
1526
- installAuthorized: extra?.installAuthorized ?? false,
1527
- repairAuthorized: extra?.repairAuthorized ?? false,
687
+ residentAuthorized: false,
688
+ installAuthorized: false,
689
+ repairAuthorized: false,
1528
690
  reason,
1529
691
  };
1530
692
  }
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;
693
+ function pathEntryPresent(path) {
1568
694
  try {
1569
- pinned = openPinnedPermit(permitPath, resolve(homedir()));
695
+ lstatSync(path);
696
+ return true;
1570
697
  }
1571
698
  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 */ }
699
+ if (error.code === 'ENOENT')
700
+ return false;
701
+ throw error;
1632
702
  }
1633
703
  }
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
- };
704
+ function inspectPinnedActivationPermit(pinned, permitPath, cfg, trustRoots, suppliedContext) {
1642
705
  let envelope;
1643
706
  try {
1644
707
  envelope = JSON.parse(pinned.text);
1645
708
  }
1646
709
  catch {
1647
- return activationReadiness('degraded', 'invalid-permit-json', trustRoots.length, extraBase);
710
+ return activationReadiness('degraded', 'invalid-permit-json', trustRoots.length);
1648
711
  }
1649
712
  if (`${canonicalizeDaemonActivationValue(envelope)}\n` !== pinned.text) {
1650
- return activationReadiness('degraded', 'noncanonical-permit-encoding', trustRoots.length, extraBase);
713
+ return activationReadiness('degraded', 'noncanonical-permit-encoding', trustRoots.length);
1651
714
  }
1652
715
  const parsed = parseEnvelope(envelope);
1653
716
  if (!parsed) {
1654
- return activationReadiness('degraded', 'invalid-permit-schema', trustRoots.length, extraBase);
717
+ return activationReadiness('degraded', 'invalid-permit-schema', trustRoots.length);
1655
718
  }
1656
719
  let configSnapshot;
1657
720
  try {
1658
721
  configSnapshot = strictConfigSnapshot(cfg);
1659
722
  }
1660
723
  catch {
1661
- return activationReadiness('degraded', 'activation-config-not-strict-json', trustRoots.length, extraBase);
724
+ return activationReadiness('degraded', 'activation-config-not-strict-json', trustRoots.length);
1662
725
  }
1663
726
  const context = suppliedContext ?? collectRuntimeContext(configSnapshot);
1664
727
  if (context.configDigest !== daemonActivationConfigDigest(configSnapshot)) {
1665
- return activationReadiness('blocked', 'runtime-config-digest-mismatch', trustRoots.length, extraBase);
728
+ return activationReadiness('blocked', 'runtime-config-digest-mismatch', trustRoots.length);
1666
729
  }
1667
730
  const verification = verifyDaemonActivationPermit(parsed, context, trustRoots);
1668
731
  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 });
732
+ return activationReadiness('blocked', verification.reason, trustRoots.length);
1674
733
  }
1675
734
  const nonceDigest = sha256(parsed.payload.nonce);
1676
735
  if (pathEntryPresent(daemonActivationReceiptPath(verification.permitId))
1677
736
  || pathEntryPresent(daemonActivationNonceReceiptPath(nonceDigest))) {
1678
- return activationReadiness('blocked', 'activation-permit-already-consumed', trustRoots.length, { ...extraBase, residentAuthorized });
737
+ return activationReadiness('blocked', 'activation-permit-already-consumed', trustRoots.length);
1679
738
  }
1680
739
  const openedAfterInspection = fstatSync(pinned.fd, { bigint: true });
1681
740
  const namedAfterInspection = lstatSync(permitPath, { bigint: true });
1682
741
  if (!sameFileSnapshot(pinned.stat, openedAfterInspection)
1683
742
  || !sameFileSnapshot(openedAfterInspection, namedAfterInspection)) {
1684
- return activationReadiness('degraded', 'activation-permit-changed-during-inspection', trustRoots.length, { ...extraBase, residentAuthorized });
743
+ return activationReadiness('degraded', 'activation-permit-changed-during-inspection', trustRoots.length);
1685
744
  }
1686
- const readyReason = requestedShape === 'once' ? 'valid-proposal-once-permit' : 'valid-resident-permit';
1687
- return activationReadiness('ready', readyReason, trustRoots.length, { ...extraBase, residentAuthorized });
745
+ return activationReadiness('ready', 'valid-proposal-once-permit', trustRoots.length);
1688
746
  }
1689
747
  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
748
  if (!needsPermit(opts)) {
1701
- return activationReadiness('ready', 'dry-run-once-does-not-require-activation-permit', trustRoots.length, standingAuthorizedFields);
749
+ return activationReadiness('ready', 'dry-run-once-does-not-require-activation-permit', trustRoots.length);
1702
750
  }
1703
- const requestedShape = requestedActivationShape(opts);
1704
- if (!requestedShape) {
1705
- return activationReadiness('blocked', 'activation-permit-cannot-authorize-requested-start-shape', trustRoots.length, standingAuthorizedFields);
751
+ if (!supportedProposalOnceShape(opts)) {
752
+ return activationReadiness('blocked', 'activation-permit-cannot-authorize-requested-start-shape', trustRoots.length);
1706
753
  }
1707
- const shapeLabel = requestedShapeLabel(requestedShape);
1708
754
  if (trustRoots.length === 0) {
1709
- return activationReadiness('blocked', 'no-trusted-activation-roots', 0, {
1710
- requestedShape: shapeLabel,
1711
- ...standingAuthorizedFields,
1712
- });
755
+ return activationReadiness('blocked', 'no-trusted-activation-roots', 0);
1713
756
  }
1714
757
  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 });
758
+ return activationReadiness('blocked', 'activation-permit-v1-unsupported-on-windows', trustRoots.length);
1727
759
  }
1728
760
  const permitPath = daemonActivationPermitPath();
1729
761
  let pinned;
1730
762
  let result;
1731
763
  try {
1732
764
  pinned = openPinnedPermit(permitPath, resolve(homedir()));
1733
- result = inspectPinnedActivationPermit(pinned, permitPath, cfg, trustRoots, suppliedContext, requestedShape, standingAuth);
765
+ result = inspectPinnedActivationPermit(pinned, permitPath, cfg, trustRoots, suppliedContext);
1734
766
  }
1735
767
  catch (error) {
1736
768
  result = activationReadiness(error.code === 'ENOENT' ? 'blocked' : 'degraded', error.code === 'ENOENT'
1737
769
  ? 'activation-permit-missing'
1738
- : 'activation-permit-inspection-failed', trustRoots.length, { requestedShape: shapeLabel, installAuthorized: standingAuth.install, repairAuthorized: standingAuth.repair });
770
+ : 'activation-permit-inspection-failed', trustRoots.length);
1739
771
  }
1740
772
  if (pinned) {
1741
773
  try {
1742
774
  closeSync(pinned.fd);
1743
775
  }
1744
776
  catch {
1745
- return activationReadiness('degraded', 'activation-permit-close-failed', trustRoots.length, { requestedShape: shapeLabel, installAuthorized: standingAuth.install, repairAuthorized: standingAuth.repair });
777
+ return activationReadiness('degraded', 'activation-permit-close-failed', trustRoots.length);
1746
778
  }
1747
779
  }
1748
780
  return result;
1749
781
  }
782
+ function needsPermit(opts) {
783
+ return !(opts.once === true && opts.dryRun === true);
784
+ }
785
+ function supportedProposalOnceShape(opts) {
786
+ return opts.once === true
787
+ && opts.dryRun !== true
788
+ && opts.drain === undefined
789
+ && opts.drainLimit === undefined;
790
+ }
1750
791
  function consumeWithAuthority(cfg, opts, trustRoots, suppliedContext, afterReceiptPersisted, mayMintCapability) {
1751
792
  if (!needsPermit(opts)) {
1752
793
  return {
@@ -1755,8 +796,7 @@ function consumeWithAuthority(cfg, opts, trustRoots, suppliedContext, afterRecei
1755
796
  reason: 'dry-run-once-does-not-require-activation-permit',
1756
797
  };
1757
798
  }
1758
- const requestedShape = requestedActivationShape(opts);
1759
- if (!requestedShape) {
799
+ if (!supportedProposalOnceShape(opts)) {
1760
800
  return {
1761
801
  authorized: false,
1762
802
  required: true,
@@ -1828,15 +868,6 @@ function consumeWithAuthority(cfg, opts, trustRoots, suppliedContext, afterRecei
1828
868
  result = { authorized: false, required: true, reason: 'invalid-permit-schema' };
1829
869
  return result;
1830
870
  }
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
- }
1840
871
  const receiptsDir = dirname(daemonActivationReceiptPath(verification.permitId));
1841
872
  assurePrivateDirectory(receiptsDir, home);
1842
873
  const receiptPath = daemonActivationReceiptPath(verification.permitId);
@@ -1918,12 +949,12 @@ function consumeWithAuthority(cfg, opts, trustRoots, suppliedContext, afterRecei
1918
949
  result = {
1919
950
  authorized: true,
1920
951
  required: true,
1921
- reason: requestedShape === 'once' ? 'proposal-once-activation-authorized' : 'resident-activation-authorized',
952
+ reason: 'proposal-once-activation-authorized',
1922
953
  permitId: verification.permitId,
1923
954
  receiptPath,
1924
955
  ...(mayMintCapability
1925
956
  ? {
1926
- capability: mintCapability(verification.permitId, parsed.payload.scope, () => {
957
+ capability: mintCapability(verification.permitId, () => {
1927
958
  const claimContext = collectRuntimeContext(configSnapshot);
1928
959
  return verifyDaemonActivationPermit(parsed, claimContext, trustRoots).ok;
1929
960
  }),
@@ -1961,54 +992,18 @@ function consumeWithAuthority(cfg, opts, trustRoots, suppliedContext, afterRecei
1961
992
  }
1962
993
  }
1963
994
  /**
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.
995
+ * Runtime entrypoint. Its immutable repository-owned roots are the only path
996
+ * that can mint an in-process activation capability.
1968
997
  */
1969
998
  export function consumeDaemonActivationPermit(cfg, opts) {
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;
999
+ return consumeWithAuthority(cfg, opts, DAEMON_ACTIVATION_TRUST_ROOTS, undefined, undefined, true);
2005
1000
  }
2006
1001
  /**
2007
1002
  * Read-only advisory inspection. A ready result is never authority: daemon
2008
1003
  * start must still reopen, revalidate, durably consume, and mint a capability.
2009
1004
  */
2010
1005
  export function inspectDaemonActivationPermit(cfg, opts) {
2011
- return inspectWithAuthority(cfg, opts, loadDaemonActivationTrustRoots(), undefined);
1006
+ return inspectWithAuthority(cfg, opts, DAEMON_ACTIVATION_TRUST_ROOTS, undefined);
2012
1007
  }
2013
1008
  /** Test-only inspection with injected roots/context; it cannot mint authority. */
2014
1009
  export function inspectDaemonActivationPermitForVerification(cfg, opts, options) {