@starci/hfs 4.0.10 → 4.1.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 (271) hide show
  1. package/CHANGELOG.md +1 -1
  2. package/emit/operations.mjs +1 -1
  3. package/emit/type-schema.mjs +2 -1
  4. package/lint/run.mjs +3 -2
  5. package/package.json +1 -1
  6. package/report/order.mjs +2 -0
  7. package/runtime/config.example.yaml +187 -0
  8. package/runtime/engine/by-code-unit.mjs +12 -0
  9. package/runtime/engine/config.mjs +221 -239
  10. package/runtime/engine/invalid-config.mjs +5 -5
  11. package/runtime/engine/model-config.mjs +90 -0
  12. package/runtime/engine/orca-config.mjs +3 -1
  13. package/runtime/engine/removed-vocabulary.mjs +28 -0
  14. package/runtime/engine/resources-config.mjs +52 -0
  15. package/runtime/engine/runtime-root.mjs +17 -0
  16. package/runtime/engine/secrets.mjs +35 -31
  17. package/runtime/engine/sonar-config.mjs +21 -0
  18. package/runtime/engine/temp-root.mjs +32 -0
  19. package/runtime/knowledge/hfs/canon-pins.yaml +14 -14
  20. package/runtime/knowledge/hfs/peer-integrations.yaml +2 -3
  21. package/runtime/knowledge/hfs/rules.yaml +98 -65
  22. package/runtime/knowledge/hfs/slots.yaml +11 -14
  23. package/runtime/knowledge/patterns/be/api.yaml +7 -24
  24. package/runtime/knowledge/patterns/be/cli.yaml +3 -12
  25. package/runtime/knowledge/patterns/be/realtime.yaml +1 -14
  26. package/runtime/knowledge/patterns/be/webhooks.yaml +0 -19
  27. package/runtime/modules/kernel/failure-codes.yaml +1 -41
  28. package/runtime/modules/kernel/removed-vocabulary.yaml +168 -0
  29. package/runtime/modules/models/registry.yaml +25 -359
  30. package/runtime/modules/models/runtimes.yaml +92 -256
  31. package/runtime/modules/models/tiers.yaml +65 -0
  32. package/runtime/scripts/api/fs/claim-file.mjs +30 -0
  33. package/runtime/scripts/api/fs/ensure-temp-root.mjs +27 -0
  34. package/runtime/scripts/api/fs/forbidden-root.mjs +2 -1
  35. package/runtime/scripts/api/fs/make-temp-dir.mjs +11 -0
  36. package/runtime/scripts/api/fs/safe-remove.mjs +52 -35
  37. package/runtime/scripts/api/git/lib.mjs +34 -1
  38. package/runtime/scripts/api/process/resolve-real-tool.mjs +14 -8
  39. package/runtime/scripts/api/process/run-program.mjs +2 -1
  40. package/runtime/scripts/api/sops/lib.mjs +198 -120
  41. package/runtime/scripts/hfs/allows.mjs +3 -3
  42. package/runtime/scripts/hfs/architecture/ast-walks.mjs +63 -50
  43. package/runtime/scripts/hfs/architecture/automatic-gates.mjs +96 -0
  44. package/runtime/scripts/hfs/architecture/backend.mjs +261 -175
  45. package/runtime/scripts/hfs/architecture/background-unowned.mjs +69 -34
  46. package/runtime/scripts/hfs/architecture/client-reaches-server.mjs +55 -54
  47. package/runtime/scripts/hfs/architecture/clones.mjs +179 -106
  48. package/runtime/scripts/hfs/architecture/config-unread.mjs +5 -22
  49. package/runtime/scripts/hfs/architecture/config.mjs +95 -80
  50. package/runtime/scripts/hfs/architecture/connection-map.mjs +233 -141
  51. package/runtime/scripts/hfs/architecture/constructor-deps.mjs +15 -9
  52. package/runtime/scripts/hfs/architecture/context-coupling.mjs +76 -52
  53. package/runtime/scripts/hfs/architecture/context-map.mjs +215 -141
  54. package/runtime/scripts/hfs/architecture/context-owner.mjs +39 -30
  55. package/runtime/scripts/hfs/architecture/context-platform-tables.mjs +31 -17
  56. package/runtime/scripts/hfs/architecture/context-transaction.mjs +38 -25
  57. package/runtime/scripts/hfs/architecture/contract-fixture-guard.mjs +86 -40
  58. package/runtime/scripts/hfs/architecture/contracts-readonly.mjs +342 -0
  59. package/runtime/scripts/hfs/architecture/contracts.mjs +263 -455
  60. package/runtime/scripts/hfs/architecture/cross-app-duplicate.mjs +43 -34
  61. package/runtime/scripts/hfs/architecture/dead-exports.mjs +201 -154
  62. package/runtime/scripts/hfs/architecture/default-deny.mjs +118 -93
  63. package/runtime/scripts/hfs/architecture/doc-language.mjs +2 -1
  64. package/runtime/scripts/hfs/architecture/entrypoint.mjs +45 -30
  65. package/runtime/scripts/hfs/architecture/error-codes.mjs +19 -12
  66. package/runtime/scripts/hfs/architecture/error-masked.mjs +39 -26
  67. package/runtime/scripts/hfs/architecture/fe-slot-allows.mjs +2 -1
  68. package/runtime/scripts/hfs/architecture/feature-shape.mjs +26 -20
  69. package/runtime/scripts/hfs/architecture/framework-pinned.mjs +1 -1
  70. package/runtime/scripts/hfs/architecture/frontend-grammar.mjs +190 -0
  71. package/runtime/scripts/hfs/architecture/frontend-routing.mjs +186 -0
  72. package/runtime/scripts/hfs/architecture/frontend-world-render.mjs +311 -0
  73. package/runtime/scripts/hfs/architecture/frontend-world.mjs +328 -0
  74. package/runtime/scripts/hfs/architecture/frontend.mjs +109 -829
  75. package/runtime/scripts/hfs/architecture/hfs-graph.mjs +14 -2
  76. package/runtime/scripts/hfs/architecture/hfs.mjs +120 -288
  77. package/runtime/scripts/hfs/architecture/hooks-are-hooks.mjs +46 -26
  78. package/runtime/scripts/hfs/architecture/i18n-keys.mjs +114 -76
  79. package/runtime/scripts/hfs/architecture/index.mjs +158 -102
  80. package/runtime/scripts/hfs/architecture/injection-token-exported.mjs +33 -28
  81. package/runtime/scripts/hfs/architecture/machine-ast.mjs +180 -144
  82. package/runtime/scripts/hfs/architecture/managed-scripts.mjs +8 -2
  83. package/runtime/scripts/hfs/architecture/module-per-transport.mjs +150 -101
  84. package/runtime/scripts/hfs/architecture/next-data-contract.mjs +51 -43
  85. package/runtime/scripts/hfs/architecture/next-data-key.mjs +338 -0
  86. package/runtime/scripts/hfs/architecture/next-data.mjs +179 -427
  87. package/runtime/scripts/hfs/architecture/owners.mjs +27 -13
  88. package/runtime/scripts/hfs/architecture/package-shape.mjs +51 -23
  89. package/runtime/scripts/hfs/architecture/presentation.mjs +165 -0
  90. package/runtime/scripts/hfs/architecture/reachability.mjs +127 -61
  91. package/runtime/scripts/hfs/architecture/register-once.mjs +112 -63
  92. package/runtime/scripts/hfs/architecture/registration.mjs +187 -110
  93. package/runtime/scripts/hfs/architecture/required-files.mjs +100 -68
  94. package/runtime/scripts/hfs/architecture/route-files-thin.mjs +83 -58
  95. package/runtime/scripts/hfs/architecture/schema-owner.mjs +182 -108
  96. package/runtime/scripts/hfs/architecture/source-names-shape.mjs +318 -0
  97. package/runtime/scripts/hfs/architecture/source-names.mjs +83 -248
  98. package/runtime/scripts/hfs/architecture/sql-owner.mjs +175 -118
  99. package/runtime/scripts/hfs/architecture/sql-returning.mjs +39 -28
  100. package/runtime/scripts/hfs/architecture/sql-tokens.mjs +302 -172
  101. package/runtime/scripts/hfs/architecture/supabase-ast.mjs +1 -1
  102. package/runtime/scripts/hfs/architecture/supabase-be.mjs +55 -45
  103. package/runtime/scripts/hfs/architecture/supabase-results.mjs +182 -0
  104. package/runtime/scripts/hfs/architecture/supabase-tables.mjs +20 -12
  105. package/runtime/scripts/hfs/architecture/supabase.mjs +191 -263
  106. package/runtime/scripts/hfs/architecture/symbols.mjs +175 -99
  107. package/runtime/scripts/hfs/architecture/test-world-files.mjs +82 -41
  108. package/runtime/scripts/hfs/architecture/tiers.mjs +120 -69
  109. package/runtime/scripts/hfs/architecture/transport-owner.mjs +84 -57
  110. package/runtime/scripts/hfs/architecture/type-context.mjs +282 -0
  111. package/runtime/scripts/hfs/architecture/typescript.mjs +116 -276
  112. package/runtime/scripts/hfs/architecture/unit-spec-providers.mjs +84 -62
  113. package/runtime/scripts/hfs/architecture.mjs +3 -4
  114. package/runtime/scripts/hfs/check.mjs +61 -70
  115. package/runtime/scripts/hfs/coverage-scope.mjs +84 -38
  116. package/runtime/scripts/hfs/declaration-shape.mjs +77 -36
  117. package/runtime/scripts/hfs/declaration-slots.mjs +1 -1
  118. package/runtime/scripts/hfs/edition-slots.mjs +5 -3
  119. package/runtime/scripts/hfs/linear-text.mjs +30 -0
  120. package/runtime/scripts/hfs/manifest-shape.mjs +164 -63
  121. package/runtime/scripts/hfs/path-findings.mjs +58 -38
  122. package/runtime/scripts/hfs/pin-findings.mjs +31 -0
  123. package/runtime/scripts/hfs/repo-identity.mjs +5 -2
  124. package/runtime/scripts/hfs/rule-catalog.mjs +194 -0
  125. package/runtime/scripts/hfs/rule-params-shape.mjs +33 -27
  126. package/runtime/scripts/hfs/rules/cli.mjs +5 -1
  127. package/runtime/scripts/hfs/rules/contract-compat.mjs +40 -22
  128. package/runtime/scripts/hfs/rules/contract.mjs +35 -27
  129. package/runtime/scripts/hfs/rules/database-config.mjs +57 -32
  130. package/runtime/scripts/hfs/rules/database-migrations.mjs +52 -31
  131. package/runtime/scripts/hfs/rules/database-plpgsql.mjs +106 -91
  132. package/runtime/scripts/hfs/rules/database-sql.mjs +254 -173
  133. package/runtime/scripts/hfs/rules/database.mjs +36 -23
  134. package/runtime/scripts/hfs/rules/deps.mjs +64 -32
  135. package/runtime/scripts/hfs/rules/docker.mjs +113 -65
  136. package/runtime/scripts/hfs/rules/edition.mjs +118 -111
  137. package/runtime/scripts/hfs/rules/event-bus.mjs +91 -45
  138. package/runtime/scripts/hfs/rules/fe-contract-documents.mjs +17 -16
  139. package/runtime/scripts/hfs/rules/fe-no-tests.mjs +36 -16
  140. package/runtime/scripts/hfs/rules/frontend-tree.mjs +4 -3
  141. package/runtime/scripts/hfs/rules/integration-specs.mjs +75 -41
  142. package/runtime/scripts/hfs/rules/kinds.mjs +59 -25
  143. package/runtime/scripts/hfs/rules/lint-suppression.mjs +9 -3
  144. package/runtime/scripts/hfs/rules/monorepo.mjs +82 -45
  145. package/runtime/scripts/hfs/rules/peer-integrations.mjs +24 -3
  146. package/runtime/scripts/hfs/rules/pipeline.mjs +58 -9
  147. package/runtime/scripts/hfs/rules/proof-commands.mjs +14 -8
  148. package/runtime/scripts/hfs/rules/repo-local-checks.mjs +11 -6
  149. package/runtime/scripts/hfs/rules/saga.mjs +128 -61
  150. package/runtime/scripts/hfs/rules/secrets.mjs +4 -3
  151. package/runtime/scripts/hfs/rules/services.mjs +34 -16
  152. package/runtime/scripts/hfs/rules/stacks.mjs +21 -14
  153. package/runtime/scripts/hfs/rules/supabase-secrets.mjs +32 -23
  154. package/runtime/scripts/hfs/rules/test-topology.mjs +25 -11
  155. package/runtime/scripts/hfs/secret.mjs +59 -48
  156. package/runtime/scripts/hfs/slot-app-view.mjs +102 -0
  157. package/runtime/scripts/hfs/slot-classify.mjs +65 -0
  158. package/runtime/scripts/hfs/slot-errors.mjs +11 -0
  159. package/runtime/scripts/hfs/slot-imports.mjs +76 -0
  160. package/runtime/scripts/hfs/slot-manifest-shape.mjs +73 -0
  161. package/runtime/scripts/hfs/slot-match.mjs +153 -0
  162. package/runtime/scripts/hfs/slot-path.mjs +8 -0
  163. package/runtime/scripts/hfs/slot-required.mjs +76 -0
  164. package/runtime/scripts/hfs/slot-semantic-problems.mjs +148 -0
  165. package/runtime/scripts/hfs/slot-side-problems.mjs +28 -0
  166. package/runtime/scripts/hfs/slots.mjs +72 -693
  167. package/runtime/scripts/hfs/sql/pg-parse.mjs +5 -2
  168. package/runtime/scripts/hfs/trailing-slashes.mjs +8 -0
  169. package/runtime/scripts/hfs/tree.mjs +19 -14
  170. package/runtime/scripts/hfs/typescript-programs.mjs +6 -4
  171. package/runtime/scripts/hfs/view.mjs +1 -1
  172. package/runtime/scripts/lib/dockerfile.mjs +56 -33
  173. package/runtime/scripts/lib/event-contract.mjs +17 -13
  174. package/runtime/scripts/lib/git.mjs +2 -2
  175. package/runtime/scripts/lib/graphql-contract.mjs +158 -322
  176. package/runtime/scripts/lib/graphql-sdl.mjs +262 -0
  177. package/runtime/scripts/lib/i18n.mjs +3 -2
  178. package/runtime/scripts/lib/in-order.mjs +71 -0
  179. package/runtime/scripts/lib/language.mjs +3 -3
  180. package/runtime/scripts/lib/list.mjs +8 -1
  181. package/runtime/scripts/lib/path-key.mjs +38 -5
  182. package/runtime/scripts/lib/pid-alive.mjs +7 -0
  183. package/runtime/scripts/lib/regex.mjs +1 -1
  184. package/runtime/scripts/lib/same-text.mjs +1 -1
  185. package/runtime/scripts/lib/secret-patterns.mjs +6 -2
  186. package/runtime/scripts/lib/sleep-sync.mjs +2 -2
  187. package/runtime/scripts/lib/sops-envelope.mjs +95 -48
  188. package/runtime/scripts/lib/stack-services.mjs +1 -2
  189. package/runtime/scripts/lib/ts-ast.mjs +9 -0
  190. package/runtime/scripts/lib/walk.mjs +6 -1
  191. package/scaffold/add-cli-lite.mjs +2 -1
  192. package/scaffold/add-table.mjs +2 -2
  193. package/scaffold/add.mjs +3 -2
  194. package/scaffold/app.mjs +4 -2
  195. package/scaffold/edition-gate.mjs +25 -25
  196. package/scaffold/lite-exports.mjs +2 -1
  197. package/scaffold/service.mjs +5 -4
  198. package/sync/hygiene.mjs +15 -9
  199. package/sync/index.mjs +7 -3
  200. package/templates/app/ci-workflows/github/workflows/ci.yml +4 -4
  201. package/templates/app/ci-workflows/github/workflows/e2e.yml +3 -3
  202. package/templates/app/ci-workflows/github/workflows/images.yml +3 -3
  203. package/templates/app/ci-workflows/sonar-steps.yml +2 -2
  204. package/templates/app/ci-workflows-lite/github/workflows/ci.yml +6 -6
  205. package/templates/app/ci-workflows-lite/github/workflows/db-deploy.yml +3 -3
  206. package/templates/app/ci-workflows-lite/github/workflows/images.yml +3 -3
  207. package/templates/app/starciwork.gitignore +1 -1
  208. package/templates/be/image/api/Dockerfile +1 -1
  209. package/templates/be/image/cli/Dockerfile +1 -1
  210. package/templates/be/image/worker/Dockerfile +1 -1
  211. package/templates/be/patterns/cli/group.cli.spec.ts.tpl +1 -1
  212. package/templates/be/patterns/cli/group.cli.ts.tpl +2 -2
  213. package/templates/be/patterns/event-bus/platform/event-runner.service.spec.ts.tpl +13 -0
  214. package/templates/be/patterns/event-bus/platform/event-runner.service.ts.tpl +7 -2
  215. package/templates/be/patterns/event-bus/platform/event.policy.ts.tpl +4 -1
  216. package/templates/be/patterns/event-bus/platform/kafka-event-transport.client.ts.tpl +2 -1
  217. package/templates/be/patterns/outbox/platform/outbox-relay.policy.ts.tpl +24 -9
  218. package/templates/be/patterns/queues/platform/queue-relay.service.ts.tpl +3 -2
  219. package/templates/be/patterns/queues/platform/queue-worker.service.ts.tpl +8 -5
  220. package/templates/be/skeleton/src/features/cli/migrate/migrate.cli.spec.ts +1 -1
  221. package/templates/be/skeleton/src/features/cli/migrate/migrate.cli.ts +2 -2
  222. package/templates/be/skeleton/src/features/cli/seed/seed.cli.spec.ts +1 -1
  223. package/templates/be/skeleton/src/features/cli/seed/seed.cli.ts +2 -2
  224. package/templates/be/skeleton/src/modules/platform/database/migrate-connections.client.ts +3 -2
  225. package/templates/be/skeleton/src/modules/platform/database/seed-connections.client.ts +5 -4
  226. package/templates/be/skeleton/src/modules/platform/primitives/index.ts +1 -0
  227. package/templates/be/skeleton/src/modules/platform/primitives/sequence.policy.spec.ts +47 -0
  228. package/templates/be/skeleton/src/modules/platform/primitives/sequence.policy.ts +16 -0
  229. package/templates/be/skeleton-lite/apps/api/Dockerfile +1 -0
  230. package/templates/be/skeleton-lite/src/modules/platform/primitives/index.ts +2 -0
  231. package/templates/fe/image/next/Dockerfile +2 -1
  232. package/templates/fe/skeleton/apps/app/src/app/[locale]/page.tsx +2 -2
  233. package/templates/fe/skeleton/apps/landing/src/app/[locale]/page.tsx +2 -2
  234. package/templates/fe/skeleton/packages/__project__-i18n/src/index.ts +1 -2
  235. package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/layout.tsx +1 -2
  236. package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/page.tsx +2 -2
  237. package/templates/fe/skeleton-lite/apps/web/src/components/blocks/SignInForm/index.tsx +7 -1
  238. package/templates/fe/skeleton-lite/apps/web/src/modules/db/auth/write-sign-out.ts +1 -1
  239. package/templates/fe/skeleton-lite/apps/web/src/modules/db/validation/validation.mapper.ts +10 -2
  240. package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/request.ts +10 -3
  241. package/upgrade/index.mjs +9 -2
  242. package/runtime/engine/admission.mjs +0 -308
  243. package/runtime/engine/canonical-json.mjs +0 -10
  244. package/runtime/engine/db/blob.mjs +0 -315
  245. package/runtime/engine/db/ledger-paths.mjs +0 -83
  246. package/runtime/engine/db/ledger.mjs +0 -1118
  247. package/runtime/engine/db/machine-connection.mjs +0 -135
  248. package/runtime/engine/db/machine-schema.mjs +0 -84
  249. package/runtime/engine/db/machine.mjs +0 -1367
  250. package/runtime/engine/db/migrations/machine/0001-init.sql +0 -924
  251. package/runtime/engine/db/migrations/runtime/0001-init.sql +0 -1108
  252. package/runtime/engine/db/provider-reservations.mjs +0 -101
  253. package/runtime/engine/digest.mjs +0 -16
  254. package/runtime/engine/refuse.mjs +0 -11
  255. package/runtime/modules/ops/_labels.yaml +0 -53
  256. package/runtime/scripts/api/node/lib.mjs +0 -14
  257. package/runtime/scripts/api/node/spawn-node.mjs +0 -6
  258. package/runtime/scripts/api/process/lib.mjs +0 -111
  259. package/runtime/scripts/api/process/owned-process.mjs +0 -78
  260. package/runtime/scripts/api/process/stop-owned-process.mjs +0 -9
  261. package/runtime/scripts/connectors/lib.mjs +0 -488
  262. package/runtime/scripts/lib/clip.mjs +0 -19
  263. package/runtime/scripts/lib/display-names.mjs +0 -259
  264. package/runtime/scripts/lib/example-refs.mjs +0 -158
  265. package/runtime/scripts/lib/json-schema.mjs +0 -52
  266. package/runtime/scripts/lib/process-identity.mjs +0 -6
  267. package/runtime/scripts/lib/read-yaml.mjs +0 -18
  268. package/runtime/scripts/lib/redact.mjs +0 -161
  269. package/runtime/scripts/lib/sleep.mjs +0 -7
  270. package/runtime/scripts/lib/source-phrases.mjs +0 -34
  271. package/runtime/scripts/lib/sqlite.mjs +0 -21
