@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.
Files changed (195) hide show
  1. package/CHANGELOG.md +290 -0
  2. package/README.md +4 -1
  3. package/dist/build-identity.json +2 -2
  4. package/dist/cli/activation.d.ts +41 -0
  5. package/dist/cli/activation.js +485 -0
  6. package/dist/cli/activation.js.map +1 -0
  7. package/dist/cli/audit.js +18 -0
  8. package/dist/cli/audit.js.map +1 -1
  9. package/dist/cli/backlog.d.ts +0 -5
  10. package/dist/cli/backlog.js +18 -0
  11. package/dist/cli/backlog.js.map +1 -1
  12. package/dist/cli/comms.js +20 -0
  13. package/dist/cli/comms.js.map +1 -1
  14. package/dist/cli/completions.d.ts +6 -1
  15. package/dist/cli/completions.js +37 -11
  16. package/dist/cli/completions.js.map +1 -1
  17. package/dist/cli/conductor-permit.d.ts +1 -0
  18. package/dist/cli/conductor-permit.js +122 -0
  19. package/dist/cli/conductor-permit.js.map +1 -0
  20. package/dist/cli/daemon.js +10 -3
  21. package/dist/cli/daemon.js.map +1 -1
  22. package/dist/cli/demo.d.ts +0 -27
  23. package/dist/cli/demo.js +17 -0
  24. package/dist/cli/demo.js.map +1 -1
  25. package/dist/cli/goals.d.ts +2 -0
  26. package/dist/cli/goals.js +90 -5
  27. package/dist/cli/goals.js.map +1 -1
  28. package/dist/cli/help.js +21 -3
  29. package/dist/cli/help.js.map +1 -1
  30. package/dist/cli/inbox.d.ts +14 -2
  31. package/dist/cli/inbox.js +137 -23
  32. package/dist/cli/inbox.js.map +1 -1
  33. package/dist/cli/index.d.ts +1 -0
  34. package/dist/cli/index.js +16 -0
  35. package/dist/cli/index.js.map +1 -1
  36. package/dist/cli/loop.js +26 -5
  37. package/dist/cli/loop.js.map +1 -1
  38. package/dist/cli/onboard.d.ts +0 -13
  39. package/dist/cli/onboard.js +20 -0
  40. package/dist/cli/onboard.js.map +1 -1
  41. package/dist/cli/resources.js +15 -0
  42. package/dist/cli/resources.js.map +1 -1
  43. package/dist/cli/run.js +1 -1
  44. package/dist/cli/run.js.map +1 -1
  45. package/dist/cli/sandbox.js +43 -0
  46. package/dist/cli/sandbox.js.map +1 -1
  47. package/dist/cli/tui.d.ts +0 -6
  48. package/dist/cli/tui.js +14 -0
  49. package/dist/cli/tui.js.map +1 -1
  50. package/dist/core/comms/elon-dialogue.js +16 -7
  51. package/dist/core/comms/elon-dialogue.js.map +1 -1
  52. package/dist/core/config.js +70 -0
  53. package/dist/core/config.js.map +1 -1
  54. package/dist/core/daemon/activation-permit.d.ts +357 -33
  55. package/dist/core/daemon/activation-permit.js +1585 -183
  56. package/dist/core/daemon/activation-permit.js.map +1 -1
  57. package/dist/core/daemon/goal-conductor-permit-operator.d.ts +75 -0
  58. package/dist/core/daemon/goal-conductor-permit-operator.js +842 -0
  59. package/dist/core/daemon/goal-conductor-permit-operator.js.map +1 -0
  60. package/dist/core/daemon/loop.d.ts +13 -1
  61. package/dist/core/daemon/loop.js +294 -29
  62. package/dist/core/daemon/loop.js.map +1 -1
  63. package/dist/core/daemon/runtime-activation-resident-start-protocol.d.ts +141 -0
  64. package/dist/core/daemon/runtime-activation-resident-start-protocol.js +462 -0
  65. package/dist/core/daemon/runtime-activation-resident-start-protocol.js.map +1 -0
  66. package/dist/core/daemon/runtime-activation-resident-start-runtime.d.ts +27 -0
  67. package/dist/core/daemon/runtime-activation-resident-start-runtime.js +28 -0
  68. package/dist/core/daemon/runtime-activation-resident-start-runtime.js.map +1 -0
  69. package/dist/core/daemon/runtime-activation-stopped-consumer.d.ts +139 -0
  70. package/dist/core/daemon/runtime-activation-stopped-consumer.js +995 -0
  71. package/dist/core/daemon/runtime-activation-stopped-consumer.js.map +1 -0
  72. package/dist/core/daemon/runtime-activation-stopped-runtime.d.ts +36 -0
  73. package/dist/core/daemon/runtime-activation-stopped-runtime.js +134 -0
  74. package/dist/core/daemon/runtime-activation-stopped-runtime.js.map +1 -0
  75. package/dist/core/daemon/runtime-activation-transaction.d.ts +41 -17
  76. package/dist/core/daemon/runtime-activation-transaction.js +80 -11
  77. package/dist/core/daemon/runtime-activation-transaction.js.map +1 -1
  78. package/dist/core/daemon/runtime-release-dependency-inventory.js +1 -0
  79. package/dist/core/daemon/runtime-release-dependency-inventory.js.map +1 -1
  80. package/dist/core/daemon/service-install-authority.d.ts +0 -4
  81. package/dist/core/daemon/service-install-authority.js +18 -3
  82. package/dist/core/daemon/service-install-authority.js.map +1 -1
  83. package/dist/core/dashboard.d.ts +10 -0
  84. package/dist/core/dashboard.js +103 -28
  85. package/dist/core/dashboard.js.map +1 -1
  86. package/dist/core/effective-config.js +7 -0
  87. package/dist/core/effective-config.js.map +1 -1
  88. package/dist/core/fleet/automerge-pass.js +33 -0
  89. package/dist/core/fleet/automerge-pass.js.map +1 -1
  90. package/dist/core/fleet/decisions-ledger.js +35 -12
  91. package/dist/core/fleet/decisions-ledger.js.map +1 -1
  92. package/dist/core/fleet/external-skill-git-capture.js +9 -1
  93. package/dist/core/fleet/external-skill-git-capture.js.map +1 -1
  94. package/dist/core/fleet/judge-decision-metadata.d.ts +8 -1
  95. package/dist/core/fleet/judge-decision-metadata.js +14 -1
  96. package/dist/core/fleet/judge-decision-metadata.js.map +1 -1
  97. package/dist/core/fleet/manager.d.ts +15 -1
  98. package/dist/core/fleet/manager.js +108 -30
  99. package/dist/core/fleet/manager.js.map +1 -1
  100. package/dist/core/fleet/post-merge-credit.d.ts +134 -9
  101. package/dist/core/fleet/post-merge-credit.js +350 -10
  102. package/dist/core/fleet/post-merge-credit.js.map +1 -1
  103. package/dist/core/fleet/quota.d.ts +74 -7
  104. package/dist/core/fleet/quota.js +688 -34
  105. package/dist/core/fleet/quota.js.map +1 -1
  106. package/dist/core/fleet/self-improve.d.ts +34 -1
  107. package/dist/core/fleet/self-improve.js +106 -1
  108. package/dist/core/fleet/self-improve.js.map +1 -1
  109. package/dist/core/fleet/skill-library.d.ts +21 -2
  110. package/dist/core/fleet/skill-library.js +54 -12
  111. package/dist/core/fleet/skill-library.js.map +1 -1
  112. package/dist/core/fleet/status.js +7 -5
  113. package/dist/core/fleet/status.js.map +1 -1
  114. package/dist/core/fleet/worked-ledger.d.ts +22 -0
  115. package/dist/core/fleet/worked-ledger.js +32 -0
  116. package/dist/core/fleet/worked-ledger.js.map +1 -1
  117. package/dist/core/foundry/automerge-scope-policy.d.ts +2 -2
  118. package/dist/core/foundry/automerge-scope-policy.js +9 -2
  119. package/dist/core/foundry/automerge-scope-policy.js.map +1 -1
  120. package/dist/core/goals/advance.js +34 -5
  121. package/dist/core/goals/advance.js.map +1 -1
  122. package/dist/core/goals/conductor-quota.d.ts +38 -0
  123. package/dist/core/goals/conductor-quota.js +110 -0
  124. package/dist/core/goals/conductor-quota.js.map +1 -0
  125. package/dist/core/goals/conductor-target.d.ts +24 -0
  126. package/dist/core/goals/conductor-target.js +35 -0
  127. package/dist/core/goals/conductor-target.js.map +1 -0
  128. package/dist/core/goals/conductor.d.ts +15 -0
  129. package/dist/core/goals/conductor.js +116 -9
  130. package/dist/core/goals/conductor.js.map +1 -1
  131. package/dist/core/goals/store.d.ts +29 -5
  132. package/dist/core/goals/store.js +197 -30
  133. package/dist/core/goals/store.js.map +1 -1
  134. package/dist/core/goals/timestamp-repair.d.ts +50 -0
  135. package/dist/core/goals/timestamp-repair.js +740 -0
  136. package/dist/core/goals/timestamp-repair.js.map +1 -0
  137. package/dist/core/inbox/apply.js +5 -2
  138. package/dist/core/inbox/apply.js.map +1 -1
  139. package/dist/core/inbox/merge.d.ts +94 -1
  140. package/dist/core/inbox/merge.js +349 -16
  141. package/dist/core/inbox/merge.js.map +1 -1
  142. package/dist/core/integrations/github.d.ts +2 -0
  143. package/dist/core/integrations/github.js +2 -1
  144. package/dist/core/integrations/github.js.map +1 -1
  145. package/dist/core/integrations/pulse-sync.js +13 -4
  146. package/dist/core/integrations/pulse-sync.js.map +1 -1
  147. package/dist/core/learn/reflect.d.ts +42 -4
  148. package/dist/core/learn/reflect.js +56 -5
  149. package/dist/core/learn/reflect.js.map +1 -1
  150. package/dist/core/run/best-of-n.d.ts +18 -0
  151. package/dist/core/run/best-of-n.js +163 -18
  152. package/dist/core/run/best-of-n.js.map +1 -1
  153. package/dist/core/run/engines.d.ts +8 -0
  154. package/dist/core/run/engines.js +9 -1
  155. package/dist/core/run/engines.js.map +1 -1
  156. package/dist/core/run/learned-router.js +28 -3
  157. package/dist/core/run/learned-router.js.map +1 -1
  158. package/dist/core/run/orchestrator.d.ts +7 -2
  159. package/dist/core/run/orchestrator.js +140 -60
  160. package/dist/core/run/orchestrator.js.map +1 -1
  161. package/dist/core/strategy/goal-planner.js +7 -1
  162. package/dist/core/strategy/goal-planner.js.map +1 -1
  163. package/dist/core/swarm/planner.d.ts +2 -2
  164. package/dist/core/swarm/planner.js +52 -5
  165. package/dist/core/swarm/planner.js.map +1 -1
  166. package/dist/core/swarm/rollback.d.ts +11 -0
  167. package/dist/core/swarm/rollback.js +54 -0
  168. package/dist/core/swarm/rollback.js.map +1 -1
  169. package/dist/core/swarm/runner.js +123 -11
  170. package/dist/core/swarm/runner.js.map +1 -1
  171. package/dist/core/types.d.ts +139 -1
  172. package/dist/core/types.js.map +1 -1
  173. package/dist/core/web/api.js +105 -7
  174. package/dist/core/web/api.js.map +1 -1
  175. package/dist/core/web/control.d.ts +20 -0
  176. package/dist/core/web/control.js +49 -34
  177. package/dist/core/web/control.js.map +1 -1
  178. package/dist/core/web/fleet-status-cache.d.ts +68 -0
  179. package/dist/core/web/fleet-status-cache.js +120 -0
  180. package/dist/core/web/fleet-status-cache.js.map +1 -0
  181. package/dist/core/web/public/next/assets/index-CY0U241s.css +1 -0
  182. package/dist/core/web/public/next/assets/index-Ct0mf8n4.js +69 -0
  183. package/dist/core/web/public/next/assets/index-Ct0mf8n4.js.map +1 -0
  184. package/dist/core/web/public/next/index.html +15 -0
  185. package/dist/release-dependency-inventory.json +1 -1
  186. package/dist/tui/app.js +2 -2
  187. package/dist/tui/app.js.map +1 -1
  188. package/dist/tui/render.d.ts +2 -0
  189. package/dist/tui/render.js +162 -4
  190. package/dist/tui/render.js.map +1 -1
  191. package/docs/RUNTIME_ACTIVATION_AUTHORITY.md +126 -21
  192. package/docs/contracts/CONTRACT-M521.md +102 -0
  193. package/package.json +20 -3
  194. package/schema/config.schema.json +800 -5
  195. 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
