@entrorove/skill 0.1.23 → 0.1.24

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 (155) hide show
  1. package/AGREEMENT.md +6 -6
  2. package/CHANGELOG.md +5 -0
  3. package/SKILL.md +12 -4
  4. package/archguard-runtime-build.mjs +15 -0
  5. package/blueprint-criterion-runner.mjs +110 -0
  6. package/blueprint-criterion-wire.mjs +78 -0
  7. package/calctool-runtime-build.mjs +59 -0
  8. package/calctool-runtime-entry.mjs +92 -0
  9. package/cli.mjs +17 -82
  10. package/local-approved-plan.mjs +74 -15
  11. package/local-archguard-ast.mjs +371 -0
  12. package/local-archguard-checkpoint.mjs +123 -0
  13. package/local-archguard-components.mjs +177 -0
  14. package/local-archguard-contract.mjs +109 -0
  15. package/local-archguard-contracts.mjs +42 -0
  16. package/local-archguard-evaluate.mjs +120 -0
  17. package/local-archguard-language-ast.mjs +165 -0
  18. package/local-archguard-mutation-fence.mjs +28 -0
  19. package/local-archguard-operations.mjs +25 -0
  20. package/local-archguard-scope.mjs +64 -0
  21. package/local-archguard-signing.mjs +52 -0
  22. package/local-archguard-snapshot.mjs +126 -0
  23. package/local-archguard-state.mjs +200 -0
  24. package/local-archguard.mjs +74 -0
  25. package/local-budget-git-attributes.mjs +49 -0
  26. package/local-budget-git-loader.mjs +61 -0
  27. package/local-budget-git-native-attributes.mjs +81 -0
  28. package/local-budget-git-native-darwin.mjs +104 -0
  29. package/local-budget-git-native-linux.mjs +93 -0
  30. package/local-budget-git-native-reads.mjs +88 -0
  31. package/local-budget-git-native-request.mjs +53 -0
  32. package/local-budget-git-native-runtime.mjs +18 -0
  33. package/local-budget-git-native.c +313 -0
  34. package/local-budget-git-native.mjs +109 -0
  35. package/local-budget-git-outcome.mjs +33 -0
  36. package/local-budget-git-scratch-loader.mjs +158 -0
  37. package/local-budget-git-scratch.mjs +112 -0
  38. package/local-budget-git-system-attributes.mjs +44 -0
  39. package/local-budget-git.mjs +92 -0
  40. package/local-budget-grants.mjs +82 -0
  41. package/local-budget-handoff-completion.mjs +70 -0
  42. package/local-budget-handoff-contracts.mjs +53 -0
  43. package/local-budget-handoff.mjs +229 -0
  44. package/local-budget-lock.mjs +55 -0
  45. package/local-budget-reads.mjs +135 -0
  46. package/local-budget-renewal.mjs +86 -34
  47. package/local-budget-source-bindings.mjs +51 -0
  48. package/local-budget-store.mjs +131 -0
  49. package/local-budget-transition.mjs +46 -0
  50. package/local-budget.mjs +185 -0
  51. package/local-calctool-cli.mjs +22 -0
  52. package/local-calctool-contracts.mjs +42 -0
  53. package/local-calctool-runtime.mjs +10 -0
  54. package/local-calctool-storage.mjs +101 -0
  55. package/local-calctool.mjs +103 -0
  56. package/local-cli.mjs +49 -16
  57. package/local-contract-types.mjs +46 -0
  58. package/local-contracts.mjs +124 -0
  59. package/local-credential.mjs +101 -0
  60. package/local-decimal.mjs +85 -0
  61. package/local-evidence-verification.mjs +199 -0
  62. package/local-execution.mjs +115 -304
  63. package/local-final-gate16-bundle.mjs +21 -0
  64. package/local-final-gate16-checks.mjs +88 -0
  65. package/local-final-gate16-contract.mjs +15 -0
  66. package/local-final-gate16-plan.mjs +66 -0
  67. package/local-final-gate16-proof.mjs +75 -0
  68. package/local-final-gate16-release.mjs +17 -0
  69. package/local-final-gate16-state.mjs +70 -0
  70. package/local-final-gate16-worker.mjs +19 -0
  71. package/local-final-gate16.mjs +137 -0
  72. package/local-formula-ast.mjs +97 -0
  73. package/local-formula-engine.mjs +111 -0
  74. package/local-formula-tests.mjs +61 -0
  75. package/local-formula.mjs +25 -125
  76. package/local-integrity.mjs +22 -41
  77. package/local-invocations.mjs +107 -0
  78. package/local-ledger.mjs +2 -7
  79. package/local-merge-archguard-verification.d.mts +44 -0
  80. package/local-merge-archguard-verification.mjs +240 -0
  81. package/local-merge-archguard.mjs +138 -0
  82. package/local-merge-artifacts.mjs +86 -0
  83. package/local-merge-authority.mjs +89 -0
  84. package/local-merge-contracts.mjs +56 -0
  85. package/local-merge-controller.mjs +170 -0
  86. package/local-merge-head.mjs +56 -0
  87. package/local-merge-journal.mjs +77 -0
  88. package/local-merge-mutation.mjs +97 -0
  89. package/local-merge-rollback.mjs +126 -0
  90. package/local-merge-snapshot-rollback.mjs +26 -0
  91. package/local-merge-storage.mjs +103 -0
  92. package/local-merge-targets.mjs +47 -0
  93. package/local-merge-transaction.mjs +133 -0
  94. package/local-mutation-audit.mjs +34 -0
  95. package/local-mutation-authority.mjs +96 -0
  96. package/local-mutation-batch-receipt.mjs +84 -0
  97. package/local-mutation-batch.mjs +143 -0
  98. package/local-mutation-binding.mjs +86 -0
  99. package/local-mutation-files.mjs +245 -0
  100. package/local-mutation-pass-store.mjs +63 -0
  101. package/local-mutation-snapshot.mjs +200 -0
  102. package/local-mutation-wire.mjs +89 -0
  103. package/local-probe.mjs +133 -11
  104. package/local-scope-candidate.mjs +28 -0
  105. package/local-scope-context.mjs +28 -0
  106. package/local-scope-files.mjs +106 -0
  107. package/local-scope-git-attributes.mjs +200 -0
  108. package/local-scope-git-comparison.mjs +8 -0
  109. package/local-scope-git-history.mjs +72 -0
  110. package/local-scope-git-reads.mjs +66 -0
  111. package/local-scope-git.mjs +124 -0
  112. package/local-scope-repository.mjs +249 -0
  113. package/local-scope-signature.mjs +70 -0
  114. package/local-scope-snapshot.mjs +39 -0
  115. package/local-scope-state.mjs +204 -0
  116. package/local-scope-workspaces.mjs +83 -0
  117. package/local-swarm-authority.mjs +143 -0
  118. package/local-swarm-baseline.mjs +32 -0
  119. package/local-swarm-completion.mjs +102 -0
  120. package/local-swarm-contracts.mjs +47 -0
  121. package/local-swarm-mutation.mjs +142 -0
  122. package/local-swarm-priority.mjs +237 -0
  123. package/local-swarm-state.mjs +102 -0
  124. package/local-swarm-storage.mjs +135 -0
  125. package/local-swarm.mjs +179 -0
  126. package/local-task-handoff-completion-wire.mjs +71 -0
  127. package/local-task-handoff-contracts.mjs +66 -0
  128. package/local-task-handoff-fence.mjs +478 -0
  129. package/local-task-handoff-observation.mjs +38 -0
  130. package/local-task-handoff-overlay-wire.mjs +94 -0
  131. package/local-task-handoff-read-authority.mjs +236 -0
  132. package/local-task-handoff-storage.mjs +73 -0
  133. package/local-task-handoff-write-archive.mjs +80 -0
  134. package/local-task-handoff-write-constraints.mjs +66 -0
  135. package/local-task-handoff-write-receipts.mjs +99 -0
  136. package/local-task-handoff-write-wire.mjs +99 -0
  137. package/local-task-routing.mjs +202 -22
  138. package/manifest.json +1 -1
  139. package/package.json +153 -3
  140. package/release-state.json +153 -17
  141. package/release.mjs +238 -4
  142. package/releases.json +6 -0
  143. package/remote-client.mjs +127 -36
  144. package/remote-transport.mjs +80 -0
  145. package/review-client.mjs +38 -0
  146. package/runtime/archguard-runtime-v7.0.44.mjs +314 -0
  147. package/runtime/calctool-runtime-v7.0.43.mjs +1887 -0
  148. package/runtime/git-native-darwin-v1.mjs +5 -0
  149. package/runtime/git-native-linux-v1.mjs +5 -0
  150. package/task-message-cli.mjs +47 -0
  151. package/task-message-entry.mjs +159 -0
  152. package/workflow-local-client.mjs +35 -0
  153. package/workflow-local-contracts.mjs +58 -0
  154. package/workflow-local-request.mjs +53 -0
  155. package/workflow-local-runner.mjs +350 -0
