@entrorove/skill 0.1.23 → 0.1.25
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGREEMENT.md +6 -6
- package/CAPABILITY-MAP.md +6 -0
- package/CHANGELOG.md +10 -0
- package/SKILL.md +18 -4
- package/archguard-runtime-build.mjs +15 -0
- package/blueprint-criterion-runner.mjs +110 -0
- package/blueprint-criterion-wire.mjs +78 -0
- package/calctool-runtime-build.mjs +59 -0
- package/calctool-runtime-entry.mjs +92 -0
- package/cli.mjs +35 -83
- package/local-approved-plan.mjs +74 -15
- package/local-archguard-ast.mjs +371 -0
- package/local-archguard-checkpoint.mjs +123 -0
- package/local-archguard-components.mjs +177 -0
- package/local-archguard-contract.mjs +109 -0
- package/local-archguard-contracts.mjs +42 -0
- package/local-archguard-evaluate.mjs +120 -0
- package/local-archguard-language-ast.mjs +165 -0
- package/local-archguard-mutation-fence.mjs +28 -0
- package/local-archguard-operations.mjs +25 -0
- package/local-archguard-scope.mjs +64 -0
- package/local-archguard-signing.mjs +52 -0
- package/local-archguard-snapshot.mjs +126 -0
- package/local-archguard-state.mjs +200 -0
- package/local-archguard.mjs +74 -0
- package/local-budget-git-attributes.mjs +49 -0
- package/local-budget-git-loader.mjs +61 -0
- package/local-budget-git-native-attributes.mjs +81 -0
- package/local-budget-git-native-darwin.mjs +104 -0
- package/local-budget-git-native-linux.mjs +93 -0
- package/local-budget-git-native-reads.mjs +88 -0
- package/local-budget-git-native-request.mjs +53 -0
- package/local-budget-git-native-runtime.mjs +18 -0
- package/local-budget-git-native.c +313 -0
- package/local-budget-git-native.mjs +109 -0
- package/local-budget-git-outcome.mjs +33 -0
- package/local-budget-git-scratch-loader.mjs +158 -0
- package/local-budget-git-scratch.mjs +112 -0
- package/local-budget-git-system-attributes.mjs +44 -0
- package/local-budget-git.mjs +92 -0
- package/local-budget-grants.mjs +82 -0
- package/local-budget-handoff-completion.mjs +70 -0
- package/local-budget-handoff-contracts.mjs +53 -0
- package/local-budget-handoff.mjs +229 -0
- package/local-budget-lock.mjs +55 -0
- package/local-budget-reads.mjs +135 -0
- package/local-budget-renewal.mjs +86 -34
- package/local-budget-source-bindings.mjs +51 -0
- package/local-budget-store.mjs +131 -0
- package/local-budget-transition.mjs +46 -0
- package/local-budget.mjs +185 -0
- package/local-calctool-cli.mjs +24 -0
- package/local-calctool-contracts.mjs +42 -0
- package/local-calctool-runtime.mjs +10 -0
- package/local-calctool-storage.mjs +101 -0
- package/local-calctool.mjs +103 -0
- package/local-chain-bindings.mjs +93 -0
- package/local-chain-broker.mjs +61 -0
- package/local-chain-contracts.mjs +21 -0
- package/local-chain-controller.mjs +321 -0
- package/local-chain-human.mjs +48 -0
- package/local-chain-lock.mjs +54 -0
- package/local-chain-operations.mjs +7 -0
- package/local-chain-plan.mjs +232 -0
- package/local-chain-release-labels.mjs +12 -0
- package/local-chain-results.mjs +75 -0
- package/local-cli.mjs +65 -24
- package/local-contract-types.mjs +46 -0
- package/local-contracts.mjs +126 -0
- package/local-credential.mjs +101 -0
- package/local-decimal.mjs +85 -0
- package/local-error.mjs +23 -0
- package/local-evidence-verification.mjs +199 -0
- package/local-execution.mjs +115 -304
- package/local-final-gate16-bundle.mjs +21 -0
- package/local-final-gate16-checks.mjs +88 -0
- package/local-final-gate16-contract.mjs +15 -0
- package/local-final-gate16-plan.mjs +66 -0
- package/local-final-gate16-proof.mjs +75 -0
- package/local-final-gate16-release.mjs +17 -0
- package/local-final-gate16-state.mjs +70 -0
- package/local-final-gate16-worker.mjs +19 -0
- package/local-final-gate16.mjs +137 -0
- package/local-formula-ast.mjs +106 -0
- package/local-formula-engine.mjs +125 -0
- package/local-formula-tests.mjs +61 -0
- package/local-formula.mjs +25 -125
- package/local-integrity.mjs +22 -41
- package/local-invocations.mjs +142 -0
- package/local-ledger.mjs +15 -9
- package/local-merge-archguard-verification.d.mts +44 -0
- package/local-merge-archguard-verification.mjs +240 -0
- package/local-merge-archguard.mjs +138 -0
- package/local-merge-artifacts.mjs +86 -0
- package/local-merge-authority.mjs +89 -0
- package/local-merge-contracts.mjs +56 -0
- package/local-merge-controller.mjs +170 -0
- package/local-merge-head.mjs +56 -0
- package/local-merge-journal.mjs +77 -0
- package/local-merge-mutation.mjs +97 -0
- package/local-merge-rollback.mjs +126 -0
- package/local-merge-snapshot-rollback.mjs +26 -0
- package/local-merge-storage.mjs +103 -0
- package/local-merge-targets.mjs +47 -0
- package/local-merge-transaction.mjs +133 -0
- package/local-mutation-audit.mjs +34 -0
- package/local-mutation-authority.mjs +103 -0
- package/local-mutation-batch-receipt.mjs +84 -0
- package/local-mutation-batch.mjs +143 -0
- package/local-mutation-binding.mjs +89 -0
- package/local-mutation-files.mjs +270 -0
- package/local-mutation-pass-store.mjs +63 -0
- package/local-mutation-snapshot.mjs +200 -0
- package/local-mutation-wire.mjs +89 -0
- package/local-probe.mjs +133 -11
- package/local-scope-candidate.mjs +28 -0
- package/local-scope-context.mjs +28 -0
- package/local-scope-files.mjs +106 -0
- package/local-scope-git-attributes.mjs +200 -0
- package/local-scope-git-comparison.mjs +8 -0
- package/local-scope-git-history.mjs +72 -0
- package/local-scope-git-reads.mjs +66 -0
- package/local-scope-git.mjs +124 -0
- package/local-scope-repository.mjs +249 -0
- package/local-scope-signature.mjs +70 -0
- package/local-scope-snapshot.mjs +39 -0
- package/local-scope-state.mjs +204 -0
- package/local-scope-workspaces.mjs +83 -0
- package/local-swarm-authority.mjs +143 -0
- package/local-swarm-baseline.mjs +32 -0
- package/local-swarm-completion.mjs +102 -0
- package/local-swarm-contracts.mjs +47 -0
- package/local-swarm-mutation.mjs +142 -0
- package/local-swarm-priority.mjs +237 -0
- package/local-swarm-state.mjs +102 -0
- package/local-swarm-storage.mjs +135 -0
- package/local-swarm.mjs +179 -0
- package/local-task-handoff-completion-wire.mjs +71 -0
- package/local-task-handoff-contracts.mjs +66 -0
- package/local-task-handoff-fence.mjs +478 -0
- package/local-task-handoff-observation.mjs +41 -0
- package/local-task-handoff-overlay-wire.mjs +94 -0
- package/local-task-handoff-read-authority.mjs +236 -0
- package/local-task-handoff-storage.mjs +73 -0
- package/local-task-handoff-write-archive.mjs +80 -0
- package/local-task-handoff-write-constraints.mjs +66 -0
- package/local-task-handoff-write-receipts.mjs +99 -0
- package/local-task-handoff-write-wire.mjs +99 -0
- package/local-task-routing.mjs +202 -22
- package/manifest.json +1 -1
- package/package.json +164 -3
- package/release-state.json +164 -18
- package/release.mjs +242 -4
- package/releases.json +12 -0
- package/remote-client.mjs +127 -36
- package/remote-transport.mjs +80 -0
- package/review-client.mjs +38 -0
- package/runtime/archguard-runtime-v7.0.44.mjs +314 -0
- package/runtime/calctool-runtime-v7.0.43.mjs +1910 -0
- package/runtime/git-native-darwin-v1.mjs +5 -0
- package/runtime/git-native-linux-v1.mjs +5 -0
- package/task-message-cli.mjs +47 -0
- package/task-message-entry.mjs +159 -0
- package/workflow-local-client.mjs +35 -0
- package/workflow-local-contracts.mjs +58 -0
- package/workflow-local-request.mjs +53 -0
- package/workflow-local-runner.mjs +350 -0
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { promises as fs } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { promisify } from 'node:util';
|
|
5
|
+
import { builtinModules } from 'node:module';
|
|
6
|
+
import ts from 'typescript';
|
|
7
|
+
import { parse as parseVue } from '@vue/compiler-sfc';
|
|
8
|
+
import { lineCount, observedScopeRisk, packageBoundary, scopeFail, scopeFiles, scopeRead, scopeTarget, stableScope } from './local-scope-files.mjs';
|
|
9
|
+
import { sha256 } from './local-integrity.mjs';
|
|
10
|
+
import { workspaceImport, workspaceIndex } from './local-scope-workspaces.mjs';
|
|
11
|
+
import { assertBudgetRunnerAllowed } from './local-budget-reads.mjs';
|
|
12
|
+
import { observeBudgetScopeGit, scopeGitBudgetEnforced } from './local-scope-git.mjs';
|
|
13
|
+
import { scopeGitMetadata } from './local-scope-git-reads.mjs';
|
|
14
|
+
|
|
15
|
+
const execute = promisify(execFile);
|
|
16
|
+
async function git(root, args, allowed = [], input = { repositoryRoot: root }) {
|
|
17
|
+
await assertBudgetRunnerAllowed(input);
|
|
18
|
+
try {
|
|
19
|
+
return (await execute('git', ['--no-optional-locks', '-c', 'core.hooksPath=/dev/null', '-c', 'core.fsmonitor=false', '-c', 'diff.external=', ...args],
|
|
20
|
+
{ cwd: root, encoding: 'utf8', timeout: 10_000, maxBuffer: 2_097_152,
|
|
21
|
+
env: { ...Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith('GIT_'))), GIT_OPTIONAL_LOCKS: '0' } })).stdout;
|
|
22
|
+
} catch (error) {
|
|
23
|
+
if (allowed.includes(error.code)) return null;
|
|
24
|
+
scopeFail('ENTROROVE_GIT_FAILED', 'Git fact inspection failed or exceeded its execution bound');
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function changedLines(output) {
|
|
28
|
+
let total = 0; let binary = false; let present = false;
|
|
29
|
+
for (const entry of output.split('\0')) {
|
|
30
|
+
const match = /^(\d+|-)\t(\d+|-)\t/.exec(entry);
|
|
31
|
+
if (!match) continue;
|
|
32
|
+
present = true;
|
|
33
|
+
if (match[1] === '-' || match[2] === '-') binary = true;
|
|
34
|
+
else total += Number(match[1]) + Number(match[2]);
|
|
35
|
+
}
|
|
36
|
+
return { lines: total, binary, present };
|
|
37
|
+
}
|
|
38
|
+
async function hasGitMarker(root) {
|
|
39
|
+
let directory = root;
|
|
40
|
+
while (true) {
|
|
41
|
+
try { await fs.lstat(path.join(directory, '.git')); return true; }
|
|
42
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
43
|
+
const parent = path.dirname(directory);
|
|
44
|
+
if (parent === directory) return false;
|
|
45
|
+
directory = parent;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/** @typedef {{available:boolean, head:string|null, baseline:string|null, currentChangedLines:number, untrackedChangedLines?:number, recentChangedLines:number, binary:boolean, commits:Array<{commit:string, changedLines:number, binary:boolean}>}} GitScopeFacts */
|
|
49
|
+
/** @typedef {{facts:GitScopeFacts, currentDiffPresent:boolean}} GitScopeObservation */
|
|
50
|
+
/** @returns {Promise<GitScopeObservation>} */
|
|
51
|
+
export async function observeGitFacts(root, files, baselineRef, input = { repositoryRoot: root }) {
|
|
52
|
+
const marker = await hasGitMarker(root);
|
|
53
|
+
if (marker && await scopeGitBudgetEnforced(input)) return observeBudgetScopeGit(root, files, baselineRef, input);
|
|
54
|
+
const top = marker ? await git(root, ['rev-parse', '--show-toplevel'], [128], input) : null;
|
|
55
|
+
if (top === null) {
|
|
56
|
+
if (baselineRef) scopeFail('ENTROROVE_GIT_BASELINE', 'baselineRef requires an actual Git repository');
|
|
57
|
+
return { facts: { available: false, head: null, baseline: null, currentChangedLines: 0, recentChangedLines: 0, binary: false, commits: [] }, currentDiffPresent: false };
|
|
58
|
+
}
|
|
59
|
+
if (await fs.realpath(top.trim()) !== root) scopeFail('ENTROROVE_GIT_ROOT', 'scope root must match the Git repository root');
|
|
60
|
+
const head = await git(root, ['rev-parse', '--verify', 'HEAD'], [128], input);
|
|
61
|
+
if (head === null) {
|
|
62
|
+
if (baselineRef) scopeFail('ENTROROVE_GIT_BASELINE', 'baselineRef requires a resolvable commit');
|
|
63
|
+
return { facts: { available: true, head: null, baseline: null, currentChangedLines: 0, recentChangedLines: 0, binary: false, commits: [] }, currentDiffPresent: false };
|
|
64
|
+
}
|
|
65
|
+
if (baselineRef !== undefined && (typeof baselineRef !== 'string' || !baselineRef || baselineRef.startsWith('-') || /[\u0000-\u001f]/.test(baselineRef))) scopeFail('ENTROROVE_GIT_BASELINE', 'baselineRef is invalid');
|
|
66
|
+
const base = await git(root, ['rev-parse', '--verify', `${baselineRef ?? 'HEAD'}^{commit}`], [128], input);
|
|
67
|
+
if (base === null) scopeFail('ENTROROVE_GIT_BASELINE', 'baselineRef must resolve to a real commit');
|
|
68
|
+
const pathspec = files.map(file => `:(literal)${file.path}`);
|
|
69
|
+
const current = changedLines(await git(root, ['diff', '--no-ext-diff', '--no-textconv', '--numstat', '-z', base.trim(), '--', ...pathspec], [], input));
|
|
70
|
+
const tracked = new Set((await git(root, ['ls-files', '--cached', '-z', '--', ...pathspec], [], input)).split('\0').filter(Boolean));
|
|
71
|
+
const untracked = files.filter(file => file.exists !== false && !tracked.has(file.path));
|
|
72
|
+
const untrackedChangedLines = untracked.reduce((sum, file) => sum + file.lines, 0);
|
|
73
|
+
const recent = (await git(root, ['log', '-5', '--format=%H', '--', ...pathspec], [], input)).trim().split('\n').filter(Boolean);
|
|
74
|
+
const commits = [];
|
|
75
|
+
for (const commit of recent) {
|
|
76
|
+
const count = changedLines(await git(root, ['show', '--format=', '--no-ext-diff', '--no-textconv', '--numstat', '-z', commit, '--', ...pathspec], [], input));
|
|
77
|
+
commits.push({ commit, changedLines: count.lines, binary: count.binary });
|
|
78
|
+
}
|
|
79
|
+
return { facts: { available: true, head: head.trim(), baseline: base.trim(), currentChangedLines: current.lines + untrackedChangedLines, untrackedChangedLines,
|
|
80
|
+
recentChangedLines: Math.max(0, ...commits.map(commit => commit.changedLines)), binary: current.binary || commits.some(commit => commit.binary), commits },
|
|
81
|
+
currentDiffPresent: current.present || untracked.length > 0 };
|
|
82
|
+
}
|
|
83
|
+
/** @returns {Promise<GitScopeFacts>} */
|
|
84
|
+
export async function gitFacts(root, files, baselineRef, input = { repositoryRoot: root }) { return (await observeGitFacts(root, files, baselineRef, input)).facts; }
|
|
85
|
+
|
|
86
|
+
const scriptKinds = new Map([['.ts', ts.ScriptKind.TS], ['.mts', ts.ScriptKind.TS], ['.cts', ts.ScriptKind.TS],
|
|
87
|
+
['.tsx', ts.ScriptKind.TSX], ['.js', ts.ScriptKind.JS], ['.mjs', ts.ScriptKind.JS], ['.cjs', ts.ScriptKind.JS], ['.jsx', ts.ScriptKind.JSX]]);
|
|
88
|
+
const dynamicNames = new Set(['eval', 'Function', 'createRequire']);
|
|
89
|
+
function staticString(expression) {
|
|
90
|
+
while (expression && ts.isParenthesizedExpression(expression)) expression = expression.expression;
|
|
91
|
+
if (!expression || (!ts.isStringLiteral(expression) && !ts.isNoSubstitutionTemplateLiteral(expression))) return null;
|
|
92
|
+
return expression.text;
|
|
93
|
+
}
|
|
94
|
+
function moduleLiteral(expression) {
|
|
95
|
+
const value = staticString(expression);
|
|
96
|
+
if (value !== null && (!value || /[\u0000-\u001f\u007f]/.test(value))) scopeFail('ENTROROVE_IMPORT_INVALID', 'module literal is empty or contains control characters');
|
|
97
|
+
return value;
|
|
98
|
+
}
|
|
99
|
+
function globalObject(expression) {
|
|
100
|
+
while (expression && (ts.isParenthesizedExpression(expression) || ts.isAsExpression(expression)
|
|
101
|
+
|| ts.isTypeAssertionExpression(expression) || ts.isNonNullExpression(expression) || ts.isSatisfiesExpression(expression))) expression = expression.expression;
|
|
102
|
+
return expression && ts.isIdentifier(expression) && ['globalThis', 'global', 'window'].includes(expression.text);
|
|
103
|
+
}
|
|
104
|
+
// The same pinned TypeScript parser as the cloud scope analyzer visits actual
|
|
105
|
+
// expressions inside templates and JSX; literal bodies never become code.
|
|
106
|
+
function scriptImports(source, filename) {
|
|
107
|
+
const parsed = ts.createSourceFile(filename, source, ts.ScriptTarget.Latest, true,
|
|
108
|
+
scriptKinds.get(path.extname(filename).toLowerCase()) ?? ts.ScriptKind.TS);
|
|
109
|
+
if (parsed.parseDiagnostics.length) scopeFail('ENTROROVE_IMPORT_INVALID', 'source has invalid or unterminated syntax');
|
|
110
|
+
const found = new Set(); let dynamic = false;
|
|
111
|
+
const add = expression => { const value = moduleLiteral(expression); if (value === null) dynamic = true; else found.add(value); };
|
|
112
|
+
const visit = node => {
|
|
113
|
+
if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) { if (node.moduleSpecifier) add(node.moduleSpecifier); }
|
|
114
|
+
else if (ts.isImportEqualsDeclaration(node) && ts.isExternalModuleReference(node.moduleReference)) add(node.moduleReference.expression);
|
|
115
|
+
else if (ts.isImportTypeNode(node)) add(ts.isLiteralTypeNode(node.argument) ? node.argument.literal : undefined);
|
|
116
|
+
else if (ts.isCallExpression(node)) {
|
|
117
|
+
const importer = node.expression.kind === ts.SyntaxKind.ImportKeyword;
|
|
118
|
+
const requireCall = ts.isIdentifier(node.expression) && node.expression.text === 'require';
|
|
119
|
+
if (importer || requireCall) {
|
|
120
|
+
if (!node.arguments.length || node.arguments.length > (importer ? 2 : 1)) dynamic = true;
|
|
121
|
+
else add(node.arguments[0]);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (ts.isIdentifier(node)) {
|
|
125
|
+
if (dynamicNames.has(node.text)) dynamic = true;
|
|
126
|
+
if (node.text === 'require' && !(ts.isCallExpression(node.parent) && node.parent.expression === node)
|
|
127
|
+
&& !(ts.isPropertyAccessExpression(node.parent) && node.parent.name === node)
|
|
128
|
+
&& !(ts.isMethodDeclaration(node.parent) && node.parent.name === node)) dynamic = true;
|
|
129
|
+
}
|
|
130
|
+
if (ts.isElementAccessExpression(node)) {
|
|
131
|
+
const member = staticString(node.argumentExpression);
|
|
132
|
+
if (dynamicNames.has(member) || member === 'require' || member === null && globalObject(node.expression)) dynamic = true;
|
|
133
|
+
}
|
|
134
|
+
if (ts.isPropertyAccessExpression(node) && node.name.text === 'require' && globalObject(node.expression)) dynamic = true;
|
|
135
|
+
ts.forEachChild(node, visit);
|
|
136
|
+
};
|
|
137
|
+
visit(parsed); return { specifiers: [...found].sort(), dynamic };
|
|
138
|
+
}
|
|
139
|
+
function vueScripts(source, filename) {
|
|
140
|
+
const { descriptor, errors } = parseVue(source, { filename, sourceMap: false,
|
|
141
|
+
templateParseOptions: { expressionPlugins: ['typescript'] } });
|
|
142
|
+
if (errors.length) scopeFail('ENTROROVE_IMPORT_INVALID', 'component has invalid or unterminated syntax');
|
|
143
|
+
if (descriptor.customBlocks.length || descriptor.template?.lang || descriptor.template?.src) {
|
|
144
|
+
scopeFail('ENTROROVE_IMPORT_UNSUPPORTED', 'component dependency inspection requires supported inline template syntax');
|
|
145
|
+
}
|
|
146
|
+
const scripts = [];
|
|
147
|
+
for (const block of [descriptor.script, descriptor.scriptSetup].filter(Boolean)) {
|
|
148
|
+
const language = block.lang ?? 'js';
|
|
149
|
+
if (!['js', 'javascript', 'ts', 'typescript', 'jsx', 'tsx'].includes(language)) scopeFail('ENTROROVE_IMPORT_UNSUPPORTED', 'component script language is unsupported');
|
|
150
|
+
const extension = { javascript: 'js', typescript: 'ts' }[language] ?? language;
|
|
151
|
+
scripts.push({ source: block.src ? `import ${JSON.stringify(block.src)};` : block.content, filename: `${filename}.${extension}` });
|
|
152
|
+
}
|
|
153
|
+
const seen = new WeakSet();
|
|
154
|
+
const walk = node => {
|
|
155
|
+
if (!node || typeof node !== 'object' || seen.has(node)) return;
|
|
156
|
+
seen.add(node);
|
|
157
|
+
if (node.type === 7 && ['on', 'for'].includes(node.name) && node.exp?.content) {
|
|
158
|
+
scripts.push({ source: node.name === 'for' ? `for (${node.exp.content}) {}` : node.exp.content, filename: `${filename}.ts` });
|
|
159
|
+
seen.add(node.exp);
|
|
160
|
+
}
|
|
161
|
+
if (node.type === 4 && !node.isStatic && node.content) scripts.push({ source: `void (${node.content});`, filename: `${filename}.ts` });
|
|
162
|
+
for (const [key, child] of Object.entries(node)) if (!['loc', 'ast', 'parent', 'codegenNode'].includes(key)) {
|
|
163
|
+
if (Array.isArray(child)) child.forEach(walk); else walk(child);
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
walk(descriptor.template?.ast); return scripts;
|
|
167
|
+
}
|
|
168
|
+
function importSpecifiers(content, filename) {
|
|
169
|
+
try {
|
|
170
|
+
const source = content.toString('utf8'), parts = path.extname(filename).toLowerCase() === '.vue' ? vueScripts(source, filename) : [{ source, filename }];
|
|
171
|
+
const specifiers = new Set(); let dynamic = false;
|
|
172
|
+
for (const part of parts) { const parsed = scriptImports(part.source, part.filename); dynamic ||= parsed.dynamic;
|
|
173
|
+
for (const specifier of parsed.specifiers) specifiers.add(specifier); }
|
|
174
|
+
return { specifiers: [...specifiers].sort(), dynamic };
|
|
175
|
+
} catch (error) {
|
|
176
|
+
if (typeof error?.code === 'string' && error.code.startsWith('ENTROROVE_')) throw error;
|
|
177
|
+
if (error instanceof RangeError) scopeFail('ENTROROVE_SCOPE_LIMIT', 'dependency syntax exceeds the supported parser or traversal bound');
|
|
178
|
+
scopeFail('ENTROROVE_IMPORT_INVALID', 'dependency source could not be parsed');
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
const sourceExtension = /\.(?:[cm]?[jt]sx?|vue)$/i;
|
|
182
|
+
async function resolveImport(root, from, specifier, workspace, input) {
|
|
183
|
+
if (/^(?:https?:|file:|data:)/i.test(specifier)) scopeFail('ENTROROVE_IMPORT_UNSUPPORTED', 'remote source imports cannot be inspected as local facts');
|
|
184
|
+
const localPackage = !specifier.startsWith('.') ? workspaceImport(workspace, specifier) : null;
|
|
185
|
+
if (!specifier.startsWith('.') && !localPackage) {
|
|
186
|
+
if (builtinModules.includes(specifier) || (specifier.startsWith('node:') && builtinModules.includes(specifier.slice(5)))) return null;
|
|
187
|
+
const boundary = await packageBoundary(root, from, input);
|
|
188
|
+
if (boundary.manifestPath) {
|
|
189
|
+
const manifest = JSON.parse((await scopeRead(root, boundary.manifestPath, input)).content.toString('utf8'));
|
|
190
|
+
const name = specifier.startsWith('@') ? specifier.split('/').slice(0, 2).join('/') : specifier.split('/')[0];
|
|
191
|
+
if (['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'].some(field => Object.hasOwn(manifest[field] ?? {}, name))) return null;
|
|
192
|
+
}
|
|
193
|
+
scopeFail('ENTROROVE_IMPORT_UNRESOLVED', 'bare imports require a builtin, declared dependency, or actual workspace export; unresolved aliases cannot be assumed external');
|
|
194
|
+
}
|
|
195
|
+
const relative = localPackage ?? path.posix.normalize(path.posix.join(path.posix.dirname(from), specifier));
|
|
196
|
+
if (relative.startsWith('../') || relative === '..') scopeFail('ENTROROVE_PATH_UNSAFE', 'local import escapes the repository');
|
|
197
|
+
const replacements = /\.js$/.test(relative) ? [relative.replace(/\.js$/, '.ts'), relative.replace(/\.js$/, '.tsx')] : [];
|
|
198
|
+
for (const candidate of [relative, ...replacements, ...['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '/index.js', '/index.ts'].map(ext => relative + ext)]) {
|
|
199
|
+
const item = await scopeTarget(root, candidate, true);
|
|
200
|
+
if (item.exists && item.stat.isFile()) return candidate;
|
|
201
|
+
}
|
|
202
|
+
scopeFail('ENTROROVE_IMPORT_UNRESOLVED', 'a local import has no resolvable source file');
|
|
203
|
+
}
|
|
204
|
+
export async function importGraph(root, originals, input = { repositoryRoot: root }) {
|
|
205
|
+
const pending = originals.filter(file => file.exists !== false).map(file => file.path); const visited = new Map(); const edges = []; let dynamic = false;
|
|
206
|
+
const workspace = await workspaceIndex(root, input);
|
|
207
|
+
while (pending.length) {
|
|
208
|
+
const relative = pending.shift(); if (visited.has(relative)) continue;
|
|
209
|
+
if (visited.size >= 1000) scopeFail('ENTROROVE_SCOPE_LIMIT', 'import graph exceeds the 1000-file inspection bound');
|
|
210
|
+
const read = await scopeRead(root, relative, input); const boundary = await packageBoundary(root, relative, input);
|
|
211
|
+
visited.set(relative, { path: relative, sha256: read.sha256, module: boundary.path, boundary });
|
|
212
|
+
if (!sourceExtension.test(relative)) continue;
|
|
213
|
+
const imports = importSpecifiers(read.content, relative); dynamic ||= imports.dynamic;
|
|
214
|
+
for (const specifier of imports.specifiers.sort()) {
|
|
215
|
+
const target = await resolveImport(root, relative, specifier, workspace, input);
|
|
216
|
+
edges.push({ from: relative, to: target, external: target === null, specifier });
|
|
217
|
+
if (target !== null && !visited.has(target)) pending.push(target);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
const nodes = [...visited.values()].sort((a, b) => a.path.localeCompare(b.path));
|
|
221
|
+
await stableScope(root, nodes, input);
|
|
222
|
+
await stableScope(root, workspace.metadata, input);
|
|
223
|
+
return { nodes, edges: edges.sort((a, b) => `${a.from}:${a.specifier}`.localeCompare(`${b.from}:${b.specifier}`)), dynamic, complete: !dynamic, workspaceMetadata: workspace.metadata };
|
|
224
|
+
}
|
|
225
|
+
export async function collectScopeFacts(root, requested, baselineRef, knownFiles = [], input = { repositoryRoot: root }) {
|
|
226
|
+
const selected = await scopeFiles(root, requested, knownFiles); const files = [];
|
|
227
|
+
for (const relative of selected) {
|
|
228
|
+
const target = await scopeTarget(root, relative, knownFiles.some(file => file.path === relative));
|
|
229
|
+
if (!target.exists) {
|
|
230
|
+
const previous = knownFiles.find(file => file.path === relative);
|
|
231
|
+
files.push({ path: relative, exists: false, sha256: sha256('entrorove.missing-file/1.0'), bytes: 0, lines: 0, module: previous.module });
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
const read = await scopeRead(root, relative, input); const boundary = await packageBoundary(root, relative, input);
|
|
235
|
+
files.push({ path: relative, exists: true, sha256: read.sha256, bytes: read.bytes, lines: lineCount(read.content), module: boundary.path });
|
|
236
|
+
}
|
|
237
|
+
const graph = await importGraph(root, files, input); const observedGit = await observeGitFacts(root, files, baselineRef, input); const history = observedGit.facts;
|
|
238
|
+
const modules = [...new Map(graph.nodes.map(node => [node.boundary.path, node.boundary])).values()].sort((a, b) => a.path.localeCompare(b.path));
|
|
239
|
+
await stableScope(root, files, input);
|
|
240
|
+
await stableScope(root, modules.filter(module => module.manifestPath).map(module => ({ path: module.manifestPath, sha256: module.manifestDigest })), input);
|
|
241
|
+
if (history.available && (await scopeGitMetadata(root, ['rev-parse', '--verify', 'HEAD'], input, [128]))?.trim() !== history.head) scopeFail('ENTROROVE_SCOPE_CHANGED', 'Git HEAD changed during scope inspection');
|
|
242
|
+
const sourceLines = files.reduce((sum, file) => sum + file.lines, 0); const bytes = files.reduce((sum, file) => sum + file.bytes, 0);
|
|
243
|
+
// An actual current diff (including empty untracked files) is authoritative; clean Git uses history, unknown Git uses source lines.
|
|
244
|
+
const estimatedChangedLines = history.available && history.head ? (observedGit.currentDiffPresent ? history.currentChangedLines : history.recentChangedLines)
|
|
245
|
+
: sourceLines + files.filter(file => !file.exists).reduce((sum, file) => sum + (knownFiles.find(previous => previous.path === file.path)?.lines ?? 0), 0);
|
|
246
|
+
return { targetPaths: [...new Set(requested)].sort(), fileCount: files.length, files, bytes, sourceLines, tokenEstimate: Math.ceil(bytes / 4), modules,
|
|
247
|
+
crossModule: modules.length > 1, risk: observedScopeRisk(files), importGraph: graph, git: history,
|
|
248
|
+
estimatedChangedLines };
|
|
249
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { createPrivateKey, createPublicKey, randomUUID, sign } from 'node:crypto';
|
|
2
|
+
import { promises as fs } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { canonical, sha256 } from './local-integrity.mjs';
|
|
5
|
+
import { scopeFail, scopeRead, scopeControlRead, scopeRoot } from './local-scope-files.mjs';
|
|
6
|
+
import { collectScopeFacts } from './local-scope-repository.mjs';
|
|
7
|
+
import { readScopeSnapshot, verifyFreshScopeSnapshot } from './local-scope-snapshot.mjs';
|
|
8
|
+
import { resolveScopePaths } from './local-scope-context.mjs';
|
|
9
|
+
import { rejectScopePrivileges, verifiedScopeCandidate } from './local-scope-candidate.mjs';
|
|
10
|
+
|
|
11
|
+
async function privateFile(file) {
|
|
12
|
+
if (typeof file !== 'string' || !path.isAbsolute(file)) scopeFail('ENTROROVE_SCOPE_SIGNER_INVALID', 'scope signer control paths must be absolute');
|
|
13
|
+
const root = path.parse(file).root; const relative = file.slice(root.length);
|
|
14
|
+
const stat = await fs.lstat(file);
|
|
15
|
+
if (!stat.isFile() || stat.isSymbolicLink() || (process.getuid && stat.uid !== process.getuid())
|
|
16
|
+
|| (process.platform !== 'win32' && (stat.mode & 0o077) !== 0)) scopeFail('ENTROROVE_SCOPE_SIGNER_INVALID', 'scope signer controls must be private regular files owned by the current account');
|
|
17
|
+
let ancestor = root;
|
|
18
|
+
for (const part of relative.split('/').slice(0, -1)) {
|
|
19
|
+
ancestor = path.join(ancestor, part); const parentStat = await fs.lstat(ancestor);
|
|
20
|
+
if (!parentStat.isSymbolicLink()) continue;
|
|
21
|
+
const systemAlias = process.platform === 'darwin' && ['/var', '/tmp'].includes(ancestor)
|
|
22
|
+
&& parentStat.uid === 0 && await fs.realpath(ancestor) === `/private${ancestor}`;
|
|
23
|
+
if (!systemAlias) scopeFail('ENTROROVE_SCOPE_SIGNER_INVALID', 'scope signer control ancestors must not be symlinked');
|
|
24
|
+
}
|
|
25
|
+
const parent = await fs.realpath(path.dirname(file));
|
|
26
|
+
return (await scopeControlRead(parent, path.basename(file))).content.toString('utf8');
|
|
27
|
+
}
|
|
28
|
+
function binding(input, result) {
|
|
29
|
+
const text = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$/; const digest = /^[0-9a-f]{64}$/;
|
|
30
|
+
for (const field of ['flowId', 'stepId', 'ownerId', 'repositoryId', 'challenge']) {
|
|
31
|
+
if (typeof input[field] !== 'string' || !text.test(input[field])) scopeFail('ENTROROVE_SCOPE_ATTESTATION_BINDING', 'scope signature requires the original task, owner, repository, step, and challenge');
|
|
32
|
+
}
|
|
33
|
+
for (const field of ['taskDigest', 'chainDigest']) if (!digest.test(String(input[field] ?? ''))) scopeFail('ENTROROVE_SCOPE_ATTESTATION_BINDING', 'scope signature requires original task and chain digests');
|
|
34
|
+
if (!digest.test(String(result.snapshotDigest ?? ''))) scopeFail('ENTROROVE_SCOPE_ATTESTATION_BINDING', 'scope signature requires a verified preserved snapshot digest');
|
|
35
|
+
}
|
|
36
|
+
export async function attestScopeFacts(input, result) {
|
|
37
|
+
rejectScopePrivileges(input);
|
|
38
|
+
if (input.signerConfigPath === undefined || input.signerConfigPath === null) return result;
|
|
39
|
+
binding(input, result);
|
|
40
|
+
const root = await scopeRoot(input.repositoryRoot); const snapshot = await readScopeSnapshot(input, root);
|
|
41
|
+
const actual = await collectScopeFacts(root, await resolveScopePaths(input, root), input.baselineRef, snapshot.known, input);
|
|
42
|
+
actual.snapshotEvidence = await verifyFreshScopeSnapshot(input, root, actual);
|
|
43
|
+
const candidate = await verifiedScopeCandidate(input, root, actual.snapshotEvidence.manifest);
|
|
44
|
+
if (root !== result.repositoryRoot || canonical(actual) !== canonical(result.facts)
|
|
45
|
+
|| actual.snapshotEvidence.snapshotDigest !== result.snapshotDigest) scopeFail('ENTROROVE_SCOPE_ATTESTATION_BINDING', 'scope signer refuses facts or snapshot assertions that do not match independent inspection');
|
|
46
|
+
let config; let privateKey;
|
|
47
|
+
try {
|
|
48
|
+
config = JSON.parse(await privateFile(input.signerConfigPath));
|
|
49
|
+
if (config.schemaVersion !== 'entrorove.scope-signer/1.0' || !Number.isSafeInteger(config.receiptTtlMs)
|
|
50
|
+
|| config.receiptTtlMs < 1 || config.receiptTtlMs > 300_000) scopeFail('ENTROROVE_SCOPE_SIGNER_INVALID', 'scope signer schema or receipt lifetime is invalid');
|
|
51
|
+
privateKey = createPrivateKey(await privateFile(config.privateKeyPath));
|
|
52
|
+
if (privateKey.asymmetricKeyType !== 'ed25519') scopeFail('ENTROROVE_SCOPE_SIGNER_INVALID', 'scope signer must use Ed25519');
|
|
53
|
+
const keyId = sha256(createPublicKey(privateKey).export({ type: 'spki', format: 'der' }));
|
|
54
|
+
if (keyId !== config.keyId) scopeFail('ENTROROVE_SCOPE_SIGNER_INVALID', 'scope signer key identity is invalid');
|
|
55
|
+
if (candidate && keyId !== candidate.runnerKeyId) scopeFail('ENTROROVE_SCOPE_SIGNER_INVALID', 'temporary scope signer must be the actual cloud registered candidate runner');
|
|
56
|
+
} catch (error) {
|
|
57
|
+
if (error.code === 'ENTROROVE_SCOPE_SIGNER_INVALID') throw error;
|
|
58
|
+
scopeFail('ENTROROVE_SCOPE_SIGNER_INVALID', 'scope signer configuration or signing key could not be read');
|
|
59
|
+
}
|
|
60
|
+
const now = Date.now();
|
|
61
|
+
const expiresAt = Math.min(now + config.receiptTtlMs, candidate ? Date.parse(candidate.expiresAt) : Infinity);
|
|
62
|
+
if (expiresAt <= now) scopeFail('ENTROROVE_SCOPE_ATTESTATION_BINDING', 'candidate challenge expired before its actual scope signature');
|
|
63
|
+
const core = { schemaVersion: 'entrorove.scope-facts/1.0', flowId: input.flowId, stepId: input.stepId,
|
|
64
|
+
taskDigest: input.taskDigest, ownerId: input.ownerId, chainDigest: input.chainDigest, repositoryId: input.repositoryId,
|
|
65
|
+
facts: result.facts, snapshotDigest: result.snapshotDigest, challenge: input.challenge,
|
|
66
|
+
issuedAt: new Date(now).toISOString(), expiresAt: new Date(expiresAt).toISOString(),
|
|
67
|
+
nonce: randomUUID(), keyId: config.keyId };
|
|
68
|
+
const signature = sign(null, Buffer.from(canonical(core)), privateKey).toString('base64url');
|
|
69
|
+
return { ...result, attestation: { ...core, signature }, trusted: false, status: 'reported' };
|
|
70
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { readSnapshotCopy14 } from './local-mutation-snapshot.mjs';
|
|
2
|
+
import { promises as fs } from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { sha256, verifySnapshotManifest } from './local-integrity.mjs';
|
|
6
|
+
import { packageBoundary, scopeFail, scopeRead, scopeControlRead, scopeTarget } from './local-scope-files.mjs';
|
|
7
|
+
|
|
8
|
+
export async function readScopeSnapshot(input, root) {
|
|
9
|
+
const id = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
10
|
+
for (const field of ['flowId', 'stepId', 'snapshotId']) if (!id.test(String(input[field] ?? ''))) scopeFail('ENTROROVE_SCOPE_BINDING', 'scope snapshot requires original flow, step, and snapshot identities');
|
|
11
|
+
const home = input.home ?? os.homedir();
|
|
12
|
+
if (typeof home !== 'string' || !path.isAbsolute(home)) scopeFail('ENTROROVE_HOME_INVALID', 'home must be absolute');
|
|
13
|
+
const base = await fs.realpath(home); const relative = `.entrorove/snapshots/${sha256(root).slice(0, 32)}/${input.flowId}/${input.snapshotId}`;
|
|
14
|
+
await scopeTarget(base, relative);
|
|
15
|
+
const manifest = JSON.parse((await scopeControlRead(base, `${relative}/manifest.json`)).content.toString('utf8'));
|
|
16
|
+
const integrity = verifySnapshotManifest(manifest, { snapshotId: input.snapshotId, flowId: input.flowId, stepId: input.stepId, repositoryDigest: sha256(root).slice(0, 32) });
|
|
17
|
+
if (!integrity.verified) scopeFail('ENTROROVE_SNAPSHOT_CORRUPT', 'scope snapshot manifest integrity failed');
|
|
18
|
+
const directory = path.join(base, relative); const known = [];
|
|
19
|
+
for (const file of manifest.files) {
|
|
20
|
+
if (file.exists) {
|
|
21
|
+
if ((await readSnapshotCopy14(input, directory, manifest, file.path)).sha256 !== file.sha256) scopeFail('ENTROROVE_SNAPSHOT_CORRUPT', 'scope snapshot copy hash failed');
|
|
22
|
+
} else if (file.sha256 !== sha256('entrorove.missing-file/1.0') || (await readSnapshotCopy14(input, directory, manifest, file.path)).exists) scopeFail('ENTROROVE_SNAPSHOT_CORRUPT', 'scope missing-file snapshot is corrupt');
|
|
23
|
+
known.push({ ...file, module: (await packageBoundary(root, file.path, input)).path });
|
|
24
|
+
}
|
|
25
|
+
return { manifest, directory, known };
|
|
26
|
+
}
|
|
27
|
+
export async function verifyFreshScopeSnapshot(input, root, facts) {
|
|
28
|
+
const snapshot = await readScopeSnapshot(input, root);
|
|
29
|
+
if (snapshot.manifest.paths.join('\0') !== facts.files.map(file => file.path).join('\0')) scopeFail('ENTROROVE_SCOPE_SNAPSHOT_PATHS', 'fresh snapshot must cover exactly the observed target paths');
|
|
30
|
+
for (const file of snapshot.manifest.files) {
|
|
31
|
+
const actual = facts.files.find(observed => observed.path === file.path);
|
|
32
|
+
const target = await scopeTarget(root, file.path, !file.exists);
|
|
33
|
+
if (file.exists !== target.exists || actual?.exists !== file.exists || actual?.sha256 !== file.sha256
|
|
34
|
+
|| (file.exists && (await scopeRead(root, file.path, input)).sha256 !== file.sha256)) scopeFail('ENTROROVE_SNAPSHOT_CHANGED', 'fresh scope snapshot no longer matches live files');
|
|
35
|
+
}
|
|
36
|
+
return { snapshotId: input.snapshotId, snapshotDigest: snapshot.manifest.snapshotDigest, flowId: input.flowId, stepId: input.stepId,
|
|
37
|
+
files: snapshot.manifest.files.map(file => ({ path: file.path, sha256: file.sha256, exists: file.exists })),
|
|
38
|
+
copiesVerified: true, currentSnapshotFresh: true, manifest: snapshot.manifest };
|
|
39
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { readSnapshotCopy14, preserveSnapshotCopies14 } from './local-mutation-snapshot.mjs';
|
|
2
|
+
import { promises as fs } from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { randomUUID } from 'node:crypto';
|
|
6
|
+
import { canonical, sha256, verifySnapshotManifest } from './local-integrity.mjs';
|
|
7
|
+
import { withStateLock } from './local-state-lock.mjs';
|
|
8
|
+
import { scopeFail, scopeRead, scopeControlRead, scopeTarget, stableScope } from './local-scope-files.mjs';
|
|
9
|
+
|
|
10
|
+
const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
11
|
+
const order = ['lock', 'probe', 'swarm'];
|
|
12
|
+
export function scopeBinding(input) {
|
|
13
|
+
for (const field of ['flowId', 'stepId', 'snapshotId']) if (typeof input[field] !== 'string' || !ID.test(input[field])) scopeFail('ENTROROVE_SCOPE_BINDING', 'scope requires concrete flowId, stepId, and snapshotId');
|
|
14
|
+
return { flowId: input.flowId, stepId: input.stepId, snapshotId: input.snapshotId };
|
|
15
|
+
}
|
|
16
|
+
async function privateDirectory(directory) {
|
|
17
|
+
await fs.mkdir(directory, { recursive: true, mode: 0o700 });
|
|
18
|
+
const stat = await fs.lstat(directory);
|
|
19
|
+
if (stat.isSymbolicLink() || !stat.isDirectory() || (process.getuid && stat.uid !== process.getuid())) scopeFail('ENTROROVE_STATE_INVALID', 'scope state must be a directory owned by the current account');
|
|
20
|
+
if (process.platform !== 'win32') await fs.chmod(directory, 0o700);
|
|
21
|
+
}
|
|
22
|
+
async function stateRoot(input) {
|
|
23
|
+
const home = input.home ?? os.homedir();
|
|
24
|
+
if (typeof home !== 'string' || !path.isAbsolute(home)) scopeFail('ENTROROVE_HOME_INVALID', 'home must be absolute');
|
|
25
|
+
await fs.mkdir(home, { recursive: true, mode: 0o700 }); const homeStat = await fs.lstat(home);
|
|
26
|
+
if (!homeStat.isDirectory() || homeStat.isSymbolicLink()) scopeFail('ENTROROVE_HOME_INVALID', 'home must be a real directory');
|
|
27
|
+
const actual = await fs.realpath(home);
|
|
28
|
+
const state = path.join(actual, '.entrorove'); await privateDirectory(state);
|
|
29
|
+
return state;
|
|
30
|
+
}
|
|
31
|
+
async function scopeDirectory(input, root) {
|
|
32
|
+
const base = await stateRoot(input); let directory = base;
|
|
33
|
+
for (const part of ['scope', sha256(root).slice(0, 32), input.flowId]) {
|
|
34
|
+
directory = path.join(directory, part); await privateDirectory(directory);
|
|
35
|
+
}
|
|
36
|
+
return { directory, base, statePath: path.join(directory, 'state.json') };
|
|
37
|
+
}
|
|
38
|
+
async function privateJson(file) {
|
|
39
|
+
const stat = await fs.lstat(file);
|
|
40
|
+
if (!stat.isFile() || stat.isSymbolicLink() || (process.getuid && stat.uid !== process.getuid())
|
|
41
|
+
|| (process.platform !== 'win32' && (stat.mode & 0o077) !== 0)) scopeFail('ENTROROVE_STATE_INVALID', 'scope state must be a private regular file');
|
|
42
|
+
return JSON.parse(await fs.readFile(file, 'utf8'));
|
|
43
|
+
}
|
|
44
|
+
async function atomicJson(file, value) {
|
|
45
|
+
const temporary = `${file}.${randomUUID()}.tmp`; let handle;
|
|
46
|
+
try {
|
|
47
|
+
handle = await fs.open(temporary, 'wx', 0o600); await handle.writeFile(`${canonical(value)}\n`); await handle.sync(); await handle.close(); handle = null;
|
|
48
|
+
await fs.rename(temporary, file);
|
|
49
|
+
} finally { if (handle) await handle.close(); await fs.rm(temporary, { force: true }); }
|
|
50
|
+
}
|
|
51
|
+
async function originalSnapshot(input, root, base) {
|
|
52
|
+
const relative = `snapshots/${sha256(root).slice(0, 32)}/${input.flowId}/${input.snapshotId}`;
|
|
53
|
+
await scopeTarget(base, relative);
|
|
54
|
+
const directory = path.join(base, relative); const manifest = JSON.parse((await scopeControlRead(base, `${relative}/manifest.json`)).content.toString('utf8'));
|
|
55
|
+
const integrity = verifySnapshotManifest(manifest, { ...scopeBinding(input), repositoryDigest: sha256(root).slice(0, 32) });
|
|
56
|
+
if (!integrity.verified) scopeFail('ENTROROVE_SNAPSHOT_CORRUPT', 'the original snapshot manifest is not valid for this scope');
|
|
57
|
+
for (const file of manifest.files) {
|
|
58
|
+
if (file.exists && (await readSnapshotCopy14(input, directory, manifest, file.path)).sha256 !== file.sha256) scopeFail('ENTROROVE_SNAPSHOT_CORRUPT', 'the original snapshot copy changed');
|
|
59
|
+
if (!file.exists) {
|
|
60
|
+
const target = await readSnapshotCopy14(input, directory, manifest, file.path);
|
|
61
|
+
if (target.exists) scopeFail('ENTROROVE_SNAPSHOT_CORRUPT', 'a missing-file snapshot contains a file');
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return { manifest, directory, digest: manifest.snapshotDigest };
|
|
65
|
+
}
|
|
66
|
+
async function capture(root, files, directory, input) {
|
|
67
|
+
for (const file of files) {
|
|
68
|
+
if (file.exists === false) {
|
|
69
|
+
if ((await scopeTarget(root, file.path, true)).exists) scopeFail('ENTROROVE_SCOPE_CHANGED', 'a deleted source reappeared before preservation');
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
const read = await scopeRead(root, file.path, input);
|
|
73
|
+
if (read.sha256 !== file.sha256) scopeFail('ENTROROVE_SCOPE_CHANGED', 'scope source changed before snapshot preservation');
|
|
74
|
+
const destination = path.join(directory, file.path);
|
|
75
|
+
await fs.mkdir(path.dirname(destination), { recursive: true, mode: 0o700 });
|
|
76
|
+
await fs.writeFile(destination, read.content, { mode: 0o600, flag: 'wx' });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function stateIntegrity(state, input, root) {
|
|
80
|
+
if (!state || !state.originalSnapshot || state.schemaVersion !== 'entrorove.scope-state/1.0' || state.repositoryDigest !== sha256(root).slice(0, 32)
|
|
81
|
+
|| state.flowId !== input.flowId || state.stepId !== input.stepId || state.originalSnapshot.snapshotId !== input.snapshotId
|
|
82
|
+
|| state.snapshotId !== state.originalSnapshot.snapshotId
|
|
83
|
+
|| !Number.isSafeInteger(state.revision) || !order.includes(state.mode) || !Array.isArray(state.history)
|
|
84
|
+
|| state.history.length !== state.revision || state.mode !== state.history.at(-1)?.mode
|
|
85
|
+
|| state.taskDigest !== sha256(canonical(state.originalTask))
|
|
86
|
+
|| canonical(state.originalFacts) !== canonical(state.history[0]?.facts)
|
|
87
|
+
|| state.originalFactsDigest !== sha256(canonical(state.originalFacts))) scopeFail('ENTROROVE_SCOPE_STATE_CORRUPT', 'scope state is not bound or its original facts changed');
|
|
88
|
+
for (const [index, record] of state.history.entries()) {
|
|
89
|
+
const core = Object.fromEntries(Object.entries(record).filter(([key]) => key !== 'recordDigest'));
|
|
90
|
+
if (record.revision !== index + 1 || record.previousDigest !== (state.history[index - 1]?.recordDigest ?? null)
|
|
91
|
+
|| record.recordDigest !== sha256(canonical(core))) scopeFail('ENTROROVE_SCOPE_STATE_CORRUPT', 'scope history integrity failed');
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
function historyRecord(state, facts, directory, mode) {
|
|
95
|
+
const previous = state.history.at(-1); const oldFiles = new Map((previous?.facts.files ?? []).map(file => [file.path, file.sha256]));
|
|
96
|
+
const changes = facts.files.filter(file => oldFiles.get(file.path) !== file.sha256).map(file => ({ path: file.path, before: oldFiles.get(file.path) ?? null, after: file.exists === false ? null : file.sha256 }));
|
|
97
|
+
const paths = new Set(facts.files.map(file => file.path));
|
|
98
|
+
for (const file of previous?.facts.files ?? []) if (!paths.has(file.path)) changes.push({ path: file.path, before: file.sha256, after: null });
|
|
99
|
+
const core = { revision: state.revision + 1, previousDigest: previous?.recordDigest ?? null, mode, facts, changes,
|
|
100
|
+
observedAt: new Date().toISOString(), snapshotDirectory: directory, factsDigest: sha256(canonical(facts)) };
|
|
101
|
+
return { ...core, recordDigest: sha256(canonical(core)) };
|
|
102
|
+
}
|
|
103
|
+
function preservedFiles(facts) {
|
|
104
|
+
const files = [...facts.files, ...facts.importGraph.workspaceMetadata.map(file => ({ ...file, exists: true })), ...facts.importGraph.nodes.map(node => ({ path: node.path, sha256: node.sha256, exists: true })),
|
|
105
|
+
...facts.modules.filter(module => module.manifestPath).map(module => ({ path: module.manifestPath, sha256: module.manifestDigest, exists: true }))];
|
|
106
|
+
return [...new Map(files.map(file => [file.path, file])).values()];
|
|
107
|
+
}
|
|
108
|
+
async function saveRevision(location, root, state, facts, mode, initial, input) {
|
|
109
|
+
const staged = await fs.mkdtemp(path.join(location.directory, '.revision-'));
|
|
110
|
+
const final = path.join(location.directory, `revision-${state.revision + 1}-${randomUUID()}`); let committed = false;
|
|
111
|
+
try {
|
|
112
|
+
const currentDirectory = path.join(staged, 'current'); await fs.mkdir(currentDirectory, { mode: 0o700 });
|
|
113
|
+
await capture(root, preservedFiles(facts), currentDirectory, input);
|
|
114
|
+
if (initial) {
|
|
115
|
+
const original = path.join(staged, 'original'); await fs.mkdir(original, { mode: 0o700 });
|
|
116
|
+
await preserveSnapshotCopies14(input, initial.directory, initial.manifest, original);
|
|
117
|
+
await fs.writeFile(path.join(original, 'manifest.json'), `${canonical(initial.manifest)}\n`, { flag: 'wx', mode: 0o600 });
|
|
118
|
+
}
|
|
119
|
+
await stableScope(root, preservedFiles(facts), input); await fs.rename(staged, final);
|
|
120
|
+
const record = historyRecord(state, facts, path.join(final, 'current'), mode);
|
|
121
|
+
const next = { ...state, revision: record.revision, mode, history: [...state.history, record] };
|
|
122
|
+
if (initial) next.originalSnapshot = { ...next.originalSnapshot, directory: path.join(final, 'original') };
|
|
123
|
+
await atomicJson(location.statePath, next); committed = true;
|
|
124
|
+
return next;
|
|
125
|
+
} finally { await fs.rm(staged, { recursive: true, force: true }); if (!committed) await fs.rm(final, { recursive: true, force: true }); }
|
|
126
|
+
}
|
|
127
|
+
async function verifyPreserved(state, input) {
|
|
128
|
+
const manifest = JSON.parse((await scopeControlRead(state.originalSnapshot.directory, 'manifest.json')).content.toString('utf8'));
|
|
129
|
+
const integrity = verifySnapshotManifest(manifest, { flowId: state.flowId, stepId: state.stepId,
|
|
130
|
+
snapshotId: state.originalSnapshot.snapshotId, repositoryDigest: state.repositoryDigest });
|
|
131
|
+
if (!integrity.verified || manifest.snapshotDigest !== state.originalSnapshot.digest
|
|
132
|
+
|| sha256(canonical(manifest)) !== state.originalSnapshot.manifestDigest) scopeFail('ENTROROVE_SNAPSHOT_CORRUPT', 'preserved original snapshot manifest or initial binding changed');
|
|
133
|
+
for (const file of manifest.files) {
|
|
134
|
+
if (file.exists && (await readSnapshotCopy14(input, state.originalSnapshot.directory, manifest, file.path)).sha256 !== file.sha256) scopeFail('ENTROROVE_SNAPSHOT_CORRUPT', 'preserved original snapshot copy changed');
|
|
135
|
+
if (!file.exists && (await readSnapshotCopy14(input, state.originalSnapshot.directory, manifest, file.path)).exists) {
|
|
136
|
+
scopeFail('ENTROROVE_SNAPSHOT_CORRUPT', 'preserved missing original snapshot acquired source bytes');
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
for (const record of state.history) for (const file of preservedFiles(record.facts)) {
|
|
140
|
+
if (file.exists === false) {
|
|
141
|
+
if ((await scopeTarget(record.snapshotDirectory, file.path, true)).exists) scopeFail('ENTROROVE_SNAPSHOT_CORRUPT', 'a preserved deletion acquired source bytes');
|
|
142
|
+
} else if ((await scopeRead(record.snapshotDirectory, file.path, input)).sha256 !== file.sha256) scopeFail('ENTROROVE_SNAPSHOT_CORRUPT', 'a preserved completed-change snapshot changed');
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
export async function retainedScope(input, root, optionalFreshProbe = false) {
|
|
146
|
+
scopeBinding(input); const home = input.home ?? os.homedir();
|
|
147
|
+
if (typeof home !== 'string' || !path.isAbsolute(home)) scopeFail('ENTROROVE_HOME_INVALID', 'home must be absolute');
|
|
148
|
+
const relative = `.entrorove/scope/${sha256(root).slice(0, 32)}/${input.flowId}/state.json`;
|
|
149
|
+
let state;
|
|
150
|
+
try {
|
|
151
|
+
await scopeTarget(await fs.realpath(home), relative);
|
|
152
|
+
state = await privateJson(path.join(await fs.realpath(home), relative));
|
|
153
|
+
} catch (error) { if (optionalFreshProbe && error.code === 'ENOENT') return null; throw error; }
|
|
154
|
+
// Validate the retained origin against its physical manifest; probe separately verifies the current step's fresh snapshot.
|
|
155
|
+
stateIntegrity(state, optionalFreshProbe ? { ...input, stepId: state?.stepId, snapshotId: state?.originalSnapshot?.snapshotId } : input, root);
|
|
156
|
+
await verifyPreserved(state, input);
|
|
157
|
+
if (input.task !== undefined && sha256(canonical(input.task)) !== state.taskDigest) scopeFail('ENTROROVE_SCOPE_TASK_CHANGED', 'probe and reassess must preserve the original accepted task');
|
|
158
|
+
return state;
|
|
159
|
+
}
|
|
160
|
+
export async function initializeScope(input, root, facts, classification) {
|
|
161
|
+
scopeBinding(input); const location = await scopeDirectory(input, root);
|
|
162
|
+
return withStateLock(location.statePath, async () => {
|
|
163
|
+
const original = await originalSnapshot(input, root, location.base);
|
|
164
|
+
for (const file of original.manifest.files) {
|
|
165
|
+
const actual = facts.files.find(observed => observed.path === file.path);
|
|
166
|
+
if (file.exists && actual?.sha256 !== file.sha256) scopeFail('ENTROROVE_SNAPSHOT_CHANGED', 'initial scope does not match the verified original snapshot');
|
|
167
|
+
}
|
|
168
|
+
let existing;
|
|
169
|
+
try { existing = await privateJson(location.statePath); } catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
170
|
+
if (existing) {
|
|
171
|
+
stateIntegrity(existing, { ...input, snapshotId: existing.originalSnapshot.snapshotId }, root); await verifyPreserved(existing, input);
|
|
172
|
+
if (input.task !== undefined && existing.taskDigest !== sha256(canonical(input.task))) scopeFail('ENTROROVE_SCOPE_TASK_CHANGED', 'the original task must remain unchanged');
|
|
173
|
+
const comparable = value => Object.fromEntries(Object.entries(value).filter(([key]) => !['snapshotEvidence', 'targetPaths'].includes(key)));
|
|
174
|
+
if (canonical(comparable(existing.history.at(-1).facts)) !== canonical(comparable(facts))) scopeFail('ENTROROVE_SCOPE_CHANGED', 'use reassess to preserve changed scope facts before probing a fresh snapshot');
|
|
175
|
+
return { state: existing, statePath: location.statePath };
|
|
176
|
+
}
|
|
177
|
+
const state = { schemaVersion: 'entrorove.scope-state/1.0', ...scopeBinding(input), repositoryDigest: sha256(root).slice(0, 32), revision: 0,
|
|
178
|
+
mode: classification.mode, originalTask: input.task ?? null, taskDigest: sha256(canonical(input.task ?? null)), originalFacts: facts, originalFactsDigest: sha256(canonical(facts)), originalSnapshot: { snapshotId: input.snapshotId,
|
|
179
|
+
digest: original.digest, manifestDigest: sha256(canonical(original.manifest)), directory: null }, history: [] };
|
|
180
|
+
return { state: await saveRevision(location, root, state, facts, classification.mode, original, input), statePath: location.statePath };
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
export async function upgradeScope(input, root, facts, classification) {
|
|
184
|
+
scopeBinding(input);
|
|
185
|
+
if (!Number.isSafeInteger(input.expectedRevision) || input.expectedRevision < 1) scopeFail('ENTROROVE_SCOPE_REVISION', 'reassess requires the exact persisted expectedRevision');
|
|
186
|
+
const location = await scopeDirectory(input, root);
|
|
187
|
+
return withStateLock(location.statePath, async () => {
|
|
188
|
+
let state;
|
|
189
|
+
try { state = await privateJson(location.statePath); } catch (error) {
|
|
190
|
+
if (error.code === 'ENOENT') scopeFail('ENTROROVE_SCOPE_STATE_MISSING', 'probe and preserve the original verified snapshot before reassess');
|
|
191
|
+
throw error;
|
|
192
|
+
}
|
|
193
|
+
stateIntegrity(state, input, root); await verifyPreserved(state, input);
|
|
194
|
+
if (state.revision !== input.expectedRevision) scopeFail('ENTROROVE_SCOPE_REVISION', 'scope revision changed; read the current scope before reassess');
|
|
195
|
+
if (input.task !== undefined && state.taskDigest !== sha256(canonical(input.task))) scopeFail('ENTROROVE_SCOPE_TASK_CHANGED', 'reassess must preserve the original task');
|
|
196
|
+
const observed = new Set(facts.files.map(file => file.path));
|
|
197
|
+
if (state.history.at(-1).facts.files.some(file => !observed.has(file.path))) scopeFail('ENTROROVE_SCOPE_SHRINK', 'reassess cannot discard a previously observed target');
|
|
198
|
+
if (input.currentMode !== undefined && input.currentMode !== state.mode) scopeFail('ENTROROVE_MODE_INVALID', 'currentMode must match the persisted mode');
|
|
199
|
+
const previousMode = state.mode; const current = order.indexOf(previousMode); const required = order.indexOf(classification.mode);
|
|
200
|
+
const mode = required > current ? order[Math.min(current + 1, order.length - 1)] : previousMode;
|
|
201
|
+
const next = await saveRevision(location, root, state, facts, mode, null, input);
|
|
202
|
+
return { state: next, statePath: location.statePath, previousMode, mode, requiredMode: classification.mode, upgraded: mode !== previousMode };
|
|
203
|
+
});
|
|
204
|
+
}
|