@@ -6,6 +6,7 @@ import path from 'node:path';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import { samePath } from '../../lib/path-key.mjs';
8
8
  import { realpathOr } from '../../lib/fs-kind.mjs';
9
+ import { tempRoot } from '../../../engine/temp-root.mjs';
9
10
 
10
11
  const SKILL_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
11
12
 
@@ -37,7 +38,7 @@ function strictlyInsideReal(p, root) {
37
38
  export function forbiddenRoot(p, { checkoutsUnder = null, hold } = {}) {
38
39
  const resolved = path.resolve(p);
39
40
  if (path.parse(resolved).root === resolved || samePath(path.dirname(resolved), resolved)) return 'a filesystem root';
40
- for (const [name, dir] of [['the home directory', os.homedir()], ['the temp directory', os.tmpdir()], ['the runtime', SKILL_ROOT],
41
+ for (const [name, dir] of [['the home directory', os.homedir()], ['the temp directory', os.tmpdir()], ['the configured temp root', tempRoot()], ['the runtime', SKILL_ROOT],
41
42
  ['the repository hosting the runtime', path.dirname(SKILL_ROOT)], ['the repositories root', path.dirname(path.dirname(SKILL_ROOT))]]) {
42
43
  if (dir && samePath(path.resolve(dir), resolved)) return name;
43
44
  }
@@ -0,0 +1,11 @@
1
+ // make-temp-dir.mjs — a fresh temporary directory under the temp root (engine/temp-root.mjs tempRoot).
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { ensureTempRoot } from './ensure-temp-root.mjs';
5
+
6
+ /** A new directory `<prefix><random>` under `parent` (default the temp root, ensureTempRoot: TEMP_ROOT_UNUSABLE when it cannot be made), which is created when it is missing. */
7
+ export function makeTempDir(prefix, { env = process.env, parent = undefined } = {}) {
8
+ const root = parent ?? ensureTempRoot({ env });
9
+ fs.mkdirSync(root, { recursive: true });
10
+ return fs.mkdtempSync(path.join(root, prefix));
11
+ }
@@ -41,7 +41,7 @@ const retrying = (fn, retries) => {
41
41
  };
42
42
  const removeFile = (p, retries) => retrying(() => {
43
43
  try { fs.unlinkSync(p); } catch (error) {
44
- if (WIN && (error?.code === 'EPERM' || error?.code === 'EACCES')) { fs.chmodSync(p, 0o666); fs.unlinkSync(p); } else throw error;
44
+ if (WIN && (error?.code === 'EPERM' || error?.code === 'EACCES')) { fs.chmodSync(p, 0o600); fs.unlinkSync(p); } else throw error;
45
45
  }
46
46
  }, retries);
47
47
 
@@ -65,40 +65,57 @@ export function safeRemove(root, { retries = 5, checkoutsUnder = null, hold } =
65
65
  // the root itself or strictly under the root's real path. A link is unlinked above (the link only); any other
66
66
  // entry that resolves outside the tree is refused, whatever made it look like a plain entry.
67
67
  const rootReal = realpathOr(target);
68
- const insideRoot = (p) => { const real = realpathOr(p); if (!real || !rootReal) return false; if (same(real, rootReal)) return true;
69
- const rel = path.relative(rootReal, real); return Boolean(rel) && !rel.startsWith('..') && !path.isAbsolute(rel); };
70
- const walk = (dir, st, parentReal) => {
71
- if (isLinkLike(dir, { parentReal, stat: st })) {
72
- if (unlinkOnly(dir)) { out.removed.links += 1; return true; }
73
- fail(dir, { code: 'LINK_STUCK', message: 'a link could not be unlinked; nothing above it is removed' });
74
- return false;
75
- }
76
- if (!insideRoot(dir)) {
77
- fail(dir, { code: 'OUTSIDE_ROOT', message: `refusing to delete ${dir}: its real path ${realpathOr(dir) ?? '?'} is outside ${rootReal ?? target}` });
78
- return false;
79
- }
80
- if (!st.isDirectory()) {
81
- const error = removeFile(dir, retries);
82
- if (error) { fail(dir, error); return false; }
83
- out.removed.files += 1; return true;
84
- }
85
- const real = realpathOr(dir);
86
- if (!real) { fail(dir, { code: 'REALPATH', message: 'cannot resolve the directory' }); return false; }
87
- let entries;
88
- try { entries = fs.readdirSync(dir); } catch (error) { fail(dir, error); return false; }
89
- let clear = true;
90
- for (const name of entries) {
91
- const child = path.join(dir, name);
92
- let childStat;
93
- try { childStat = fs.lstatSync(child); } catch (error) { if (error?.code !== 'ENOENT') { fail(child, error); clear = false; } continue; }
94
- if (!walk(child, childStat, real)) clear = false;
95
- }
96
- if (!clear) return false;
97
- const error = retrying(() => fs.rmdirSync(dir), retries);
98
- if (error) { fail(dir, error); return false; }
99
- out.removed.dirs += 1; return true;
100
- };
101
- walk(target, st, null);
68
+ const ctx = { out, fail, retries, rootReal, target };
69
+ walk(ctx, target, st, null);
102
70
  out.ok = !fs.existsSync(target) && (() => { try { fs.lstatSync(target); return false; } catch { return true; } })();
103
71
  return out;
104
72
  }
73
+
74
+ function insideRoot({ rootReal }, p) {
75
+ const real = realpathOr(p);
76
+ if (!real || !rootReal) return false;
77
+ if (same(real, rootReal)) return true;
78
+ const rel = path.relative(rootReal, real);
79
+ return Boolean(rel) && !rel.startsWith('..') && !path.isAbsolute(rel);
80
+ }
81
+
82
+ // Removes each child of `dir` (real path `real`); false when any child stays.
83
+ function removeChildren(ctx, dir, entries, real) {
84
+ let clear = true;
85
+ for (const name of entries) {
86
+ const child = path.join(dir, name);
87
+ let childStat;
88
+ try { childStat = fs.lstatSync(child); } catch (error) { if (error?.code !== 'ENOENT') { ctx.fail(child, error); clear = false; } continue; }
89
+ if (!walk(ctx, child, childStat, real)) clear = false;
90
+ }
91
+ return clear;
92
+ }
93
+
94
+ function walkDirectory(ctx, dir) {
95
+ const { out, fail, retries } = ctx;
96
+ const real = realpathOr(dir);
97
+ if (!real) { fail(dir, { code: 'REALPATH', message: 'cannot resolve the directory' }); return false; }
98
+ let entries;
99
+ try { entries = fs.readdirSync(dir); } catch (error) { fail(dir, error); return false; }
100
+ if (!removeChildren(ctx, dir, entries, real)) return false;
101
+ const error = retrying(() => fs.rmdirSync(dir), retries);
102
+ if (error) { fail(dir, error); return false; }
103
+ out.removed.dirs += 1; return true;
104
+ }
105
+
106
+ function walk(ctx, dir, st, parentReal) {
107
+ const { out, fail, retries, rootReal, target } = ctx;
108
+ if (isLinkLike(dir, { parentReal, stat: st })) {
109
+ if (unlinkOnly(dir)) { out.removed.links += 1; return true; }
110
+ fail(dir, { code: 'LINK_STUCK', message: 'a link could not be unlinked; nothing above it is removed' });
111
+ return false;
112
+ }
113
+ if (!insideRoot(ctx, dir)) {
114
+ fail(dir, { code: 'OUTSIDE_ROOT', message: `refusing to delete ${dir}: its real path ${realpathOr(dir) ?? '?'} is outside ${rootReal ?? target}` });
115
+ return false;
116
+ }
117
+ if (st.isDirectory()) return walkDirectory(ctx, dir);
118
+ const error = removeFile(dir, retries);
119
+ if (error) { fail(dir, error); return false; }
120
+ out.removed.files += 1; return true;
121
+ }
@@ -7,8 +7,12 @@
7
7
  // (diff.mjs, ls-files.mjs, worktree-*.mjs, ...), and a caller folds the spawn result it gets back with the pure
8
8
  // helpers of scripts/lib/git.mjs (gitResultOf, gitOutputOf).
9
9
  import { assertMutationFence } from '../../lib/mutation-fence.mjs';
10
+ import fs from 'node:fs';
11
+ import os from 'node:os';
12
+ import path from 'node:path';
10
13
  import { spawnSync } from 'node:child_process';
11
14
  import { withoutGitLocalEnv } from '../../lib/git.mjs';
15
+ import { withTempEnv } from '../../../engine/temp-root.mjs';
12
16
 
13
17
  /**
14
18
  * Spawn `file` (the git binary) once with `args`: utf8 text, a hidden window, never a shell.
@@ -20,7 +24,8 @@ export const gitSpawn = (file, args, options = {}) => {
20
24
  assertMutationFence({ kind: 'git-effect', args });
21
25
  const spawn = { encoding: 'utf8', windowsHide: true, ...options };
22
26
  if (spawn.encoding === 'buffer') spawn.encoding = null;
23
- return spawnSync(file, args, spawn);
27
+ // tempChildEnv copies the env (node adds NODE_V8_COVERAGE to the env object it is given: a frozen caller env must not throw under coverage) and points TEMP/TMP/TMPDIR at the temp root
28
+ return spawnSync(file, args, withTempEnv(spawn));
24
29
  };
25
30
 
26
31
  /**
@@ -42,3 +47,31 @@ export const gitRunner = (git = null) => (args, opts = {}) => {
42
47
  const r = git ? git(args, opts) : runGit(args, { timeout: 300_000, maxBuffer: 64 * 1024 * 1024, ...opts });
43
48
  return { ok: r?.ok ?? (!r?.error && r?.status === 0), stdout: String(r?.stdout ?? r?.out ?? '').trim(), stderr: String(r?.stderr ?? r?.err ?? r?.error?.message ?? r?.error ?? '').trim() };
44
49
  };
50
+
51
+ let cachedGit;
52
+ const gitPathOf = ({ env, platform, home, exists }) => {
53
+ const p = platform === 'win32' ? path.win32 : path.posix;
54
+ const pathKey = Object.keys(env).find((name) => name.toLowerCase() === 'path');
55
+ const shim = p.join(home, '.starci', 'bin');
56
+ const same = (a, b) => (platform === 'win32' ? p.resolve(a).toLowerCase() === p.resolve(b).toLowerCase() : p.resolve(a) === p.resolve(b));
57
+ const binary = platform === 'win32' ? 'git.exe' : 'git';
58
+ for (const raw of String(pathKey ? env[pathKey] : '').split(platform === 'win32' ? ';' : ':')) {
59
+ const dir = raw.replace(/^"(.*)"$/, '$1');
60
+ const file = dir && p.isAbsolute(dir) && !same(dir, shim) ? p.join(dir, binary) : null;
61
+ if (file && exists(file)) return file;
62
+ }
63
+ return null;
64
+ };
65
+ /**
66
+ * The absolute path of the real git binary, or null: the first PATH directory holding git (git.exe on Windows) that is not
67
+ * the StarCi shim directory <home>/.starci/bin (a seat's guard shim is never the git the runtime itself runs). A bare name is never
68
+ * spawned, so a writable directory cannot be reached by name lookup at spawn time. Resolved once per process; options (env,
69
+ * platform, home, exists) are for a spec and bypass the cache.
70
+ */
71
+ export const gitExecutable = (options = null) => {
72
+ if (!options && cachedGit !== undefined) return cachedGit;
73
+ const { env = process.env, platform = process.platform, home = os.homedir(), exists = (file) => fs.statSync(file, { throwIfNoEntry: false })?.isFile() === true } = options ?? {};
74
+ const found = gitPathOf({ env, platform, home, exists });
75
+ if (!options) cachedGit = found;
76
+ return found;
77
+ };
@@ -22,6 +22,18 @@ const executableNames = (program, { env, platform }) => {
22
22
  return [...new Set(names)];
23
23
  };
24
24
 
25
+ const firstExecutableHit = (entry, names, stat, realpath) => {
26
+ for (const executable of names) {
27
+ const candidate = path.join(entry, executable);
28
+ try {
29
+ if (stat(candidate).isFile()) {
30
+ try { return realpath(candidate); } catch { return path.resolve(candidate); }
31
+ }
32
+ } catch { /* try the next executable spelling or PATH entry */ }
33
+ }
34
+ return null;
35
+ };
36
+
25
37
  /** Resolve `program` from PATH while excluding <home>/.starci/bin by canonical directory identity. */
26
38
  export function resolveRealTool(program, { env = process.env, home = os.homedir(), platform = process.platform,
27
39
  stat = fs.statSync, realpath = fs.realpathSync.native } = {}) {
@@ -33,14 +45,8 @@ export function resolveRealTool(program, { env = process.env, home = os.homedir(
33
45
  for (const rawEntry of pathValue(env).split(delimiter).filter(Boolean)) {
34
46
  const entry = rawEntry.replace(/^"(.*)"$/, '$1');
35
47
  if (canonical(entry, { platform, realpath }) === shimKey) continue;
36
- for (const executable of names) {
37
- const candidate = path.join(entry, executable);
38
- try {
39
- if (stat(candidate).isFile()) {
40
- try { return realpath(candidate); } catch { return path.resolve(candidate); }
41
- }
42
- } catch { /* try the next executable spelling or PATH entry */ }
43
- }
48
+ const hit = firstExecutableHit(entry, names, stat, realpath);
49
+ if (hit) return hit;
44
50
  }
45
51
  return null;
46
52
  }
@@ -2,6 +2,7 @@
2
2
  // assisted run (scripts/uat/assisted-runner.mjs), resolved to its file by scripts/uat/launch.mjs launchFor. Returns the
3
3
  // spawnSync result {status, stdout, stderr, error, signal} (utf8 text, a hidden window).
4
4
  import { spawnSync } from 'node:child_process';
5
+ import { withTempEnv } from '../../../engine/temp-root.mjs';
5
6
 
6
7
  /** Runs `file args`; options (cwd, env, timeout, stdio, input) pass through. */
7
- export const runProgram = (file, args, options = {}) => spawnSync(file, args, { encoding: 'utf8', windowsHide: true, ...options });
8
+ export const runProgram = (file, args, options = {}) => spawnSync(file, args, withTempEnv({ encoding: 'utf8', windowsHide: true, ...options }));
@@ -22,33 +22,50 @@ export function custodyInputType(file, override) {
22
22
  return type;
23
23
  }
24
24
 
25
+ const sopsNames = (env, win, pathext) => {
26
+ if (!win) return ['sops'];
27
+ if (!pathext) return ['sops.exe', 'sops'];
28
+ return String(env.PATHEXT || '.EXE;.CMD;.BAT').split(';').filter(Boolean).map((ext) => `sops${ext}`);
29
+ };
30
+
31
+ const nestedPackageDirs = (filesystem, paths, directory) => {
32
+ const dirs = [];
33
+ try {
34
+ for (const nested of filesystem.readdirSync(directory, { withFileTypes: true })) {
35
+ if (nested.isDirectory()) dirs.push(paths.join(directory, nested.name));
36
+ }
37
+ } catch { /* unreadable package */ }
38
+ return dirs;
39
+ };
40
+
41
+ const wingetPackageDirs = (filesystem, paths, packages, wingetPackageTree) => {
42
+ const dirs = [];
43
+ try {
44
+ for (const entry of filesystem.readdirSync(packages)) {
45
+ if (!wingetPackageTree && !/sops/i.test(entry)) continue;
46
+ const directory = paths.join(packages, entry);
47
+ dirs.push(directory);
48
+ if (wingetPackageTree) dirs.push(...nestedPackageDirs(filesystem, paths, directory));
49
+ }
50
+ } catch { /* no WinGet packages */ }
51
+ return dirs;
52
+ };
53
+
54
+ const wingetDirs = (filesystem, paths, env, wingetPackageTree) => {
55
+ const dirs = [];
56
+ if (!env.LOCALAPPDATA) return dirs;
57
+ const winget = paths.join(env.LOCALAPPDATA, 'Microsoft', 'WinGet');
58
+ dirs.push(paths.join(winget, 'Links'), ...wingetPackageDirs(filesystem, paths, paths.join(winget, 'Packages'), wingetPackageTree));
59
+ return dirs;
60
+ };
61
+
25
62
  /** Resolve SOPS from PATH and WinGet. Consumers select PATHEXT names and the full package tree for their launcher. */
26
63
  export function resolveSops(env = process.env, { platform = process.platform, pathext = false, wingetPackageTree = false, filesystem = fs } = {}) {
27
64
  const win = platform === 'win32';
28
65
  const paths = win ? path.win32 : path.posix;
29
- const names = win
30
- ? (pathext ? String(env.PATHEXT || '.EXE;.CMD;.BAT').split(';').filter(Boolean).map((ext) => `sops${ext}`) : ['sops.exe', 'sops'])
31
- : ['sops'];
66
+ const names = sopsNames(env, win, pathext);
32
67
  const dirs = String(env.PATH ?? '').split(win ? ';' : ':').filter(Boolean);
33
- if (win && env.LOCALAPPDATA) {
34
- const winget = paths.join(env.LOCALAPPDATA, 'Microsoft', 'WinGet');
35
- dirs.push(paths.join(winget, 'Links'));
36
- const packages = paths.join(winget, 'Packages');
37
- try {
38
- for (const entry of filesystem.readdirSync(packages)) {
39
- if (!wingetPackageTree && !/sops/i.test(entry)) continue;
40
- const directory = paths.join(packages, entry);
41
- dirs.push(directory);
42
- if (wingetPackageTree) {
43
- try {
44
- for (const nested of filesystem.readdirSync(directory, { withFileTypes: true })) {
45
- if (nested.isDirectory()) dirs.push(paths.join(directory, nested.name));
46
- }
47
- } catch { /* unreadable package */ }
48
- }
49
- }
50
- } catch { /* no WinGet packages */ }
51
- }
68
+ if (win) dirs.push(...wingetDirs(filesystem, paths, env, wingetPackageTree));
52
69
  for (const directory of dirs) for (const name of names) {
53
70
  const file = paths.join(directory, name);
54
71
  try { if (filesystem.statSync(file).isFile()) return file; } catch { /* next candidate */ }
@@ -83,7 +100,7 @@ export function runSopsFile(bin, args, operation, options = {}) {
83
100
  }
84
101
 
85
102
  const SELECTED_SOPS_VERSIONS = new Set(['3.13.2', '3.13.3']);
86
- const SELECTED_AGE_VERSIONS = new Set(['1.2.1', '1.3.1']);
103
+ export const SELECTED_AGE_VERSIONS = new Set(['1.2.1', '1.3.1']);
87
104
  const IDENTITY_TIMEOUT_MS = 20_000;
88
105
  const CONTROLLED_ENV = new Set(['SOPS_AGE_KEY', 'SOPS_AGE_KEY_FILE', 'SOPS_AGE_KEY_CMD', 'SOPS_AGE_SSH_PRIVATE_KEY_FILE', 'SOPS_AGE_SSH_PRIVATE_KEY_CMD', 'SOPS_AGE_RECIPIENT', 'SOPS_KEYSERVICE', 'SOPS_ENABLE_LOCAL_KEYSERVICE', 'SOPS_DECRYPTION_ORDER', 'SOPS_KMS_ARN', 'SOPS_GCP_KMS_IDS', 'SOPS_HUAWEICLOUD_KMS_IDS', 'SOPS_AZURE_KEYVAULT_URLS', 'SOPS_VAULT_URIS', 'SOPS_PGP_FP', 'SOPS_AGE_RECIPIENTS', 'HOME', 'USERPROFILE', 'APPDATA', 'XDG_CONFIG_HOME']);
89
106
 
@@ -119,7 +136,7 @@ const auditAbsent = cwd => {
119
136
  try { fs.lstatSync(path.resolve(cwd ?? process.cwd(), '/etc/sops/audit.yaml')); return false; }
120
137
  catch (error) { return error.code === 'ENOENT'; }
121
138
  };
122
- const captured = r => r && r.status === 0 && r.signal == null && !r.error && Buffer.isBuffer(r.stdout) && Buffer.isBuffer(r.stderr);
139
+ const captured = r => r?.status === 0 && r.signal == null && !r.error && Buffer.isBuffer(r.stdout) && Buffer.isBuffer(r.stderr);
123
140
  const decoded = bytes => new TextDecoder('utf-8', { fatal: true }).decode(bytes);
124
141
  const ownedRequest = request => {
125
142
  if (request.operation === 'seal') {
@@ -141,115 +158,176 @@ const ownedRequest = request => {
141
158
  return { inputType: flags['--input-type'], outputType: flags['--output-type'], file: args.at(-1), recipient: flags['--age'] };
142
159
  };
143
160
 
161
+ /** The admitted identity key, or the unchanged refusal/failure result (admitted.key is absent then). */
162
+ const selectedAdmission = (selection, spec, invocation, env) => {
163
+ const fail = () => ({ status: null, stdout: '', stderr: '', error: selection.error });
164
+ if (!spec || !['binary', 'json', 'yaml', 'dotenv'].includes(spec.inputType) || !['binary', 'json', 'yaml', 'dotenv'].includes(spec.outputType)) return fail();
165
+ if (!invocation || typeof invocation.runProgram !== 'function' || typeof invocation.resolveRealTool !== 'function') return fail();
166
+ if (!['win32', 'linux', 'darwin'].includes(process.platform)) return held('unsupported-platform');
167
+ const key = env[selection.inlineName];
168
+ if (typeof key !== 'string' || !/^AGE-SECRET-KEY-1[0-9A-Z]+$/.test(key.trim())) return fail();
169
+ return { key };
170
+ };
171
+
172
+ const decryptArgs = (file, inputType, outputType) => ['decrypt', '--input-type', inputType, '--output-type', outputType, '--filename-override', file, '--enable-local-keyservice=true', '--decryption-order', 'age'];
173
+
174
+ const decryptSelected = (run, exe, file, input, spec, recipient) => {
175
+ const envelope = selectedAgeEnvelope(decoded(input), spec.inputType, recipient);
176
+ if (!envelope.ok) return held(envelope.reason);
177
+ const result = run(exe, decryptArgs(file, spec.inputType, spec.outputType), input);
178
+ if (!captured(result)) return held('selected-decryption-failed');
179
+ return { status: 0, stdout: decoded(result.stdout), stderr: '', error: null };
180
+ };
181
+
182
+ const encryptSelected = (run, exe, file, input, spec, recipient) => {
183
+ if (spec.recipient !== recipient) return held('recipient-mismatch');
184
+ const args = ['encrypt', '--input-type', spec.inputType, '--output-type', spec.outputType, '--filename-override', file, '--enable-local-keyservice=true', '--age', recipient];
185
+ const result = run(exe, args, input);
186
+ if (!captured(result)) return held('selected-encryption-failed');
187
+ if (result.stdout.length > CREDENTIAL_FILE_MAX_BYTES) return held('document-over-budget');
188
+ const text = decoded(result.stdout);
189
+ const envelope = selectedAgeEnvelope(text, spec.outputType, recipient);
190
+ if (!envelope.ok) return held(envelope.reason);
191
+ const readback = run(exe, decryptArgs(file, spec.outputType, spec.inputType), result.stdout);
192
+ if (!captured(readback)) return held('selected-readback-failed');
193
+ return { status: 0, stdout: text, stderr: '', error: null };
194
+ };
195
+
196
+ const wipeCaptures = (captures) => {
197
+ for (const result of captures) {
198
+ if (Buffer.isBuffer(result?.stdout)) result.stdout.fill(0);
199
+ if (Buffer.isBuffer(result?.stderr)) result.stderr.fill(0);
200
+ }
201
+ };
202
+
203
+ // The one recipient an `age -y` capture names, or null.
204
+ const recipientOf = (stdout) => {
205
+ const recipients = decoded(stdout).trim().split(/\r?\n/);
206
+ return recipients.length === 1 && /^age1[ac-hj-np-z02-9]+$/.test(recipients[0]) ? recipients[0] : null;
207
+ };
208
+
209
+ const toolProfileSupported = (version, ageVersion) => captured(version) && captured(ageVersion)
210
+ && SELECTED_SOPS_VERSIONS.has(/^sops\s+(\d+\.\d+\.\d+)/.exec(decoded(version.stdout))?.[1])
211
+ && SELECTED_AGE_VERSIONS.has(/^v?(\d+\.\d+\.\d+)/.exec(decoded(ageVersion.stdout))?.[1]);
212
+
213
+ const invalidCaptureBudget = (deadline, maxBuffer) => !Number.isInteger(deadline) || deadline < 1 || deadline > 2_147_483_647 || !Number.isInteger(maxBuffer) || maxBuffer < 1;
214
+
215
+ // The document bytes into `state.input`; the refusal when they are unavailable or over budget, else null.
216
+ const readSelectedInput = (state, request, spec, file) => {
217
+ try { state.input = request.operation === 'seal' ? Buffer.from(spec.plaintext) : readSecretBytes(file); } catch { return held('actual-document-unavailable'); }
218
+ return state.input.length > CREDENTIAL_FILE_MAX_BYTES ? held('document-over-budget') : null;
219
+ };
220
+
221
+ const selectedRunner = (invocation, options, captures, selectedEnv) => (program, args, bytes, childEnv = selectedEnv) => {
222
+ const result = invocation.runProgram(program, args, { ...options, env: childEnv, input: bytes });
223
+ captures.push(result); return result;
224
+ };
225
+
226
+ const performSelected = (state, { bin, request, spec, key, selection, invocation, env, cwd, maxBuffer, timeout }) => {
227
+ const file = path.resolve(cwd ?? process.cwd(), spec.file);
228
+ const unavailable = readSelectedInput(state, request, spec, file);
229
+ if (unavailable) return unavailable;
230
+ const exe = bin ?? resolveSops(env), age = invocation.resolveRealTool('age-keygen', { env });
231
+ if (!nativeFile(exe) || !nativeFile(age)) return held('native-tool-unavailable');
232
+ if (!auditAbsent(cwd)) return held('unsupported-audit-context');
233
+ const deadline = timeout ?? IDENTITY_TIMEOUT_MS;
234
+ if (invalidCaptureBudget(deadline, maxBuffer)) return held('invalid-capture-budget');
235
+ state.selectedEnv = isolatedSopsEnv(env, selection.inlineName);
236
+ const publicEnv = isolatedSopsEnv(env, null);
237
+ const options = { cwd, env: state.selectedEnv, shell: false, encoding: 'buffer', windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'], timeout: deadline, maxBuffer };
238
+ const run = selectedRunner(invocation, options, state.captures, state.selectedEnv);
239
+ const version = run(exe, ['--disable-version-check', '--version'], undefined, publicEnv);
240
+ const ageVersion = run(age, ['--version'], undefined, publicEnv);
241
+ if (!toolProfileSupported(version, ageVersion)) return held('unsupported-tool-profile');
242
+ state.identity = Buffer.from(key.trim(), 'utf8');
243
+ const derived = run(age, ['-y'], state.identity, publicEnv);
244
+ if (!captured(derived)) return held('recipient-unproven');
245
+ const recipient = recipientOf(derived.stdout);
246
+ if (recipient === null) return held('recipient-unproven');
247
+ if (request.operation === 'decrypt') return decryptSelected(run, exe, file, state.input, spec, recipient);
248
+ return encryptSelected(run, exe, file, state.input, spec, recipient);
249
+ };
250
+
144
251
  /** The domain composes the existing process owner: invocation={runProgram(file,args,options),resolveRealTool(program,{env})}. No API imports another system. */
145
252
  export function runSelectedSops(bin, request, { selection, invocation, env, cwd, maxBuffer, timeout } = {}) {
146
253
  if (selection?.error?.identityRefusal !== 'inline-context-unqualified' || typeof selection.inlineName !== 'string' || !env || typeof env !== 'object') return held('selected-context-missing');
147
254
  const spec = ownedRequest(request);
148
- if (!spec || !['binary', 'json', 'yaml', 'dotenv'].includes(spec.inputType) || !['binary', 'json', 'yaml', 'dotenv'].includes(spec.outputType)) return { status: null, stdout: '', stderr: '', error: selection.error };
149
- if (!invocation || typeof invocation.runProgram !== 'function' || typeof invocation.resolveRealTool !== 'function') return { status: null, stdout: '', stderr: '', error: selection.error };
150
- if (!['win32', 'linux', 'darwin'].includes(process.platform)) return held('unsupported-platform');
151
- const key = env[selection.inlineName];
152
- if (typeof key !== 'string' || !/^AGE-SECRET-KEY-1[0-9A-Z]+$/.test(key.trim())) return { status: null, stdout: '', stderr: '', error: selection.error };
153
- let input, identity, selectedEnv;
154
- const captures = [];
255
+ const admitted = selectedAdmission(selection, spec, invocation, env);
256
+ if (!admitted.key) return admitted;
257
+ const state = { input: undefined, identity: undefined, selectedEnv: undefined, captures: [] };
155
258
  try {
156
- const file = path.resolve(cwd ?? process.cwd(), spec.file);
157
- try { input = request.operation === 'seal' ? Buffer.from(spec.plaintext) : readSecretBytes(file); } catch { return held('actual-document-unavailable'); }
158
- if (input.length > CREDENTIAL_FILE_MAX_BYTES) return held('document-over-budget');
159
- const exe = bin ?? resolveSops(env), age = invocation.resolveRealTool('age-keygen', { env });
160
- if (!nativeFile(exe) || !nativeFile(age)) return held('native-tool-unavailable');
161
- if (!auditAbsent(cwd)) return held('unsupported-audit-context');
162
- const deadline = timeout ?? IDENTITY_TIMEOUT_MS;
163
- if (!Number.isInteger(deadline) || deadline < 1 || deadline > 2_147_483_647 || !Number.isInteger(maxBuffer) || maxBuffer < 1) return held('invalid-capture-budget');
164
- selectedEnv = isolatedSopsEnv(env, selection.inlineName);
165
- const publicEnv = isolatedSopsEnv(env, null);
166
- const options = { cwd, env: selectedEnv, shell: false, encoding: 'buffer', windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'], timeout: deadline, maxBuffer };
167
- const run = (program, args, bytes, childEnv = selectedEnv) => {
168
- const result = invocation.runProgram(program, args, { ...options, env: childEnv, input: bytes });
169
- captures.push(result); return result;
170
- };
171
- const version = run(exe, ['--disable-version-check', '--version'], undefined, publicEnv);
172
- const ageVersion = run(age, ['--version'], undefined, publicEnv);
173
- if (!captured(version) || !captured(ageVersion) || !SELECTED_SOPS_VERSIONS.has(/^sops\s+([0-9]+\.[0-9]+\.[0-9]+)/.exec(decoded(version.stdout))?.[1]) || !SELECTED_AGE_VERSIONS.has(/^v?([0-9]+\.[0-9]+\.[0-9]+)/.exec(decoded(ageVersion.stdout))?.[1])) return held('unsupported-tool-profile');
174
- identity = Buffer.from(key.trim(), 'utf8');
175
- const derived = run(age, ['-y'], identity, publicEnv);
176
- if (!captured(derived)) return held('recipient-unproven');
177
- const recipients = decoded(derived.stdout).trim().split(/\r?\n/);
178
- if (recipients.length !== 1 || !/^age1[ac-hj-np-z02-9]+$/.test(recipients[0])) return held('recipient-unproven');
179
- const recipient = recipients[0];
180
- const decryptArgs = (inputType, outputType) => ['decrypt', '--input-type', inputType, '--output-type', outputType, '--filename-override', file, '--enable-local-keyservice=true', '--decryption-order', 'age'];
181
- if (request.operation === 'decrypt') {
182
- const envelope = selectedAgeEnvelope(decoded(input), spec.inputType, recipient);
183
- if (!envelope.ok) return held(envelope.reason);
184
- const result = run(exe, decryptArgs(spec.inputType, spec.outputType), input);
185
- if (!captured(result)) return held('selected-decryption-failed');
186
- return { status: 0, stdout: decoded(result.stdout), stderr: '', error: null };
187
- }
188
- if (spec.recipient !== recipient) return held('recipient-mismatch');
189
- const args = ['encrypt', '--input-type', spec.inputType, '--output-type', spec.outputType, '--filename-override', file, '--enable-local-keyservice=true', '--age', recipient];
190
- const result = run(exe, args, input);
191
- if (!captured(result)) return held('selected-encryption-failed');
192
- if (result.stdout.length > CREDENTIAL_FILE_MAX_BYTES) return held('document-over-budget');
193
- const text = decoded(result.stdout);
194
- const envelope = selectedAgeEnvelope(text, spec.outputType, recipient);
195
- if (!envelope.ok) return held(envelope.reason);
196
- const readback = run(exe, decryptArgs(spec.outputType, spec.inputType), result.stdout);
197
- if (!captured(readback)) return held('selected-readback-failed');
198
- return { status: 0, stdout: text, stderr: '', error: null };
259
+ return performSelected(state, { bin, request, spec, key: admitted.key, selection, invocation, env, cwd, maxBuffer, timeout });
199
260
  } catch { return held('selected-native-refused'); }
200
261
  finally {
201
- identity?.fill(0); input?.fill(0);
202
- for (const result of captures) { if (Buffer.isBuffer(result?.stdout)) result.stdout.fill(0); if (Buffer.isBuffer(result?.stderr)) result.stderr.fill(0); }
203
- if (selectedEnv) delete selectedEnv.SOPS_AGE_KEY;
262
+ state.identity?.fill(0); state.input?.fill(0);
263
+ wipeCaptures(state.captures);
264
+ if (state.selectedEnv) delete state.selectedEnv.SOPS_AGE_KEY;
204
265
  }
205
266
  }
206
267
 
268
+ const identityRequestValid = ({ env, cwd, invocation, assertLease, consume }) =>
269
+ env && typeof env === 'object' && path.isAbsolute(cwd ?? '') && typeof consume === 'function'
270
+ && typeof assertLease === 'function' && typeof invocation?.runProgram === 'function'
271
+ && typeof invocation?.resolveRealTool === 'function';
272
+ const identityLinesValid = (lines, identities) => identities.length === 1 && /^AGE-SECRET-KEY-1[0-9A-Z]+$/.test(identities[0])
273
+ && !lines.some(line => line.startsWith('#') && !/^# (?:created:|public key:)/.test(line));
274
+ const declaredRecipientMatches = (lines, recipient) => {
275
+ const declared = lines.filter(line => line.startsWith('# public key:'));
276
+ return declared.length === 1 && declared[0].slice('# public key:'.length).trim() === recipient;
277
+ };
278
+ const publicationShaped = (publication) => publication && typeof publication.ok === 'boolean' && ['none', 'unknown', 'complete'].includes(publication.effectState);
279
+ const publicationDurable = (publication) => [true, false].includes(publication.created) && ['file-fsync', 'file-and-parent-fsync', 'file-fsync-namespace-unqualified'].includes(publication.durability);
280
+
281
+ const identityFailure = (reason, generated) => ({ ok: false, reason, captureState: generated ? 'unknown' : 'none', effectState: generated ? 'unknown' : 'none' });
282
+
283
+ // The outcome of handing the captured identity to the installer's publication.
284
+ const publicationOutcome = (publication, recipient) => {
285
+ if (!publicationShaped(publication)) return identityFailure('publication-unknown', true);
286
+ if (!publication.ok || publication.effectState !== 'complete') return { ok: false, captureState: 'generated', effectState: publication.effectState,
287
+ reason: 'publication-held' };
288
+ if (!publicationDurable(publication)) return identityFailure('publication-unknown', true);
289
+ return { ok: true, captureState: 'generated', effectState: 'complete', created: publication.created,
290
+ durability: publication.durability, publicRecipient: recipient };
291
+ };
292
+
293
+ // One age-keygen run under the caller's lease, its capture kept for wiping.
294
+ const leasedAgeRunner = (invocation, age, options, captures, assertLease) => (args, input) => {
295
+ if (assertLease() !== true) throw new Error('initial age lease lost');
296
+ const result = invocation.runProgram(age, args, { ...options, input });
297
+ captures.push(result);
298
+ if (!captured(result) || result.stdout.length > options.maxBuffer || result.stderr.length > options.maxBuffer) throw new Error('initial age capture incomplete');
299
+ return result;
300
+ };
301
+
302
+ const generateAndPublish = (state, { env, cwd, invocation, assertLease, consume }) => {
303
+ const age = invocation.resolveRealTool('age-keygen', { env });
304
+ if (!nativeFile(age)) return identityFailure('native-tool-unavailable', false);
305
+ const options = { cwd, env: isolatedSopsEnv(env, null), shell: false, encoding: 'buffer', windowsHide: true,
306
+ stdio: ['pipe', 'pipe', 'pipe'], timeout: IDENTITY_TIMEOUT_MS, maxBuffer: CREDENTIAL_FILE_MAX_BYTES };
307
+ const run = leasedAgeRunner(invocation, age, options, state.captures, assertLease);
308
+ const version = run(['--version']);
309
+ if (!SELECTED_AGE_VERSIONS.has(/^v?(\d+\.\d+\.\d+)/.exec(decoded(version.stdout))?.[1])) return identityFailure('unsupported-tool-profile', false);
310
+ if (assertLease() !== true) return identityFailure('lease-lost', false);
311
+ state.generated = true;
312
+ const made = run([]), lines = decoded(made.stdout).trim().split(/\r?\n/);
313
+ const identities = lines.filter(line => line && !line.startsWith('#'));
314
+ if (!identityLinesValid(lines, identities)) return identityFailure('capture-incomplete', true);
315
+ state.identity = Buffer.from(identities[0], 'utf8');
316
+ const recipient = recipientOf(run(['-y'], state.identity).stdout);
317
+ if (recipient === null) return identityFailure('recipient-unproven', true);
318
+ if (!declaredRecipientMatches(lines, recipient) || assertLease() !== true) return identityFailure('recipient-unproven', true);
319
+ return publicationOutcome(consume(state.identity, recipient), recipient);
320
+ };
321
+
207
322
  /** One real private capture for an admitted initial request; the installer owns its durable attempt. */
208
323
  export function withGeneratedAgeIdentity({ env, cwd, invocation, assertLease, consume } = {}) {
209
- const failure = (reason, generated) => ({ ok: false, reason, captureState: generated ? 'unknown' : 'none', effectState: generated ? 'unknown' : 'none' });
210
- if (!env || typeof env !== 'object' || !path.isAbsolute(cwd ?? '') || typeof consume !== 'function'
211
- || typeof assertLease !== 'function' || typeof invocation?.runProgram !== 'function'
212
- || typeof invocation?.resolveRealTool !== 'function') return failure('invalid-request', false);
213
- const captures = [];
214
- let generated = false, identity;
324
+ if (!identityRequestValid({ env, cwd, invocation, assertLease, consume })) return identityFailure('invalid-request', false);
325
+ const state = { captures: [], generated: false, identity: undefined };
215
326
  try {
216
- const age = invocation.resolveRealTool('age-keygen', { env });
217
- if (!nativeFile(age)) return failure('native-tool-unavailable', false);
218
- const options = { cwd, env: isolatedSopsEnv(env, null), shell: false, encoding: 'buffer', windowsHide: true,
219
- stdio: ['pipe', 'pipe', 'pipe'], timeout: IDENTITY_TIMEOUT_MS, maxBuffer: CREDENTIAL_FILE_MAX_BYTES };
220
- const run = (args, input) => {
221
- if (assertLease() !== true) throw new Error('initial age lease lost');
222
- const result = invocation.runProgram(age, args, { ...options, input });
223
- captures.push(result);
224
- if (!captured(result) || result.stdout.length > options.maxBuffer || result.stderr.length > options.maxBuffer) throw new Error('initial age capture incomplete');
225
- return result;
226
- };
227
- const version = run(['--version']);
228
- if (!SELECTED_AGE_VERSIONS.has(/^v?([0-9]+\.[0-9]+\.[0-9]+)/.exec(decoded(version.stdout))?.[1])) return failure('unsupported-tool-profile', false);
229
- if (assertLease() !== true) return failure('lease-lost', false);
230
- generated = true;
231
- const made = run([]), lines = decoded(made.stdout).trim().split(/\r?\n/);
232
- const identities = lines.filter(line => line && !line.startsWith('#'));
233
- if (identities.length !== 1 || !/^AGE-SECRET-KEY-1[0-9A-Z]+$/.test(identities[0])
234
- || lines.some(line => line.startsWith('#') && !/^# (?:created:|public key:)/.test(line))) return failure('capture-incomplete', true);
235
- identity = Buffer.from(identities[0], 'utf8');
236
- const derived = run(['-y'], identity), recipients = decoded(derived.stdout).trim().split(/\r?\n/);
237
- if (recipients.length !== 1 || !/^age1[ac-hj-np-z02-9]+$/.test(recipients[0])) return failure('recipient-unproven', true);
238
- const recipient = recipients[0], declared = lines.filter(line => line.startsWith('# public key:'));
239
- if (declared.length !== 1 || declared[0].slice('# public key:'.length).trim() !== recipient || assertLease() !== true) return failure('recipient-unproven', true);
240
- const publication = consume(identity, recipient);
241
- if (!publication || typeof publication.ok !== 'boolean' || !['none', 'unknown', 'complete'].includes(publication.effectState)) return failure('publication-unknown', true);
242
- if (!publication.ok || publication.effectState !== 'complete') return { ok: false, captureState: 'generated', effectState: publication.effectState,
243
- reason: 'publication-held' };
244
- if (![true, false].includes(publication.created) || !['file-fsync', 'file-and-parent-fsync', 'file-fsync-namespace-unqualified'].includes(publication.durability)) return failure('publication-unknown', true);
245
- return { ok: true, captureState: 'generated', effectState: 'complete', created: publication.created,
246
- durability: publication.durability, publicRecipient: recipient };
247
- } catch { return failure('capture-or-publication-unknown', generated); }
327
+ return generateAndPublish(state, { env, cwd, invocation, assertLease, consume });
328
+ } catch { return identityFailure('capture-or-publication-unknown', state.generated); }
248
329
  finally {
249
- identity?.fill(0);
250
- for (const result of captures) {
251
- if (Buffer.isBuffer(result?.stdout)) result.stdout.fill(0);
252
- if (Buffer.isBuffer(result?.stderr)) result.stderr.fill(0);
253
- }
330
+ state.identity?.fill(0);
331
+ wipeCaptures(state.captures);
254
332
  }
255
333
  }
@@ -36,9 +36,9 @@ export function slotAdmitsFile(resolver, file) {
36
36
  if (classified.status === 'no-slot' || classified.status === 'ambiguous' || !classified.slot) return null;
37
37
  const slot = resolver.slot(classified.slot);
38
38
  const relative = relativeToRoot(classified.path, classified.root);
39
- const hit = matchersOf([...(slot.requires ?? []), ...(slot.allows ?? [])], classified.bindings).find(matcher => matcher.expression.test(relative));
40
- const forbidden = matchersOf(slot.forbids, classified.bindings).find(matcher => matcher.expression.test(relative) || matcher.expression.test(`${relative}/`));
41
- return Boolean(hit) && !forbidden;
39
+ const hit = matchersOf([...(slot.requires ?? []), ...(slot.allows ?? [])], classified.bindings).some(matcher => matcher.expression.test(relative));
40
+ const forbidden = matchersOf(slot.forbids, classified.bindings).some(matcher => matcher.expression.test(relative) || matcher.expression.test(`${relative}/`));
41
+ return hit && !forbidden;
42
42
  }
43
43
 
44
44
  /**