- * 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([]);
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['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;
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 { ok: false, reason: 'permit-runtime-binding-mismatch' };
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
- function currentAuthorityStateDigest() {
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: currentAuthorityStateDigest(),
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
- function activationReadiness(state, reason, trustRootCount) {
678
- return {
679
- schemaVersion: 1,
680
- policyVersion: POLICY_VERSION,
681
- authority: 'observation-only',
682
- sourceState: state === 'degraded' ? 'degraded' : 'healthy',
683
- state,
684
- commandEligible: state === 'ready',
685
- requestedShape: 'proposal-once',
686
- trustRootCount,
687
- residentAuthorized: false,
688
- installAuthorized: false,
689
- repairAuthorized: false,
690
- reason,
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
- function inspectPinnedActivationPermit(pinned, permitPath, cfg, trustRoots, suppliedContext) {
705
- let envelope;
706
- try {
707
- envelope = JSON.parse(pinned.text);
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
- catch {
710
- return activationReadiness('degraded', 'invalid-permit-json', trustRoots.length);
827
+ let dirStat;
828
+ try {
829
+ dirStat = lstatSync(dirname(path), { bigint: true });
711
830
  }
712
- if (`${canonicalizeDaemonActivationValue(envelope)}\n` !== pinned.text) {
713
- return activationReadiness('degraded', 'noncanonical-permit-encoding', trustRoots.length);
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
- const parsed = parseEnvelope(envelope);
716
- if (!parsed) {
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
- configSnapshot = strictConfigSnapshot(cfg);
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
- catch {
724
- return activationReadiness('degraded', 'activation-config-not-strict-json', trustRoots.length);
885
+ finally {
886
+ closeSync(fd);
725
887
  }
726
- const context = suppliedContext ?? collectRuntimeContext(configSnapshot);
727
- if (context.configDigest !== daemonActivationConfigDigest(configSnapshot)) {
728
- return activationReadiness('blocked', 'runtime-config-digest-mismatch', trustRoots.length);
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
- const verification = verifyDaemonActivationPermit(parsed, context, trustRoots);
731
- if (!verification.ok || !verification.permitId) {
732
- return activationReadiness('blocked', verification.reason, trustRoots.length);
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
- const nonceDigest = sha256(parsed.payload.nonce);
735
- if (pathEntryPresent(daemonActivationReceiptPath(verification.permitId))
736
- || pathEntryPresent(daemonActivationNonceReceiptPath(nonceDigest))) {
737
- return activationReadiness('blocked', 'activation-permit-already-consumed', trustRoots.length);
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
- const openedAfterInspection = fstatSync(pinned.fd, { bigint: true });
740
- const namedAfterInspection = lstatSync(permitPath, { bigint: true });
741
- if (!sameFileSnapshot(pinned.stat, openedAfterInspection)
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
- return activationReadiness('ready', 'valid-proposal-once-permit', trustRoots.length);
950
+ if (!releaseLocalStoreLock(lock))
951
+ throw new Error('activation-store-lock-release-failed');
952
+ return result;
746
953
  }
747
- function inspectWithAuthority(cfg, opts, trustRoots, suppliedContext, platform = process.platform) {
748
- if (!needsPermit(opts)) {
749
- return activationReadiness('ready', 'dry-run-once-does-not-require-activation-permit', trustRoots.length);
750
- }
751
- if (!supportedProposalOnceShape(opts)) {
752
- return activationReadiness('blocked', 'activation-permit-cannot-authorize-requested-start-shape', trustRoots.length);
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
- if (trustRoots.length === 0) {
755
- return activationReadiness('blocked', 'no-trusted-activation-roots', 0);
984
+ finally {
985
+ closeSync(fd);
756
986
  }
757
- if (platform === 'win32') {
758
- return activationReadiness('blocked', 'activation-permit-v1-unsupported-on-windows', trustRoots.length);
987
+ }
988
+ function validTrustRootRecord(value) {
989
+ if (!isRecord(value) || !hasExactKeys(value, ['keyId', 'publicKeyPem', 'createdAt', 'revokedAt', 'label'])) {
990
+ return false;
759
991
  }
760
- const permitPath = daemonActivationPermitPath();
761
- let pinned;
762
- let result;
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
- pinned = openPinnedPermit(permitPath, resolve(homedir()));
765
- result = inspectPinnedActivationPermit(pinned, permitPath, cfg, trustRoots, suppliedContext);
997
+ const key = createPublicKey(value['publicKeyPem']);
998
+ if (key.asymmetricKeyType !== 'ed25519')
999
+ return false;
766
1000
  }
767
- catch (error) {
768
- result = activationReadiness(error.code === 'ENOENT' ? 'blocked' : 'degraded', error.code === 'ENOENT'
769
- ? 'activation-permit-missing'
770
- : 'activation-permit-inspection-failed', trustRoots.length);
1001
+ catch {
1002
+ return false;
771
1003
  }
772
- if (pinned) {
773
- try {
774
- closeSync(pinned.fd);
775
- }
776
- catch {
777
- return activationReadiness('degraded', 'activation-permit-close-failed', trustRoots.length);
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 result;
1028
+ return { ok: true, records };
781
1029
  }
782
- function needsPermit(opts) {
783
- return !(opts.once === true && opts.dryRun === true);
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
- function supportedProposalOnceShape(opts) {
786
- return opts.once === true
787
- && opts.dryRun !== true
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 consumeWithAuthority(cfg, opts, trustRoots, suppliedContext, afterReceiptPersisted, mayMintCapability) {
792
- if (!needsPermit(opts)) {
793
- return {
794
- authorized: true,
795
- required: false,
796
- reason: 'dry-run-once-does-not-require-activation-permit',
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
- if (!supportedProposalOnceShape(opts)) {
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 (trustRoots.length === 0) {
807
- return { authorized: false, required: true, reason: 'no-trusted-activation-roots' };
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
- let configSnapshot;
1163
+ if (killSwitchOn())
1164
+ return { granted: false, reason: 'kill-switch-is-on' };
1165
+ let guardHealthy;
817
1166
  try {
818
- configSnapshot = strictConfigSnapshot(cfg);
1167
+ guardHealthy = !diagnoseGuardHealth().blocked;
819
1168
  }
820
1169
  catch {
821
- return { authorized: false, required: true, reason: 'activation-config-not-strict-json' };
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
- const home = resolve(homedir());
824
- const permitPath = daemonActivationPermitPath();
825
- const lockPath = join(dirname(permitPath), 'daemon-activation-permit.lock');
826
- const lock = acquireLocalStoreLock(lockPath, LOCK_WAIT_MS, {
827
- anchorPath: home,
828
- exactPrivateStorage: true,
829
- });
830
- if (!lock)
831
- return { authorized: false, required: true, reason: 'activation-permit-lock-unavailable' };
832
- let pinned;
833
- let result = {
834
- authorized: false,
835
- required: true,
836
- reason: 'activation-permit-consumption-failed',
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. Its immutable repository-owned roots are the only path
996
- * that can mint an in-process activation capability.
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
- return consumeWithAuthority(cfg, opts, DAEMON_ACTIVATION_TRUST_ROOTS, undefined, undefined, true);
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, DAEMON_ACTIVATION_TRUST_ROOTS, undefined);
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