package/cli.mjs CHANGED
@@ -6,18 +6,19 @@ import os from 'node:os';
6
6
  import path from 'node:path';
7
7
  import { fileURLToPath } from 'node:url';
8
8
  import { compareVersions } from './version.mjs';
9
+ import { credentialPath, readCredential, writeCredential } from './local-credential.mjs';
9
10
  import { runLocal } from './local-cli.mjs';
11
+ import { runTaskMessageCli } from './task-message-cli.mjs';
10
12
  import { apiUrl, buildInvocation, invokeRemote, queryFlows, queryStatus } from './remote-client.mjs';
11
13
  const packageDir = path.dirname(fileURLToPath(import.meta.url));
12
- const reviewUrl = 'https://entrorove.com/api/ide-reviews';
14
+ import { buildReview, submitSkillFeedback, feedbackNoticeVersion } from './review-client.mjs';
13
15
  const markerName = '.entrorove-managed.json';
14
16
  const installFiles = ['SKILL.md', 'CAPABILITY-MAP.md', 'AGREEMENT.md', 'manifest.json'];
15
17
  const markerSchema = 'entrorove.managed-install/1.0';
16
- const credentialSchema = 'entrorove.credential/1.0';
17
18
  const agreementSchema = 'entrorove.agreement-acceptance/1.0';
18
19
  const installLockWaitMs = 10_000;
19
20
  const installLockStaleMs = 120_000;
20
- export const agreementVersion = '2026-10-02.1';
21
+ export const agreementVersion = feedbackNoticeVersion;
21
22
 
22
23
  function sha256(content) {
23
24
  return createHash('sha256').update(content).digest('hex');
@@ -25,7 +26,8 @@ function sha256(content) {
25
26
 
26
27
 
27
28
  async function readJson(file) {
28
- const value = JSON.parse(await fs.readFile(file, 'utf8'));
29
+ let value;
30
+ try { value = JSON.parse(await fs.readFile(file, 'utf8')); } catch { throw new Error('Invalid JSON object'); }
29
31
  if (!value || typeof value !== 'object' || Array.isArray(value)) {
30
32
  throw new Error(`Invalid JSON object: ${file}`);
31
33
  }
@@ -59,18 +61,6 @@ function globalInstallPath(home = os.homedir()) {
59
61
  return path.join(home, '.codex', 'skills', 'entrorove');
60
62
  }
61
63
 
62
- function credentialPath(home = os.homedir()) {
63
- if (process.platform === 'win32') {
64
- const appData = process.env.LOCALAPPDATA;
65
- if (!appData || !path.isAbsolute(appData)) throw new Error('LOCALAPPDATA is required');
66
- return path.join(appData, 'Entrorove', 'credential.json');
67
- }
68
- if (process.platform === 'darwin') {
69
- return path.join(home, 'Library', 'Application Support', 'Entrorove', 'credential.json');
70
- }
71
- return path.join(home, '.local', 'share', 'entrorove', 'credential.json');
72
- }
73
-
74
64
  function agreementPath(home = os.homedir()) {
75
65
  return path.join(path.dirname(credentialPath(home)), 'agreement.json');
76
66
  }
@@ -313,7 +303,8 @@ async function stdinJson() {
313
303
  raw += chunk;
314
304
  if (raw.length > 1_000_000) throw new Error('Input is too large');
315
305
  }
316
- const value = JSON.parse(raw);
306
+ let value;
307
+ try { value = JSON.parse(raw); } catch { throw new Error('Input must be valid JSON'); }
317
308
  if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Input must be a JSON object');
318
309
  return value;
319
310
  }
@@ -325,38 +316,6 @@ async function preparePrivateDirectory(parent) {
325
316
  if (process.platform !== 'win32') await fs.chmod(parent, 0o700);
326
317
  }
327
318
 
328
- async function writeCredential(token, home) {
329
- if (typeof token !== 'string' || !/^ent_[A-Za-z0-9_-]{43}$/.test(token)) {
330
- throw new Error('Invalid entrorove key');
331
- }
332
- const file = credentialPath(home);
333
- const parent = path.dirname(file);
334
- await preparePrivateDirectory(parent);
335
- const existing = await statOrNull(file);
336
- if (existing && (!existing.isFile() || existing.isSymbolicLink())) throw new Error('Credential path is not a regular file');
337
- const stage = path.join(parent, `.credential-${randomUUID()}.tmp`);
338
- const handle = await fs.open(stage, 'wx', 0o600);
339
- try {
340
- await handle.writeFile(JSON.stringify({ schemaVersion: credentialSchema, token }) + '\n');
341
- await handle.sync();
342
- } finally {
343
- await handle.close();
344
- }
345
- await fs.rename(stage, file);
346
- if (process.platform !== 'win32') await fs.chmod(file, 0o600);
347
- }
348
-
349
- async function readCredential(home) {
350
- const file = credentialPath(home);
351
- const stat = await fs.lstat(file);
352
- if (!stat.isFile() || stat.isSymbolicLink()) throw new Error('Credential path is not a regular file');
353
- if (process.platform !== 'win32' && (stat.mode & 0o077) !== 0) throw new Error('Credential permissions must be 0600');
354
- const data = await readJson(file);
355
- if (data.schemaVersion !== credentialSchema || typeof data.token !== 'string'
356
- || !/^ent_[A-Za-z0-9_-]{43}$/.test(data.token)) throw new Error('Invalid credential file');
357
- return data.token;
358
- }
359
-
360
319
  async function agreementAccepted(home) {
361
320
  const file = agreementPath(home);
362
321
  const stat = await statOrNull(file);
@@ -398,9 +357,9 @@ async function writeAgreementAcceptance(data, home) {
398
357
 
399
358
  export { buildInvocation };
400
359
 
401
- function remoteDependencies() {
360
+ export function remoteDependencies(home = os.homedir()) {
402
361
  return {
403
- install, agreementAccepted, agreementRequired, readCredential, home: os.homedir(),
362
+ install: () => install({ home }), agreementAccepted, agreementRequired, readCredential, home,
404
363
  apiOrigin: () => new URL(apiUrl).origin,
405
364
  };
406
365
  }
@@ -409,37 +368,11 @@ async function invoke(data) {
409
368
  return invokeRemote(data, remoteDependencies());
410
369
  }
411
370
 
412
- export function buildReview(data) {
413
- if (!data || typeof data !== 'object' || Array.isArray(data)
414
- || Object.keys(data).sort().join(',') !== 'comment,ide,publicConsent,rating'
415
- || typeof data.ide !== 'string' || !/^[\p{L}\p{N} .+_-]{2,48}$/u.test(data.ide.trim())
416
- || !Number.isInteger(data.rating) || data.rating < 1 || data.rating > 5
417
- || typeof data.comment !== 'string' || data.comment.trim().length < 4
418
- || data.comment.trim().length > 280 || data.publicConsent !== true) {
419
- throw new Error('Expected user-authored review with ide, rating 1-5, comment, and publicConsent:true');
420
- }
421
- return {
422
- url: reviewUrl,
423
- body: {
424
- ide: data.ide.trim(), rating: data.rating,
425
- comment: data.comment.trim(), publicConsent: true,
426
- },
427
- };
428
- }
371
+ export { buildReview };
429
372
 
430
373
  async function submitReview(data) {
431
- const review = buildReview(data);
432
- const token = await readCredential(os.homedir());
433
- const response = await fetch(review.url, {
434
- method: 'POST', redirect: 'error', signal: AbortSignal.timeout(15_000),
435
- headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
436
- body: JSON.stringify(review.body),
437
- });
438
- if (!response.ok) throw new Error(`entrorove review failed: HTTP ${response.status}`);
439
- const result = await response.json();
440
- if (result?.published !== true || typeof result.id !== 'string') {
441
- throw new Error('Invalid entrorove review response');
442
- }
374
+ const result = await submitSkillFeedback(data, remoteDependencies());
375
+ if (result.error === 'agreement-required') process.exitCode = 2;
443
376
  process.stdout.write(JSON.stringify(result) + '\n');
444
377
  }
445
378
 
@@ -458,6 +391,8 @@ async function main() {
458
391
  process.stdout.write(JSON.stringify({ accepted, version: agreementVersion }) + '\n');
459
392
  } else if (command === 'invoke' && args.length === 0) {
460
393
  await invoke(await stdinJson());
394
+ } else if (command === 'host-message') {
395
+ await runTaskMessageCli(args, await stdinJson(), remoteDependencies());
461
396
  } else if (command === 'review' && args.length === 0) {
462
397
  await submitReview(await stdinJson());
463
398
  } else if (command === 'status' && args.length === 1) {
@@ -479,12 +414,12 @@ async function main() {
479
414
  } else if (command === 'local') {
480
415
  await runLocal(args);
481
416
  } else {
482
- throw new Error('Usage: entrorove install | configure | agreement | invoke | status <requestId> | flows | review | local (JSON on stdin)');
417
+ throw new Error('Usage: entrorove install | configure | agreement | invoke | host-message [adapterModulePath] | status <requestId> | flows | review | local (JSON on stdin)');
483
418
  }
484
419
  }
485
420
  if (process.argv[1] && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))) {
486
421
  main().catch((error) => {
487
- process.stderr.write(`${error.message}\n`);
422
+ process.stderr.write(`${error instanceof SyntaxError ? 'Invalid JSON data' : error.message}\n`);
488
423
  process.exitCode = 1;
489
424
  });
490
425
  }
@@ -1,9 +1,11 @@
1
+ import { LOCAL_OPERATION_SCHEMAS } from './local-contracts.mjs';
1
2
  import { createHash, createPrivateKey, createPublicKey, randomUUID, sign } from 'node:crypto';
2
3
  import { spawn } from 'node:child_process';
3
4
  import { promises as fs, constants } from 'node:fs';
4
5
  import os from 'node:os';
5
6
  import path from 'node:path';
6
7
  import { performance } from 'node:perf_hooks';
8
+ import { assertBudgetRunnerAllowed } from './local-budget-reads.mjs';
7
9
 
8
10
  export const RUNNER_SCHEMA = 'entrorove.validator-local-runner/1.0';
9
11
  export const PLAN_SCHEMA = 'validator.execution-plan/1.0';
@@ -18,24 +20,37 @@ function canonical(value) { if (Array.isArray(value)) return `[${value.map(canon
18
20
  function safeRelative(value, label = 'path') { if (typeof value !== 'string' || !value || value !== value.normalize('NFC') || path.isAbsolute(value) || /^[A-Za-z]:/.test(value) || value.includes('\\') || /[\u0000-\u001f\u007f]/.test(value) || value.split('/').some(part => !part || part === '.' || part === '..')) fail('ENTROROVE_PATH_UNSAFE', `${label} must be a normalized relative path`); return value; }
19
21
  function inside(root, target, label) { const relative = path.relative(root, target); if (relative === '' || (!path.isAbsolute(relative) && relative !== '..' && !relative.startsWith(`..${path.sep}`))) return; fail('ENTROROVE_PATH_ESCAPE', `${label} escapes repositoryRoot`); }
20
22
  async function rootOf(value) { if (typeof value !== 'string' || !path.isAbsolute(value)) fail('ENTROROVE_ROOT_INVALID', 'repositoryRoot must be absolute'); const stat = await fs.lstat(value); if (!stat.isDirectory() || stat.isSymbolicLink()) fail('ENTROROVE_ROOT_INVALID', 'repositoryRoot must be a real directory'); return fs.realpath(value); }
21
- async function regular(file, max, privateFile = false) { const stat = await fs.lstat(file); if (!stat.isFile() || stat.isSymbolicLink() || stat.size > max) fail('ENTROROVE_FILE_INVALID', 'control file must be a bounded regular file'); if (privateFile && process.platform !== 'win32' && ((typeof process.getuid === 'function' && stat.uid !== process.getuid()) || (stat.mode & 0o777) !== 0o600 || stat.nlink !== 1)) fail('ENTROROVE_CONTROL_PERMISSIONS', 'control file must be owned and mode 0600'); return { bytes: await fs.readFile(file), sha256: digest(await fs.readFile(file)) }; }
23
+ async function regular(file, max, privateFile = false) { const stat = await fs.lstat(file); if (!stat.isFile() || stat.isSymbolicLink() || stat.size > max) fail('ENTROROVE_FILE_INVALID', 'control file must be a bounded regular file'); if (privateFile && process.platform !== 'win32' && ((typeof process.getuid === 'function' && stat.uid !== process.getuid()) || (stat.mode & 0o777) !== 0o600 || stat.nlink !== 1)) fail('ENTROROVE_CONTROL_PERMISSIONS', 'control file must be owned and mode 0600'); const bytes = await fs.readFile(file); return { bytes, sha256: digest(bytes) }; }
22
24
  async function projectFile(root, relative) { const safe = safeRelative(relative); const target = path.resolve(root, ...safe.split('/')); inside(root, target, safe); const stat = await fs.lstat(target); if (!stat.isFile() || stat.isSymbolicLink() || await fs.realpath(target) !== target) fail('ENTROROVE_FILE_INVALID', `${safe} must be a regular file`); return { path: safe, target, stat, ...(await regular(target, RUNNER_LIMITS.maxFileBytes)) }; }
23
25
  async function externalFile(root, value, label) { if (typeof value !== 'string' || !path.isAbsolute(value)) fail('ENTROROVE_CONTROL_PATH', `${label} must be an absolute path`); const entry = await fs.lstat(value); if (!entry.isFile() || entry.isSymbolicLink()) fail('ENTROROVE_CONTROL_FILE', `${label} must be a regular non-symlink file`); const target = await fs.realpath(value); const relative = path.relative(root, target); if (relative === '' || (!path.isAbsolute(relative) && relative !== '..' && !relative.startsWith(`..${path.sep}`))) fail('ENTROROVE_PATH_ESCAPE', `${label} must be outside repositoryRoot`); return { path: target, ...(await regular(target, RUNNER_LIMITS.maxControlBytes, true)) }; }
24
26
  function exactObject(value, fields, label) { if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).sort().join('|') !== [...fields].sort().join('|')) fail('ENTROROVE_PLAN_FIELDS', `${label} has invalid fields`); return value; }
25
27
  function boundedInt(value, max, label) { if (!Number.isSafeInteger(value) || value <= 0 || value > max) fail('ENTROROVE_LIMIT_INVALID', `${label} must be a positive bounded integer`); }
26
- function planArtifact(files) { return digest(canonical(files)); }
28
+ function planArtifact(files) { return validatorPlanArtifact(files); }
29
+ function validatorPlanArtifact(files) { return digest(`validator.file-manifest/1.0\n${files.map(file => `${file.path}\0${file.sha256}`).join('\n')}`); }
27
30
 
28
- function validatePlan(plan) {
29
- exactObject(plan, ['schemaVersion', 'frozen', 'planId', 'validationRunId', 'memberId', 'chainId', 'artifactSha256', 'files', 'tests', 'policy', 'goldenBaseline', 'contracts'], 'execution plan');
31
+ function validatePlan(plan, artifactDigest = planArtifact) {
32
+ exactObject(plan, ['schemaVersion', 'frozen', 'planId', 'validationRunId', 'memberId', 'chainId', 'artifactSha256', 'files', 'tests', 'policy', 'goldenBaseline', 'contracts', ...(plan?.evidencePolicy === undefined ? [] : ['evidencePolicy'])], 'execution plan');
30
33
  if (plan.schemaVersion !== PLAN_SCHEMA || plan.frozen !== true || !ID.test(plan.planId) || !ID.test(plan.validationRunId) || !ID.test(plan.memberId) || !ID.test(plan.chainId) || !SHA256.test(plan.artifactSha256) || !Array.isArray(plan.files) || plan.files.length === 0 || plan.files.length > RUNNER_LIMITS.maxFiles) fail('ENTROROVE_PLAN_INVALID', 'plan must be frozen and bounded');
31
34
  for (let i = 0; i < plan.files.length; i += 1) { const file = exactObject(plan.files[i], ['path', 'sha256'], 'manifest entry'); safeRelative(file.path); if (!SHA256.test(file.sha256) || (i > 0 && plan.files[i - 1].path >= file.path)) fail('ENTROROVE_PLAN_MANIFEST', 'manifest paths must be sorted and unique'); }
32
- if (planArtifact(plan.files) !== plan.artifactSha256) fail('ENTROROVE_PLAN_MANIFEST', 'artifact manifest digest mismatch');
35
+ if (artifactDigest(plan.files) !== plan.artifactSha256) fail('ENTROROVE_PLAN_MANIFEST', 'artifact manifest digest mismatch');
33
36
  const policy = exactObject(plan.policy, ['executable', 'executableSha256', 'args', 'environment', 'timeoutMs', 'maxOutputBytes', 'requiredExitCode'], 'execution policy');
34
37
  if (typeof policy.executable !== 'string' || !path.isAbsolute(policy.executable) || !SHA256.test(policy.executableSha256) || !Array.isArray(policy.args) || policy.args.length > 128 || policy.args.some(value => typeof value !== 'string' || value.includes('\0') || value.length > 4096)) fail('ENTROROVE_POLICY_INVALID', 'executable and args are invalid');
35
38
  if (!policy.environment || typeof policy.environment !== 'object' || Array.isArray(policy.environment) || Object.keys(policy.environment).length > 64 || Object.entries(policy.environment).some(([key, value]) => !ENV.test(key) || typeof value !== 'string' || value.includes('\0') || value.length > 16_384)) fail('ENTROROVE_POLICY_ENVIRONMENT', 'environment must be explicit bounded strings');
36
39
  boundedInt(policy.timeoutMs, RUNNER_LIMITS.maxTimeoutMs, 'timeoutMs'); boundedInt(policy.maxOutputBytes, RUNNER_LIMITS.maxOutputBytes, 'maxOutputBytes'); if (policy.requiredExitCode !== 0) fail('ENTROROVE_POLICY_EXIT', 'requiredExitCode must be zero');
37
40
  if (!Array.isArray(plan.tests) || plan.tests.length === 0 || plan.tests.length > RUNNER_LIMITS.maxFiles) fail('ENTROROVE_PLAN_TESTS', 'tests must be non-empty');
38
41
  for (const test of plan.tests) { exactObject(test, ['testId', 'path'], 'test'); if (!ID.test(test.testId) || !plan.files.some(file => file.path === test.path) || !policy.args.includes(test.path)) fail('ENTROROVE_PLAN_TESTS', 'each test must be in the manifest and explicit in args'); }
42
+ if (plan.evidencePolicy !== undefined) {
43
+ const evidencePolicy = exactObject(plan.evidencePolicy, ['kind', 'runner', 'flowId', 'stepId', ...(plan.evidencePolicy?.localVerification === undefined ? [] : ['localVerification'])], 'evidence policy');
44
+ if (!['test', 'build', 'lint', 'security', 'benchmark'].includes(evidencePolicy.kind)
45
+ || !['local', 'trusted-runner', 'sandbox'].includes(evidencePolicy.runner)
46
+ || !ID.test(String(evidencePolicy.flowId ?? '')) || !ID.test(String(evidencePolicy.stepId ?? ''))) fail('ENTROROVE_EVIDENCE_POLICY', 'evidence policy must bind kind, runner, flow and step');
47
+ if (evidencePolicy.localVerification !== undefined) {
48
+ const local = exactObject(evidencePolicy.localVerification, ['operation', 'requestedTarget', 'requirementsSha256'], 'local verification policy');
49
+ if (!['sandbox-run', 'fuzz-input', 'perf-benchmark', 'intrusive-test'].includes(local.operation)
50
+ || typeof local.requestedTarget !== 'string' || !local.requestedTarget.trim() || local.requestedTarget.length > 2000
51
+ || !SHA256.test(String(local.requirementsSha256 ?? ''))) fail('ENTROROVE_EVIDENCE_POLICY', 'local verification requires its exact operation, target and requirements digest');
52
+ }
53
+ }
39
54
  if (plan.contracts?.archguard?.driftStatus === 'red') fail('ENTROROVE_ARCHGUARD_BLOCKED', 'ArchGuard red blocks execution'); return plan;
40
55
  }
41
56
 
@@ -52,8 +67,10 @@ async function signer(root, input, policy) {
52
67
  }
53
68
 
54
69
  async function authority(input) {
70
+ await assertBudgetRunnerAllowed(input);
55
71
  if (process.platform === 'win32' || typeof process.getuid !== 'function') fail('ENTROROVE_POSIX_REQUIRED', 'approved plan runner requires POSIX');
56
- exactObject(input, ['repositoryRoot', 'planPath', 'approvalPath', 'signerConfigPath'], 'runner input'); const root = await rootOf(input.repositoryRoot); const planFile = await projectFile(root, input.planPath); if (planFile.bytes.length > RUNNER_LIMITS.maxControlBytes) fail('ENTROROVE_PLAN_SIZE', 'plan exceeds control limit');
72
+ const fields = ['repositoryRoot', 'planPath', 'approvalPath', 'signerConfigPath'];
73
+ exactObject(input, [...fields, ...['home', 'flowId'].filter(field => input[field] !== undefined)], 'runner input'); const root = await rootOf(input.repositoryRoot); const planFile = await projectFile(root, input.planPath); if (planFile.bytes.length > RUNNER_LIMITS.maxControlBytes) fail('ENTROROVE_PLAN_SIZE', 'plan exceeds control limit');
57
74
  let plan; try { plan = validatePlan(JSON.parse(planFile.bytes.toString('utf8'))); } catch (cause) { throw cause; } const approval = await validateApproval(root, input, planFile.sha256); const signing = await signer(root, input, plan.policy); const executable = await regular(plan.policy.executable, RUNNER_LIMITS.maxExecutableBytes); if (executable.sha256 !== plan.policy.executableSha256) fail('ENTROROVE_EXECUTABLE_HASH', 'executable SHA-256 mismatch');
58
75
  for (const file of plan.files) { const actual = await projectFile(root, file.path); if (actual.sha256 !== file.sha256) fail('ENTROROVE_PLAN_FILE_HASH', `${file.path} changed before execution`); }
59
76
  return { root, plan, planFile, approval, signing };
@@ -61,21 +78,63 @@ async function authority(input) {
61
78
 
62
79
  function runCommand(authority) {
63
80
  return new Promise((resolve, reject) => {
64
- const started = performance.now(); const { policy } = authority.plan; const child = spawn(policy.executable, policy.args, { cwd: authority.root, env: { ...policy.environment }, shell: false, detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
65
- const stdout = []; const stderr = []; let bytes = 0; let dropped = 0; let timedOut = false; let terminating = false; let timer; const stdoutHash = createHash('sha256'); const stderrHash = createHash('sha256');
66
- const terminate = () => { if (terminating) return; terminating = true; try { process.kill(-child.pid, 'SIGTERM'); } catch {} setTimeout(() => { try { process.kill(-child.pid, 'SIGKILL'); } catch {} }, 250).unref(); };
81
+ const started = performance.now(); const { policy } = authority.plan; const remaining = authority.executionDeadlineAt ? Date.parse(authority.executionDeadlineAt) - Date.now() : policy.timeoutMs;
82
+ if (remaining <= 0) { reject(Object.assign(new Error('workflow execution lease expired before process launch'), { code: 'ENTROROVE_WORKFLOW_EXPIRED' })); return; }
83
+ const child = spawn(policy.executable, policy.args, { cwd: authority.root, env: { ...policy.environment }, shell: false, detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
84
+ const stdout = []; const stderr = []; let bytes = 0; let dropped = 0; let timedOut = false; let terminating = false; let spawned = false; let processStartedAt = null; let spawnError = null; let timer; let killTimer; const stdoutHash = createHash('sha256'); const stderrHash = createHash('sha256');
85
+ const terminate = () => { if (terminating) return; terminating = true; try { process.kill(-child.pid, 'SIGTERM'); } catch {} killTimer = setTimeout(() => { try { process.kill(-child.pid, 'SIGKILL'); } catch {} }, 250); killTimer.unref(); };
67
86
  const consume = (target, hash, chunk) => { const data = Buffer.from(chunk); hash.update(data); const keep = Math.max(0, Math.min(data.length, policy.maxOutputBytes - bytes)); if (keep) target.push(data.subarray(0, keep)); bytes += keep; dropped += data.length - keep; if (dropped) terminate(); };
68
- child.stdout.on('data', chunk => consume(stdout, stdoutHash, chunk)); child.stderr.on('data', chunk => consume(stderr, stderrHash, chunk)); child.once('error', reject); timer = setTimeout(() => { timedOut = true; terminate(); }, policy.timeoutMs);
69
- child.once('close', (exitCode, signal) => { clearTimeout(timer); resolve({ schemaVersion: RUNNER_SCHEMA, status: exitCode === 0 && signal === null && !timedOut && !dropped ? 'succeeded' : 'failed', exitCode, signal, timedOut, outputLimitExceeded: dropped > 0, durationMs: Math.max(0, Math.round(performance.now() - started)), stdout: Buffer.concat(stdout).toString('utf8'), stderr: Buffer.concat(stderr).toString('utf8'), stdoutSha256: stdoutHash.digest('hex'), stderrSha256: stderrHash.digest('hex'), capturedBytes: bytes, droppedBytes: dropped }); });
87
+ child.stdout.on('data', chunk => consume(stdout, stdoutHash, chunk)); child.stderr.on('data', chunk => consume(stderr, stderrHash, chunk));
88
+ child.once('spawn', () => { spawned = true; processStartedAt = new Date().toISOString(); }); child.once('error', error => { spawnError = error; terminate(); }); timer = setTimeout(() => { timedOut = true; terminate(); }, Math.min(policy.timeoutMs, remaining));
89
+ child.once('close', (exitCode, signal) => {
90
+ clearTimeout(timer); clearTimeout(killTimer);
91
+ const processResult = { schemaVersion: RUNNER_SCHEMA, status: exitCode === 0 && signal === null && !timedOut && !dropped && !spawnError ? 'succeeded' : 'failed', exitCode, signal, timedOut, outputLimitExceeded: dropped > 0, durationMs: Math.max(0, Math.round(performance.now() - started)), stdout: Buffer.concat(stdout).toString('utf8'), stderr: Buffer.concat(stderr).toString('utf8'), stdoutSha256: stdoutHash.digest('hex'), stderrSha256: stderrHash.digest('hex'), capturedBytes: bytes, droppedBytes: dropped,
92
+ ...(authority.objectPlan ? { started: spawned, startedAt: processStartedAt, finishedAt: new Date().toISOString(), executionDeadlineExceeded: timedOut && remaining <= policy.timeoutMs } : {}) };
93
+ if (spawnError) { spawnError.execution = { schemaVersion: RUNNER_SCHEMA, status: 'failed', process: processResult, evidence: [], effects: spawned ? 'unknown' : 'none' }; reject(spawnError); return; }
94
+ resolve(processResult);
95
+ });
70
96
  });
71
97
  }
72
- function subject(authority, result) { return { schemaVersion: 'validator.validation-subject/1.0', artifactSha256: authority.plan.artifactSha256, memberId: authority.plan.memberId, chainId: authority.plan.chainId, executedAt: new Date().toISOString(), files: authority.plan.files, validationRunId: authority.plan.validationRunId, planId: authority.plan.planId, tests: authority.plan.tests, policy: { command: [authority.plan.policy.executable, ...authority.plan.policy.args].map(value => JSON.stringify(value)).join(' '), requiredExitCode: 0, executionPlanSha256: authority.planFile.sha256 }, goldenBaseline: authority.plan.goldenBaseline, contracts: authority.plan.contracts, execution: { exitCode: result.exitCode, signal: result.signal, timedOut: result.timedOut, outputLimitExceeded: result.outputLimitExceeded } }; }
73
- function evidence(authority, result, sub) { const summary = result.timedOut ? 'Approved validation command exceeded its deadline' : result.outputLimitExceeded ? 'Approved validation command exceeded its output limit' : result.signal ? `Approved validation command ended on signal ${result.signal}` : `Approved validation command exited with code ${result.exitCode}`; const base = { schemaVersion: 'entrorove.test-evidence/2.0', evidenceId: `${authority.plan.validationRunId}:process`, kind: 'test', runner: authority.signing ? 'trusted-runner' : 'local', command: [authority.plan.policy.executable, ...authority.plan.policy.args].map(value => JSON.stringify(value)).join(' '), exitCode: result.exitCode, durationMs: result.durationMs, summary, artifactSha256: authority.plan.artifactSha256, subject: sub, subjectDigest: digest(canonical(sub)) };
74
- if (!authority.signing || !Number.isInteger(result.exitCode)) return base; const privateKey = authority.signing.privateKey; const receiptCore = { schemaVersion: RECEIPT_SCHEMA, keyId: authority.signing.config.keyId, subjectDigest: base.subjectDigest, nonce: randomUUID(), issuedAt: new Date().toISOString(), expiresAt: new Date(Date.now() + authority.signing.config.receiptTtlMs).toISOString(), result: { runner: 'trusted-runner', passed: result.status === 'succeeded', exitCode: result.exitCode, durationMs: result.durationMs, summary } }; const receipt = { ...receiptCore, signature: sign(null, Buffer.from(canonical(receiptCore)), privateKey).toString('base64url') }; return { ...base, receipt };
98
+ function subject(authority, result) { return { schemaVersion: 'validator.validation-subject/1.0', artifactSha256: authority.plan.artifactSha256, memberId: authority.plan.memberId, chainId: authority.plan.chainId, executedAt: new Date().toISOString(), files: authority.plan.files, validationRunId: authority.plan.validationRunId, planId: authority.plan.planId, tests: authority.plan.tests, policy: { command: [authority.plan.policy.executable, ...authority.plan.policy.args].map(value => JSON.stringify(value)).join(' '), requiredExitCode: 0, executionPlanSha256: authority.planFile.sha256, ...(authority.plan.evidencePolicy ? { evidenceKind: authority.plan.evidencePolicy.kind, evidenceRunner: authority.plan.evidencePolicy.runner, workflowBinding: { flowId: authority.plan.evidencePolicy.flowId, stepId: authority.plan.evidencePolicy.stepId }, ...(authority.plan.evidencePolicy.localVerification ? { localVerification: authority.plan.evidencePolicy.localVerification } : {}) } : {}) }, goldenBaseline: authority.plan.goldenBaseline, contracts: authority.plan.contracts, execution: { exitCode: result.exitCode, signal: result.signal, timedOut: result.timedOut, outputLimitExceeded: result.outputLimitExceeded } }; }
99
+ function evidence(authority, result, sub) { const summary = result.timedOut ? 'Approved validation command exceeded its deadline' : result.outputLimitExceeded ? 'Approved validation command exceeded its output limit' : result.signal ? `Approved validation command ended on signal ${result.signal}` : `Approved validation command exited with code ${result.exitCode}`; const base = { schemaVersion: 'entrorove.test-evidence/2.0', evidenceId: `${authority.plan.validationRunId}:process`, kind: authority.plan.evidencePolicy?.kind ?? 'test', runner: authority.signing ? authority.plan.evidencePolicy?.runner ?? 'trusted-runner' : 'local', ...(authority.plan.evidencePolicy ? { flowId: authority.plan.evidencePolicy.flowId, stepId: authority.plan.evidencePolicy.stepId } : {}), command: [authority.plan.policy.executable, ...authority.plan.policy.args].map(value => JSON.stringify(value)).join(' '), exitCode: result.exitCode === null ? 1 : result.exitCode, durationMs: result.durationMs, summary, artifactSha256: authority.plan.artifactSha256, subject: sub, subjectDigest: digest(canonical(sub)) };
100
+ if (!authority.signing || !Number.isInteger(base.exitCode)) return base; const privateKey = authority.signing.privateKey; const receiptCore = { schemaVersion: RECEIPT_SCHEMA, keyId: authority.signing.config.keyId, subjectDigest: base.subjectDigest, nonce: randomUUID(), issuedAt: new Date().toISOString(), expiresAt: new Date(Date.now() + authority.signing.config.receiptTtlMs).toISOString(), result: { runner: 'trusted-runner', passed: result.status === 'succeeded', exitCode: result.exitCode === null ? 1 : result.exitCode, durationMs: result.durationMs, summary } }; const receipt = { ...receiptCore, signature: sign(null, Buffer.from(canonical(receiptCore)), privateKey).toString('base64url') }; return { ...base, receipt };
75
101
  }
76
102
 
77
103
  export async function runApprovedPlan(input) { const owner = await authority(input); const result = await runCommand(owner); try { const after = await authority(input); if (after.planFile.sha256 !== owner.planFile.sha256) fail('ENTROROVE_PLAN_CHANGED', 'plan changed during execution'); } catch (cause) { const execution = { schemaVersion: RUNNER_SCHEMA, status: 'failed', error: cause.message, process: result, evidence: [] }; const error = new Error(cause.message); error.code = cause.code; error.execution = execution; throw error; }
78
104
  const sub = subject(owner, result); const item = evidence(owner, result, sub); const status = !owner.signing && result.status === 'succeeded' ? 'incomplete' : result.status; return { schemaVersion: RUNNER_SCHEMA, status, planSha256: owner.planFile.sha256, approvalSha256: owner.approval.sha256, approvedBy: owner.approval.approval.approvedBy, subject: sub, process: result, evidence: [item], receipt: item.receipt ?? null, ...(owner.signing ? { receiptPublicKey: createPublicKey(owner.signing.privateKey).export({ format: 'der', type: 'spki' }).toString('base64') } : {}), signingBoundary: owner.signing ? 'configured-signature-only' : 'local-evidence-only' };
79
105
  }
80
106
 
81
- export const RUNNER_CAPABILITIES = Object.freeze({ schemaVersion: RUNNER_SCHEMA, operations: ['capabilities', 'run-approved-plan'], operationSchemas: { capabilities: { type: 'object', additionalProperties: false, required: [] }, 'run-approved-plan': { type: 'object', additionalProperties: false, required: ['repositoryRoot', 'planPath', 'approvalPath', 'signerConfigPath'] } }, limits: RUNNER_LIMITS, boundary: 'POSIX subprocess runner; external approval is required; local evidence remains incomplete without a configured signer.' });
107
+ async function objectAuthority(input, afterExecution = false) {
108
+ await assertBudgetRunnerAllowed(input);
109
+ if (process.platform === 'win32' || typeof process.getuid !== 'function') fail('ENTROROVE_POSIX_REQUIRED', 'approved plan runner requires POSIX');
110
+ const fields = ['repositoryRoot', 'executionPlan', 'approvalPath', 'signerConfigPath', ...['home', 'flowId'].filter(field => input[field] !== undefined)];
111
+ exactObject(input, input?.executionDeadlineAt === undefined ? fields : [...fields, 'executionDeadlineAt'], 'object runner input');
112
+ const root = await rootOf(input.repositoryRoot); const bytes = Buffer.from(canonical(input.executionPlan) + '\n');
113
+ if (bytes.length > RUNNER_LIMITS.maxControlBytes) fail('ENTROROVE_PLAN_SIZE', 'plan exceeds control limit');
114
+ let parsed; try { parsed = JSON.parse(bytes.toString('utf8')); } catch { fail('ENTROROVE_PLAN_INVALID', 'execution plan must contain JSON values'); }
115
+ const plan = validatePlan(parsed, validatorPlanArtifact); const planFile = { bytes, sha256: digest(bytes) };
116
+ const approval = await validateApproval(root, input, planFile.sha256); const signing = await signer(root, input, plan.policy);
117
+ const executable = await regular(plan.policy.executable, RUNNER_LIMITS.maxExecutableBytes);
118
+ if (executable.sha256 !== plan.policy.executableSha256) fail('ENTROROVE_EXECUTABLE_HASH', 'executable SHA-256 mismatch');
119
+ for (const file of plan.files) { const actual = await projectFile(root, file.path); if (actual.sha256 !== file.sha256) fail('ENTROROVE_PLAN_FILE_HASH', `${file.path} changed before execution`); }
120
+ if (input.executionDeadlineAt !== undefined && (typeof input.executionDeadlineAt !== 'string' || !Number.isFinite(Date.parse(input.executionDeadlineAt))
121
+ || (!afterExecution && Date.parse(input.executionDeadlineAt) <= Date.now()))) fail('ENTROROVE_WORKFLOW_EXPIRED', 'workflow execution deadline is invalid or expired');
122
+ return { root, plan, planFile, approval, signing, objectPlan: true, ...(input.executionDeadlineAt === undefined ? {} : { executionDeadlineAt: input.executionDeadlineAt }) };
123
+ }
124
+
125
+ export async function runApprovedPlanObject(input) {
126
+ const owner = await objectAuthority(input); const result = await runCommand(owner);
127
+ try { const after = await objectAuthority(input, true); if (after.planFile.sha256 !== owner.planFile.sha256) fail('ENTROROVE_PLAN_CHANGED', 'plan changed during execution'); }
128
+ catch (cause) {
129
+ const error = new Error(cause.message); error.code = cause.code;
130
+ error.execution = { schemaVersion: RUNNER_SCHEMA, status: 'failed', error: cause.message, process: result, evidence: [], effects: 'unknown' }; throw error;
131
+ }
132
+ const sub = subject(owner, result); const item = evidence(owner, result, sub);
133
+ return { schemaVersion: RUNNER_SCHEMA, status: !owner.signing && result.status === 'succeeded' ? 'incomplete' : result.status,
134
+ planSha256: owner.planFile.sha256, approvalSha256: owner.approval.sha256, approvedBy: owner.approval.approval.approvedBy,
135
+ subject: sub, process: result, evidence: [item], receipt: item.receipt ?? null,
136
+ ...(owner.signing ? { receiptPublicKey: createPublicKey(owner.signing.privateKey).export({ format: 'der', type: 'spki' }).toString('base64') } : {}),
137
+ signingBoundary: owner.signing ? 'configured-signature-only' : 'local-evidence-only' };
138
+ }
139
+
140
+ export const RUNNER_CAPABILITIES = Object.freeze({ schemaVersion: RUNNER_SCHEMA, operations: ['capabilities', 'run-approved-plan'], operationSchemas: { capabilities: LOCAL_OPERATION_SCHEMAS.capabilities, 'run-approved-plan': LOCAL_OPERATION_SCHEMAS['run-approved-plan'] }, limits: RUNNER_LIMITS, boundary: 'POSIX subprocess runner; external approval is required; local evidence remains incomplete without a configured signer.' });