@entrorove/skill 0.1.68 → 0.1.70
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/CAPABILITY-MAP.md +2 -0
- package/CHANGELOG.md +10 -0
- package/IDE-EXECUTION.md +16 -2
- package/SKILL.md +3 -2
- package/cli.mjs +1 -1
- package/cloud-locale.d.mts +7 -0
- package/cloud-locale.mjs +59 -0
- package/cloud-task-client.mjs +21 -13
- package/cloud-watch-client.mjs +11 -3
- package/connected-cli.mjs +2 -0
- package/design-lint.mjs +20 -2
- package/ide-command.mjs +1 -1
- package/ide-hook.mjs +4 -4
- package/ide-presentation.mjs +15 -4
- package/local-archguard-components.mjs +31 -4
- package/local-contracts.mjs +5 -2
- package/local-delegated-modify-analysis.mjs +16 -7
- package/local-discipline-gate.mjs +2 -0
- package/local-discipline-snapshot.mjs +8 -2
- package/local-mutation-snapshot.mjs +2 -2
- package/local-preserved-mutation-protection.mjs +2 -0
- package/local-scope-files.mjs +14 -2
- package/local-scope-read-set.mjs +66 -0
- package/local-scope-repository.mjs +97 -13
- package/local-scope-signature.mjs +52 -6
- package/local-scope-uni-build.mjs +135 -0
- package/local-scope-uni-conditional.mjs +54 -0
- package/local-scope-uni-directives.mjs +96 -0
- package/local-scope-uni-expressions.mjs +101 -0
- package/local-scope-workspaces.mjs +14 -2
- package/manifest.json +1 -1
- package/mutation-client.mjs +8 -5
- package/package.json +17 -2
- package/release-state.json +46 -31
- package/release.mjs +4 -3
- package/releases.json +12 -0
- package/remote-client.mjs +1 -0
- package/scope-amendment-observer.mjs +3 -1
- package/scope-amendment-state.mjs +3 -1
- package/scope-context-client.mjs +118 -0
- package/scope-context-journal.mjs +44 -0
- package/scope-context-observation.mjs +42 -0
- package/scope-context-observer.mjs +86 -0
- package/scope-context-release.mjs +8 -0
- package/scope-context-remote.mjs +60 -0
- package/scope-context-state.mjs +88 -0
- package/scope-context-wire.mjs +84 -0
- package/scope-runner-client.mjs +9 -5
package/local-contracts.mjs
CHANGED
|
@@ -103,7 +103,8 @@ const scopeTask = object({ goal: { ...text, pattern: '\\S', maxLength: 12000 },
|
|
|
103
103
|
unclear: { type: 'boolean' }, architectureContract: { type: 'boolean' }, explicitAimlockRequested: { type: 'boolean' }, explicitEntroroveRequested: { type: 'boolean' }, userSpecifiedSkills: array(text),
|
|
104
104
|
requirements: { ...array(scopeRequirement, 1), maxItems: 500 }, phase: enumeration(['plan', 'coordinate', 'architecture', 'verify', 'merge', 'decision']), baselineRef: text, interaction: scopeInteraction }, ['goal', 'workType', 'difficulty', 'risk']);
|
|
105
105
|
const probe = { ...step, paths: probePaths, targetPaths: probePaths, task: scopeTask, baselineRef: text, snapshotId: identifier,
|
|
106
|
-
targetSymbols: array(text, 1), contextMapPath: relativePath, importContexts,
|
|
106
|
+
targetSymbols: array(text, 1), contextMapPath: relativePath, importContexts, contextRevisionDigest: sha256,
|
|
107
|
+
contextEvidence: { ...array(object({ path: relativePath, sha256 }, ['path', 'sha256'])), maxItems: 200 }, signerConfigPath: nullable(text),
|
|
107
108
|
taskDigest: sha256, ownerId: ledgerIdentifier, chainDigest: sha256, repositoryId: identifier, challenge: ledgerIdentifier };
|
|
108
109
|
add('probe', { ...probe, handoffCandidate: HANDOFF_SNAPSHOT_CANDIDATE_SCHEMA }, ['repositoryRoot']);
|
|
109
110
|
schemas.probe.allOf = [{ if: { type: 'object', anyOf: ['flowId', 'stepId', 'snapshotId'].map(field => ({ required: [field] })) },
|
|
@@ -112,7 +113,9 @@ schemas.probe.allOf = [{ if: { type: 'object', anyOf: ['flowId', 'stepId', 'snap
|
|
|
112
113
|
then: { type: 'object', required: ['flowId', 'stepId', 'snapshotId', 'taskDigest', 'ownerId', 'chainDigest', 'repositoryId', 'challenge'] } },
|
|
113
114
|
{ if: { type: 'object', required: ['handoffCandidate'] }, then: { type: 'object', required: ['flowId', 'stepId', 'snapshotId', 'paths', 'taskDigest', 'ownerId', 'chainDigest', 'repositoryId', 'challenge', 'signerConfigPath'],
|
|
114
115
|
properties: { signerConfigPath: text } } },
|
|
115
|
-
{ if: { type: 'object', required: ['targetSymbols'] }, then: { type: 'object', required: ['contextMapPath'] } }
|
|
116
|
+
{ if: { type: 'object', required: ['targetSymbols'] }, then: { type: 'object', required: ['contextMapPath'] } },
|
|
117
|
+
{ if: { type: 'object', required: ['contextRevisionDigest'] }, then: { type: 'object', required: ['importContexts'] } },
|
|
118
|
+
{ if: { type: 'object', required: ['contextEvidence'] }, then: { type: 'object', required: ['contextRevisionDigest', 'importContexts'] } }];
|
|
116
119
|
add('reassess', { ...probe, snapshotId: nullable(identifier), currentMode: modes, expectedRevision: integer(1) }, ['repositoryRoot']);
|
|
117
120
|
schemas.reassess.allOf = [{ if: { type: 'object', anyOf: [
|
|
118
121
|
...['flowId', 'stepId', 'expectedRevision', 'taskDigest', 'ownerId', 'chainDigest', 'repositoryId', 'challenge'].map(field => ({ required: [field] })),
|
|
@@ -3,6 +3,7 @@ import { parse as parseVue, parseCache } from '@vue/compiler-sfc';
|
|
|
3
3
|
import { canonical } from './local-integrity.mjs';
|
|
4
4
|
import { createScopeImportResolver } from './local-scope-import-expressions.mjs';
|
|
5
5
|
import { isDynamicFunctionReference, isTypeOnlyReference } from './local-typescript-function-reference.mjs';
|
|
6
|
+
import { uniConditionalVariants } from './local-scope-uni-conditional.mjs';
|
|
6
7
|
|
|
7
8
|
const fail = () => { throw Object.assign(new Error('Preserved modification changed an existing definition, public contract, dependency, or unsupported source boundary'),
|
|
8
9
|
{ code: 'ENTROROVE_MUTATION_DELEGATION_CHANGE' }); };
|
|
@@ -216,17 +217,25 @@ function parts(path, content) {
|
|
|
216
217
|
function retained(before, after) {
|
|
217
218
|
for (const [key, count] of before) if ((after.get(key) ?? 0) < count) fail();
|
|
218
219
|
}
|
|
220
|
+
function compareSource(path, before, after) {
|
|
221
|
+
if (!before.trim() && !after.trim()) return;
|
|
222
|
+
const original = parts(path, before), candidate = parts(path, after);
|
|
223
|
+
if (canonical(original.metadata) !== canonical(candidate.metadata) || original.scripts.length !== candidate.scripts.length) fail();
|
|
224
|
+
for (let index = 0; index < original.scripts.length; index++) {
|
|
225
|
+
const a = scriptProjection(original.scripts[index]), b = scriptProjection(candidate.scripts[index]);
|
|
226
|
+
if (canonical(a.dependencies) !== canonical(b.dependencies) || canonical(a.public) !== canonical(b.public)) fail();
|
|
227
|
+
retained(a.definitions, b.definitions); retained(a.signatures, b.signatures); retained(b.unknown, a.unknown);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
219
230
|
export function assertDelegatedSourceModification(input) {
|
|
220
231
|
const { path, before, after } = input ?? {};
|
|
221
232
|
if (typeof path !== 'string' || typeof before !== 'string' || typeof after !== 'string'
|
|
222
233
|
|| Buffer.byteLength(before) > 1_048_576 || Buffer.byteLength(after) > 1_048_576) fail();
|
|
223
234
|
try {
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
retained(a.definitions, b.definitions); retained(a.signatures, b.signatures); retained(b.unknown, a.unknown);
|
|
230
|
-
}
|
|
235
|
+
if (!/\.vue$/i.test(path)) return compareSource(path, before, after);
|
|
236
|
+
const original = uniConditionalVariants(before, path), candidate = uniConditionalVariants(after, path);
|
|
237
|
+
if (canonical(original.directives) !== canonical(candidate.directives)
|
|
238
|
+
|| canonical(original.variants.map(value => value.key)) !== canonical(candidate.variants.map(value => value.key))) fail();
|
|
239
|
+
for (let index = 0; index < original.variants.length; index++) compareSource(path, original.variants[index].source, candidate.variants[index].source);
|
|
231
240
|
} catch { fail(); }
|
|
232
241
|
}
|
|
@@ -9,6 +9,7 @@ import { assertDelegatedSourceModification } from './local-delegated-modify-anal
|
|
|
9
9
|
import { assertDesignWrite } from './design-gate.mjs';
|
|
10
10
|
import { assertPackageReplacement } from './package-replacement.mjs';
|
|
11
11
|
import { assertRollbackCandidate } from './rollback-history.mjs';
|
|
12
|
+
import { scopeContextSnapshotInput } from './scope-context-observation.mjs';
|
|
12
13
|
|
|
13
14
|
const needs = message => mutationFail14('ENTROROVE_DISCIPLINE_NEEDS_HUMAN', message);
|
|
14
15
|
function declaredWrite(input, pass, baseline) {
|
|
@@ -84,6 +85,7 @@ export async function disciplineWritePreflight(input, value, content) {
|
|
|
84
85
|
}
|
|
85
86
|
const { pass, target, binding, snapshot, ledger } = value;
|
|
86
87
|
const review = await readDisciplineSnapshot(snapshot), baseline = snapshot.manifest.files.find(file => file.path === target);
|
|
88
|
+
input = (await scopeContextSnapshotInput(input, binding.root, review.scopeContext)).input;
|
|
87
89
|
await assertDisciplineRepository(input, binding.root, review, ledger);
|
|
88
90
|
const intent = declaredWrite(input, pass, baseline);
|
|
89
91
|
const originalBytes = baseline.exists ? (await readSnapshotCopy14(input, snapshot.snapshotRoot, snapshot.manifest, target)).content : Buffer.alloc(0);
|
|
@@ -2,16 +2,22 @@ import path from 'node:path';
|
|
|
2
2
|
import { canonical, sha256 } from './local-integrity.mjs';
|
|
3
3
|
import { atomicMutationJson14, mutationFail14, readMutationJson14 } from './local-mutation-files.mjs';
|
|
4
4
|
import { disciplineRepositoryObservation } from './local-discipline-repository.mjs';
|
|
5
|
+
import { scopeContextSnapshotInput } from './scope-context-observation.mjs';
|
|
5
6
|
|
|
6
7
|
export function snapshotDisciplineBinding(value) {
|
|
7
8
|
if (!Object.hasOwn(value, 'disciplineReviewDigest')) return {};
|
|
8
9
|
if (!/^[0-9a-f]{64}$/.test(value.disciplineReviewDigest ?? '')) mutationFail14('ENTROROVE_DISCIPLINE_REVIEW_INVALID', 'Snapshot discipline review digest is invalid');
|
|
9
10
|
return { disciplineReviewDigest: value.disciplineReviewDigest };
|
|
10
11
|
}
|
|
11
|
-
export async function captureDisciplineSnapshot(input, root, location, binding) {
|
|
12
|
-
|
|
12
|
+
export async function captureDisciplineSnapshot(input, root, location, binding, contextAuthority) {
|
|
13
|
+
// A fresh S context candidate carries its own signed challenge mapping before
|
|
14
|
+
// admission. It must not be mistaken for an already accepted M snapshot head.
|
|
15
|
+
const selected = !contextAuthority && input.contextRevisionDigest !== undefined ? { input, context: null }
|
|
16
|
+
: await scopeContextSnapshotInput(input, root, contextAuthority, { initial: true });
|
|
17
|
+
const observed = await disciplineRepositoryObservation(selected.input, root, { analyze: true });
|
|
13
18
|
const review = { schemaVersion: 'entrorove.discipline-review/1.0', ...binding,
|
|
14
19
|
repositoryDigest: sha256(root).slice(0, 32), createdAt: new Date().toISOString(), ...observed,
|
|
20
|
+
...(selected.context ? { scopeContext: selected.context } : {}),
|
|
15
21
|
thresholds: { deletionAdditionRatio: null, semanticOverlap: null, directLines: null, usageScopeLimit: null } };
|
|
16
22
|
const digest = sha256(canonical(review));
|
|
17
23
|
await atomicMutationJson14(path.join(location.snapshotRoot, 'discipline-review.json'), review, { privateFrom: location.stateRoot });
|
|
@@ -148,7 +148,7 @@ async function copySource(input, root, snapshotRoot, relative, stateRoot) {
|
|
|
148
148
|
await writeMutationFile14(path.join(snapshotRoot, 'files', relative), read.content, { privateFrom: stateRoot });
|
|
149
149
|
return { path: relative, exists: true, sha256: read.sha256, mode: read.mode };
|
|
150
150
|
}
|
|
151
|
-
export async function snapshotPlan14(input, { handoffBinding: binding = {} } = {}) {
|
|
151
|
+
export async function snapshotPlan14(input, { handoffBinding: binding = {}, contextAuthority } = {}) {
|
|
152
152
|
const root = await mutationRoot14(input.repositoryRoot);
|
|
153
153
|
const flowId = mutationId14(input.flowId, 'flowId'); const stepId = mutationId14(input.stepId, 'stepId');
|
|
154
154
|
const paths = mutationPaths14(input.paths); const handoff = handoffBinding(binding);
|
|
@@ -162,7 +162,7 @@ export async function snapshotPlan14(input, { handoffBinding: binding = {} } = {
|
|
|
162
162
|
await privateMutationDirectory14(path.join(loc.snapshotRoot, 'files'), { privateFrom: loc.stateRoot });
|
|
163
163
|
const files = [];
|
|
164
164
|
for (const relative of paths) files.push(await copySource(input, root, loc.snapshotRoot, relative, loc.stateRoot));
|
|
165
|
-
const discipline = await captureDisciplineSnapshot(input, root, loc, { snapshotId, flowId, stepId });
|
|
165
|
+
const discipline = await captureDisciplineSnapshot(input, root, loc, { snapshotId, flowId, stepId }, contextAuthority);
|
|
166
166
|
const core = { snapshotId, flowId, stepId, paths, files, contractDigest, ...handoff, ...discipline };
|
|
167
167
|
const manifest = { schemaVersion: SNAPSHOT_SCHEMA, ...core, repositoryDigest: repoKey(root), copyLayout: COPY_LAYOUT,
|
|
168
168
|
createdAt: new Date().toISOString(), snapshotDigest: sha256(canonical(core)) };
|
|
@@ -5,6 +5,7 @@ import { readDisciplineSnapshot } from './local-discipline-snapshot.mjs';
|
|
|
5
5
|
import { preservedMutationDelegation, preservedMutationFail } from './local-preserved-mutation-wire.mjs';
|
|
6
6
|
import { preservedMutationProtectedFiles } from './local-preserved-mutation-state.mjs';
|
|
7
7
|
import { scopeCandidateImports } from './local-scope-repository.mjs';
|
|
8
|
+
import { scopeContextSnapshotInput } from './scope-context-observation.mjs';
|
|
8
9
|
|
|
9
10
|
function unknownTarget(path, site) {
|
|
10
11
|
const reason = typeof site?.reason === 'string' && /^[a-z-]+$/.test(site.reason) ? site.reason : 'unknown-import-source';
|
|
@@ -23,6 +24,7 @@ async function observe(input, root, expected) {
|
|
|
23
24
|
}
|
|
24
25
|
export async function capturePreservedMutationProtection(input, root, authority, snapshot) {
|
|
25
26
|
const delegation = preservedMutationDelegation(authority), review = await readDisciplineSnapshot(snapshot);
|
|
27
|
+
input = (await scopeContextSnapshotInput(input, root, review.scopeContext)).input;
|
|
26
28
|
for (const expected of delegation.targetFiles) {
|
|
27
29
|
const file = snapshot.manifest.files.find(item => item.path === expected.path);
|
|
28
30
|
if (delegation.purpose === 'planned-file-create') {
|
package/local-scope-files.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import { canonical, sha256 } from './local-integrity.mjs';
|
|
|
4
4
|
import { readBudgetedProjectFile } from './local-budget-reads.mjs';
|
|
5
5
|
import { verifiedSfnt } from './local-scope-font.mjs';
|
|
6
6
|
import { verifiedZip } from './local-scope-zip.mjs';
|
|
7
|
+
import { observeScopeAbsence, observeScopeRead, observeScopeTarget } from './local-scope-read-set.mjs';
|
|
7
8
|
|
|
8
9
|
export function scopeFail(code, message) { throw Object.assign(new Error(message), { code }); }
|
|
9
10
|
export function observedScopeRisk(files) {
|
|
@@ -66,6 +67,7 @@ export async function scopeTarget(root, relative, allowMissing = false, allowDep
|
|
|
66
67
|
current = path.join(current, part);
|
|
67
68
|
let stat;
|
|
68
69
|
try { stat = await fs.lstat(current); } catch (error) {
|
|
70
|
+
if (error.code === 'ENOENT') observeScopeAbsence(root, relative, () => scopeTarget(root, relative, true, allowDependency));
|
|
69
71
|
if (error.code === 'ENOENT' && allowMissing) return { target: path.join(root, relative), exists: false, stat: null };
|
|
70
72
|
throw error;
|
|
71
73
|
}
|
|
@@ -82,7 +84,11 @@ export async function scopeTarget(root, relative, allowMissing = false, allowDep
|
|
|
82
84
|
}
|
|
83
85
|
}
|
|
84
86
|
if (index < parts.length - 1 && !stat.isDirectory()) await invalidScopeTarget(root, relative, current, 'non-directory-ancestor');
|
|
85
|
-
if (index === parts.length - 1)
|
|
87
|
+
if (index === parts.length - 1) {
|
|
88
|
+
const target = { target: current, exists: true, stat, dependency: followDependency };
|
|
89
|
+
observeScopeTarget(root, relative, target, () => scopeTarget(root, relative, true, allowDependency));
|
|
90
|
+
return target;
|
|
91
|
+
}
|
|
86
92
|
}
|
|
87
93
|
}
|
|
88
94
|
// Only speculative, read-only resolver candidates may treat a non-directory ancestor as absent.
|
|
@@ -90,6 +96,7 @@ export async function scopeProbeTarget(root, relative, allowDependency = false)
|
|
|
90
96
|
try { return await scopeTarget(root, relative, true, allowDependency); }
|
|
91
97
|
catch (error) {
|
|
92
98
|
if (error.code !== 'ENTROROVE_FILE_INVALID' || error.reason !== 'non-directory-ancestor') throw error;
|
|
99
|
+
observeScopeAbsence(root, relative, () => scopeProbeTarget(root, relative, allowDependency));
|
|
93
100
|
return { target: path.join(root, relative), exists: false, stat: null };
|
|
94
101
|
}
|
|
95
102
|
}
|
|
@@ -109,7 +116,12 @@ async function safeScopeRead(root, relative, input, allowDependency = true) {
|
|
|
109
116
|
|| after.mtimeMs !== before.mtimeMs || after.ctimeMs !== before.ctimeMs || latest.ino !== after.ino
|
|
110
117
|
|| latest.dev !== after.dev || !dependency && latest.isSymbolicLink() || beforeReal !== afterReal) scopeFail('ENTROROVE_SCOPE_CHANGED', 'scope source changed during inspection');
|
|
111
118
|
await scopeTarget(root, relative, false, dependency);
|
|
112
|
-
|
|
119
|
+
const digest = sha256(content);
|
|
120
|
+
observeScopeRead(root, relative, { sha256: digest, mode: after.mode & 0o777, realPath: afterReal ?? target.target }, async () => {
|
|
121
|
+
const actual = await safeScopeRead(root, relative, input, allowDependency);
|
|
122
|
+
return { sha256: actual.sha256, mode: actual.mode, realPath: await fs.realpath(target.target) };
|
|
123
|
+
});
|
|
124
|
+
return { content, mode: after.mode & 0o777, sha256: digest, bytes: content.length };
|
|
113
125
|
} finally { await handle.close(); }
|
|
114
126
|
}
|
|
115
127
|
export const scopeRead = (root, relative, input = { repositoryRoot: root }) => safeScopeRead(root, relative, input, true);
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { promises as fs } from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
const context = new AsyncLocalStorage();
|
|
7
|
+
const MAX_RECORDS = 32_768, MAX_PATH_BYTES = 4_194_304, MAX_DIRECTORY_ENTRIES = 32_768;
|
|
8
|
+
const fail = (code, message) => { throw Object.assign(new Error(message), { code }); };
|
|
9
|
+
const changed = () => fail('ENTROROVE_SCOPE_CHANGED', 'Original scope resolver observations changed before signing');
|
|
10
|
+
const fingerprint = value => createHash('sha256').update(JSON.stringify(value)).digest('hex');
|
|
11
|
+
function record(root, relative, kind, expected, inspect, directoryEntries = 0) {
|
|
12
|
+
const state = context.getStore();
|
|
13
|
+
if (!state || state.sealed) return;
|
|
14
|
+
if (root !== state.root || typeof relative !== 'string' || typeof inspect !== 'function') {
|
|
15
|
+
fail('ENTROROVE_SCOPE_ATTESTATION_BINDING', 'Scope read observations require their original in-process root');
|
|
16
|
+
}
|
|
17
|
+
const key = `${kind}:${relative}`, checksum = fingerprint(expected), previous = state.records.get(key);
|
|
18
|
+
if (previous) { if (previous.checksum !== checksum) changed(); return; }
|
|
19
|
+
state.pathBytes += Buffer.byteLength(relative);
|
|
20
|
+
state.directoryEntries += directoryEntries;
|
|
21
|
+
if (state.records.size >= MAX_RECORDS || state.pathBytes > MAX_PATH_BYTES || state.directoryEntries > MAX_DIRECTORY_ENTRIES) {
|
|
22
|
+
fail('ENTROROVE_SCOPE_LIMIT', 'Scope resolver observations exceed their bounded in-process read set');
|
|
23
|
+
}
|
|
24
|
+
state.records.set(key, { checksum, inspect });
|
|
25
|
+
}
|
|
26
|
+
export function observeScopeAbsence(root, relative, inspect) {
|
|
27
|
+
record(root, relative, 'absence', false, async () => (await inspect()).exists);
|
|
28
|
+
}
|
|
29
|
+
export function observeScopeRead(root, relative, expected, inspect) {
|
|
30
|
+
record(root, relative, 'read', expected, inspect);
|
|
31
|
+
}
|
|
32
|
+
export function observeScopeTarget(root, relative, target, inspect) {
|
|
33
|
+
const kind = value => ({ exists: value.exists, file: value.stat?.isFile() ?? false, directory: value.stat?.isDirectory() ?? false });
|
|
34
|
+
record(root, relative, 'target', kind(target), async () => kind(await inspect()));
|
|
35
|
+
}
|
|
36
|
+
export function observeScopeDirectory(root, relative, names, inspect) {
|
|
37
|
+
record(root, relative, 'directory', names, inspect, names.length);
|
|
38
|
+
}
|
|
39
|
+
function sameRoot(before, after) {
|
|
40
|
+
return after.isDirectory() && !after.isSymbolicLink() && before.dev === after.dev && before.ino === after.ino;
|
|
41
|
+
}
|
|
42
|
+
export async function captureScopeReadSet(root, action) {
|
|
43
|
+
if (context.getStore() || typeof root !== 'string' || !path.isAbsolute(root) || typeof action !== 'function') {
|
|
44
|
+
fail('ENTROROVE_SCOPE_ATTESTATION_BINDING', 'Scope read capture requires one actual independent observation');
|
|
45
|
+
}
|
|
46
|
+
const original = await fs.lstat(root);
|
|
47
|
+
if (!sameRoot(original, original) || await fs.realpath(root) !== root) {
|
|
48
|
+
fail('ENTROROVE_SCOPE_ATTESTATION_BINDING', 'Scope read capture requires the original real repository root');
|
|
49
|
+
}
|
|
50
|
+
const state = { root, records: new Map(), pathBytes: 0, directoryEntries: 0, sealed: false };
|
|
51
|
+
let value;
|
|
52
|
+
try { value = await context.run(state, action); } finally { state.sealed = true; }
|
|
53
|
+
const verify = () => context.run(undefined, async () => {
|
|
54
|
+
try {
|
|
55
|
+
if (!sameRoot(original, await fs.lstat(root)) || await fs.realpath(root) !== root) changed();
|
|
56
|
+
for (const observation of state.records.values()) {
|
|
57
|
+
if (fingerprint(await observation.inspect()) !== observation.checksum) changed();
|
|
58
|
+
}
|
|
59
|
+
if (!sameRoot(original, await fs.lstat(root)) || await fs.realpath(root) !== root) changed();
|
|
60
|
+
} catch (error) {
|
|
61
|
+
if (error.code?.startsWith('ENTROROVE_')) throw error;
|
|
62
|
+
changed();
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
return { value, verify };
|
|
66
|
+
}
|
|
@@ -17,8 +17,16 @@ import { createScopeImportResolver, unresolvedImportSite } from './local-scope-i
|
|
|
17
17
|
import { isTypeOnlyReference, isDynamicFunctionReference } from './local-typescript-function-reference.mjs';
|
|
18
18
|
import { createScopeRequireContexts } from './local-scope-create-require.mjs';
|
|
19
19
|
import { resolveCreatedRequire } from './local-scope-require-resolution.mjs';
|
|
20
|
+
import { uniConditionalVariants } from './local-scope-uni-conditional.mjs';
|
|
21
|
+
import { createUniBuildInspection } from './local-scope-uni-build.mjs';
|
|
20
22
|
|
|
21
23
|
const execute = promisify(execFile);
|
|
24
|
+
const inspectionFreshness = new WeakMap();
|
|
25
|
+
export async function verifyScopeInspectionFreshness(facts) {
|
|
26
|
+
const verify = facts && typeof facts === 'object' ? inspectionFreshness.get(facts.importGraph) : null;
|
|
27
|
+
if (!verify) scopeFail('ENTROROVE_SCOPE_ATTESTATION_BINDING', 'Fresh scope verification requires its original in-process graph inspection');
|
|
28
|
+
await verify();
|
|
29
|
+
}
|
|
22
30
|
async function git(root, args, allowed = [], input = { repositoryRoot: root }) {
|
|
23
31
|
await assertBudgetRunnerAllowed(input);
|
|
24
32
|
const executable = await scopeGitExecutable({ repositoryRoot: root });
|
|
@@ -93,6 +101,10 @@ export async function gitFacts(root, files, baselineRef, input = { repositoryRoo
|
|
|
93
101
|
const scriptKinds = new Map([['.ts', ts.ScriptKind.TS], ['.mts', ts.ScriptKind.TS], ['.cts', ts.ScriptKind.TS],
|
|
94
102
|
['.tsx', ts.ScriptKind.TSX], ['.js', ts.ScriptKind.JS], ['.mjs', ts.ScriptKind.JS], ['.cjs', ts.ScriptKind.JS], ['.jsx', ts.ScriptKind.JSX]]);
|
|
95
103
|
const dynamicNames = new Set(['eval', 'Function', 'createRequire']);
|
|
104
|
+
function physicalImportSite(site, context) {
|
|
105
|
+
return { ...site, line: site.line + (context.lineOffset ?? 0),
|
|
106
|
+
column: site.line === 1 ? Math.max(1, site.column + (context.columnOffset ?? 0) - (context.syntheticPrefix ?? 0)) : site.column };
|
|
107
|
+
}
|
|
96
108
|
function staticString(expression) {
|
|
97
109
|
while (expression && ts.isParenthesizedExpression(expression)) expression = expression.expression;
|
|
98
110
|
if (!expression || (!ts.isStringLiteral(expression) && !ts.isNoSubstitutionTemplateLiteral(expression))) return null;
|
|
@@ -108,7 +120,11 @@ function globalObject(expression) {
|
|
|
108
120
|
function scriptImports(source, filename, context = {}) {
|
|
109
121
|
const parsed = ts.createSourceFile(filename, source, ts.ScriptTarget.Latest, true,
|
|
110
122
|
scriptKinds.get(path.extname(filename).toLowerCase()) ?? ts.ScriptKind.TS);
|
|
111
|
-
if (parsed.parseDiagnostics.length)
|
|
123
|
+
if (parsed.parseDiagnostics.length) {
|
|
124
|
+
const diagnostic = parsed.parseDiagnostics[0], location = parsed.getLineAndCharacterOfPosition(diagnostic.start ?? 0);
|
|
125
|
+
const site = physicalImportSite({ line: location.line + 1, column: location.character + 1 }, context);
|
|
126
|
+
scopeFail('ENTROROVE_IMPORT_INVALID', `${context.sourceFilename ?? filename}:${site.line}:${site.column}: source syntax diagnostic ${diagnostic.code}`);
|
|
127
|
+
}
|
|
112
128
|
const found = new Set(), requires = new Map(), unresolvedImports = [], resolve = createScopeImportResolver(parsed); let dynamic = false;
|
|
113
129
|
const contexts = createScopeRequireContexts(parsed, context.repositoryRoot, context.sourceFilename ?? filename);
|
|
114
130
|
const unresolved = (expression, reason, message) => { dynamic = true;
|
|
@@ -160,11 +176,14 @@ function scriptImports(source, filename, context = {}) {
|
|
|
160
176
|
};
|
|
161
177
|
visit(parsed); return { specifiers: [...found].sort(), requires: [...requires.values()], dynamic, unresolvedImports };
|
|
162
178
|
}
|
|
163
|
-
function
|
|
179
|
+
function vueVariantScripts(source, filename, label) {
|
|
164
180
|
const { descriptor, errors } = parseVue(source, { filename, sourceMap: false,
|
|
165
181
|
templateParseOptions: { expressionPlugins: ['typescript'] } });
|
|
166
182
|
vueParseCache.clear();
|
|
167
|
-
if (errors.length)
|
|
183
|
+
if (errors.length) {
|
|
184
|
+
const error = errors[0], location = error.loc?.start;
|
|
185
|
+
scopeFail('ENTROROVE_IMPORT_INVALID', `${filename}:${location?.line ?? 1}:${location?.column ?? 1}: component syntax diagnostic ${error.code ?? 'invalid'} (${label})`);
|
|
186
|
+
}
|
|
168
187
|
if (descriptor.customBlocks.length || descriptor.template?.lang || descriptor.template?.src) {
|
|
169
188
|
scopeFail('ENTROROVE_IMPORT_UNSUPPORTED', 'component dependency inspection requires supported inline template syntax');
|
|
170
189
|
}
|
|
@@ -174,29 +193,40 @@ function vueScripts(source, filename) {
|
|
|
174
193
|
if (!['js', 'javascript', 'ts', 'typescript', 'jsx', 'tsx'].includes(language)) scopeFail('ENTROROVE_IMPORT_UNSUPPORTED', 'component script language is unsupported');
|
|
175
194
|
const extension = { javascript: 'js', typescript: 'ts' }[language] ?? language;
|
|
176
195
|
scripts.push({ source: block.src ? `import ${JSON.stringify(block.src)};` : block.content, filename: `${filename}.${extension}`,
|
|
177
|
-
lineOffset: block.loc.start.line - 1, vueComponent: true, vueSetup: block === descriptor.scriptSetup });
|
|
196
|
+
lineOffset: block.loc.start.line - 1, columnOffset: block.loc.start.column - 1, vueComponent: true, vueSetup: block === descriptor.scriptSetup });
|
|
178
197
|
}
|
|
179
198
|
const seen = new WeakSet();
|
|
180
199
|
const walk = node => {
|
|
181
200
|
if (!node || typeof node !== 'object' || seen.has(node)) return;
|
|
182
201
|
seen.add(node);
|
|
183
202
|
if (node.type === 7 && ['on', 'for'].includes(node.name) && node.exp?.content) {
|
|
184
|
-
scripts.push({ source: node.name === 'for' ? `for (${node.exp.content}) {}` : node.exp.content, filename: `${filename}.ts
|
|
203
|
+
scripts.push({ source: node.name === 'for' ? `for (${node.exp.content}) {}` : node.exp.content, filename: `${filename}.ts`,
|
|
204
|
+
lineOffset: node.exp.loc.start.line - 1, columnOffset: node.exp.loc.start.column - 1, syntheticPrefix: node.name === 'for' ? 5 : 0 });
|
|
185
205
|
seen.add(node.exp);
|
|
186
206
|
}
|
|
187
|
-
if (node.type === 4 && !node.isStatic && node.content) scripts.push({ source: `void (${node.content});`, filename: `${filename}.ts
|
|
207
|
+
if (node.type === 4 && !node.isStatic && node.content) scripts.push({ source: `void (${node.content});`, filename: `${filename}.ts`,
|
|
208
|
+
lineOffset: node.loc.start.line - 1, columnOffset: node.loc.start.column - 1, syntheticPrefix: 6 });
|
|
188
209
|
for (const [key, child] of Object.entries(node)) if (!['loc', 'ast', 'parent', 'codegenNode'].includes(key)) {
|
|
189
210
|
if (Array.isArray(child)) child.forEach(walk); else walk(child);
|
|
190
211
|
}
|
|
191
212
|
};
|
|
192
213
|
walk(descriptor.template?.ast); return scripts;
|
|
193
214
|
}
|
|
194
|
-
function
|
|
215
|
+
function vueScripts(source, filename, conditional) {
|
|
216
|
+
const { variants } = conditional ?? uniConditionalVariants(source, filename), scripts = [], inspected = new Set();
|
|
217
|
+
for (const variant of variants) {
|
|
218
|
+
if (!variant.source.trim() || inspected.has(variant.source)) continue;
|
|
219
|
+
inspected.add(variant.source); scripts.push(...vueVariantScripts(variant.source, filename, variant.label));
|
|
220
|
+
}
|
|
221
|
+
if (!scripts.length && !variants.some(variant => variant.source.trim())) scopeFail('ENTROROVE_IMPORT_INVALID', `${filename}:1:1: component has no inspectable conditional branch`);
|
|
222
|
+
return scripts;
|
|
223
|
+
}
|
|
224
|
+
function importSpecifiers(content, filename, repositoryRoot, conditional) {
|
|
195
225
|
try {
|
|
196
|
-
const source = content.toString('utf8'), parts = path.extname(filename).toLowerCase() === '.vue' ? vueScripts(source, filename) : [{ source, filename }];
|
|
226
|
+
const source = content.toString('utf8'), parts = path.extname(filename).toLowerCase() === '.vue' ? vueScripts(source, filename, conditional) : [{ source, filename }];
|
|
197
227
|
const specifiers = new Set(), requires = new Map(), unresolvedImports = []; let dynamic = false;
|
|
198
228
|
for (const part of parts) { const parsed = scriptImports(part.source, part.filename, { ...part, repositoryRoot, sourceFilename: filename }); dynamic ||= parsed.dynamic;
|
|
199
|
-
unresolvedImports.push(...parsed.unresolvedImports.map(site => (
|
|
229
|
+
unresolvedImports.push(...parsed.unresolvedImports.map(site => physicalImportSite(site, part)));
|
|
200
230
|
for (const require of parsed.requires) requires.set(`${require.anchor}\0${require.specifier}`, require);
|
|
201
231
|
for (const specifier of parsed.specifiers) specifiers.add(specifier); }
|
|
202
232
|
return { specifiers: [...specifiers].sort(), requires: [...requires.values()], dynamic, unresolvedImports };
|
|
@@ -261,6 +291,19 @@ async function configMetadata(root, relative, input) {
|
|
|
261
291
|
const referenceRoot = outside ? await externalConfigReference(root, relative, resolved) : undefined;
|
|
262
292
|
return { path: relative, sha256: read.sha256, contentBase64: read.content.toString('base64'), realPath, referenceRoot };
|
|
263
293
|
}
|
|
294
|
+
async function conditionalBuildDirectory(root, relative, maximumEntries) {
|
|
295
|
+
const target = relative === '.' ? root : (await scopeTarget(root, relative, false, dependencyReference(relative))).target;
|
|
296
|
+
const directory = await fs.opendir(target); const environmentFiles = []; let entries = 0;
|
|
297
|
+
for await (const entry of directory) {
|
|
298
|
+
if (++entries > maximumEntries) scopeFail('ENTROROVE_SCOPE_LIMIT', 'Conditional build directory exceeds its bounded entry inspection');
|
|
299
|
+
if (entry.name === '.env' || entry.name.startsWith('.env.')) environmentFiles.push(entry.name);
|
|
300
|
+
}
|
|
301
|
+
return { environmentFiles, entries };
|
|
302
|
+
}
|
|
303
|
+
function conditionalBuildInspection(root, input) {
|
|
304
|
+
return createUniBuildInspection(relative => configMetadata(root, relative, input),
|
|
305
|
+
(relative, maximumEntries) => conditionalBuildDirectory(root, relative, maximumEntries));
|
|
306
|
+
}
|
|
264
307
|
async function resolveLocalImport(root, relative, required = true) {
|
|
265
308
|
if (relative.startsWith('../') || relative === '..') scopeFail('ENTROROVE_PATH_UNSAFE', 'local import escapes the repository');
|
|
266
309
|
const replacements = /\.js$/.test(relative) ? [relative.replace(/\.js$/, '.ts'), relative.replace(/\.js$/, '.tsx')] : [];
|
|
@@ -280,6 +323,7 @@ export async function importGraph(root, originals, input = { repositoryRoot: roo
|
|
|
280
323
|
const pendingCreates = new Set(originals.filter(file => file.exists === false && input.scopeCreatePaths?.includes(file.path)).map(file => file.path));
|
|
281
324
|
const workspace = await workspaceIndex(root, input);
|
|
282
325
|
const metadata = new Map(workspace.metadata.map(item => [item.path, item]));
|
|
326
|
+
const inspectBuild = conditionalBuildInspection(root, input);
|
|
283
327
|
while (pending.length) {
|
|
284
328
|
const relative = pending.shift(); if (visited.has(relative)) continue;
|
|
285
329
|
if (visited.size >= 1000) scopeFail('ENTROROVE_SCOPE_LIMIT', 'import graph exceeds the 1000-file inspection bound');
|
|
@@ -298,8 +342,10 @@ export async function importGraph(root, originals, input = { repositoryRoot: roo
|
|
|
298
342
|
const configPath = contexts[0]?.configPath;
|
|
299
343
|
const aliases = await scopeTsconfigImports(target => configMetadata(root, target, input), relative, configPath);
|
|
300
344
|
for (const observed of aliases.observations) metadata.set(observed.path, observed);
|
|
345
|
+
const conditional = /\.vue$/i.test(relative) ? uniConditionalVariants(read.content.toString('utf8'), relative) : undefined;
|
|
346
|
+
if (conditional?.directives.length) for (const observed of await inspectBuild(relative, configPath)) metadata.set(observed.path, observed);
|
|
301
347
|
const declarations = declarationModules(read.content, relative);
|
|
302
|
-
const imports = importSpecifiers(read.content, relative, root); dynamic ||= imports.dynamic;
|
|
348
|
+
const imports = importSpecifiers(read.content, relative, root, conditional); dynamic ||= imports.dynamic;
|
|
303
349
|
unresolvedImports.push(...imports.unresolvedImports.map(site => ({ path: relative, ...site })));
|
|
304
350
|
if (imports.dynamic) dynamicSources.push({ path: relative, sha256: read.sha256 });
|
|
305
351
|
for (const specifier of imports.specifiers.sort()) {
|
|
@@ -327,18 +373,27 @@ export async function importGraph(root, originals, input = { repositoryRoot: roo
|
|
|
327
373
|
}
|
|
328
374
|
const nodes = [...visited.values()].sort((a, b) => a.path.localeCompare(b.path));
|
|
329
375
|
await stableScope(root, nodes, input);
|
|
376
|
+
await inspectBuild.verify();
|
|
330
377
|
const workspaceMetadata = [...metadata.values()].sort((a, b) => a.path.localeCompare(b.path));
|
|
331
378
|
await stableScope(root, workspaceMetadata, input);
|
|
332
|
-
|
|
379
|
+
const graph = { nodes, edges: edges.sort((a, b) => `${a.from}:${a.specifier}`.localeCompare(`${b.from}:${b.specifier}`)),
|
|
333
380
|
dynamic, complete: !dynamic, dynamicSources: dynamicSources.sort((a, b) => a.path.localeCompare(b.path)), workspaceMetadata,
|
|
334
381
|
...(unresolvedImports.length ? { unresolvedImports } : {}) };
|
|
382
|
+
const actualFiles = [...nodes, ...workspaceMetadata].map(file => ({ path: file.path, sha256: file.sha256 }));
|
|
383
|
+
inspectionFreshness.set(graph, async () => { await inspectBuild.verify(); await stableScope(root, actualFiles, input); });
|
|
384
|
+
return graph;
|
|
335
385
|
}
|
|
336
386
|
export async function scopeCandidateImports(root, file, input = { repositoryRoot: root }) {
|
|
337
|
-
const parsed = importSpecifiers(Buffer.from(file.content), file.path, root);
|
|
338
387
|
const workspace = await workspaceIndex(root, input);
|
|
339
388
|
const context = (input.importContexts ?? []).filter(item => file.path === item.sourcePrefix || file.path.startsWith(`${item.sourcePrefix}/`))
|
|
340
389
|
.sort((a, b) => b.sourcePrefix.length - a.sourcePrefix.length)[0];
|
|
341
390
|
const aliases = await scopeTsconfigImports(relative => configMetadata(root, relative, input), file.path, context?.configPath);
|
|
391
|
+
const conditional = /\.vue$/i.test(file.path) ? uniConditionalVariants(file.content, file.path) : undefined;
|
|
392
|
+
let inspectBuild;
|
|
393
|
+
if (conditional?.directives.length) {
|
|
394
|
+
inspectBuild = conditionalBuildInspection(root, input); await inspectBuild(file.path, context?.configPath);
|
|
395
|
+
}
|
|
396
|
+
const parsed = importSpecifiers(Buffer.from(file.content), file.path, root, conditional);
|
|
342
397
|
const declarations = declarationModules(Buffer.from(file.content), file.path), edges = [];
|
|
343
398
|
for (const specifier of parsed.specifiers) {
|
|
344
399
|
const target = await resolveImport(root, file.path, specifier, workspace, input, aliases, context?.configPath, declarations);
|
|
@@ -349,6 +404,7 @@ export async function scopeCandidateImports(root, file, input = { repositoryRoot
|
|
|
349
404
|
await stableScope(root, resolved.metadata, input);
|
|
350
405
|
edges.push({ from: file.path, to: resolved.external ? null : resolved.target, external: resolved.external, specifier: request.specifier });
|
|
351
406
|
}
|
|
407
|
+
await inspectBuild?.verify();
|
|
352
408
|
return { edges: edges.sort((a, b) => `${a.from}:${a.specifier}`.localeCompare(`${b.from}:${b.specifier}`)), dynamic: parsed.dynamic,
|
|
353
409
|
...(parsed.unresolvedImports.length ? { unresolvedImports: parsed.unresolvedImports.map(site => ({ path: file.path, ...site })) } : {}) };
|
|
354
410
|
}
|
|
@@ -364,7 +420,11 @@ export async function collectScopeFacts(root, requested, baselineRef, knownFiles
|
|
|
364
420
|
const read = await scopeRead(root, relative, input); const boundary = await packageBoundary(root, relative, input);
|
|
365
421
|
files.push({ path: relative, exists: true, sha256: read.sha256, bytes: read.bytes, lines: lineCount(read.content), module: boundary.path });
|
|
366
422
|
}
|
|
367
|
-
const graph = await importGraph(root, files, input);
|
|
423
|
+
const graph = await importGraph(root, files, input);
|
|
424
|
+
await contextEvidence(root, graph, input);
|
|
425
|
+
const verifyGraph = inspectionFreshness.get(graph), observedMetadata = graph.workspaceMetadata.map(file => ({ path: file.path, sha256: file.sha256 }));
|
|
426
|
+
inspectionFreshness.set(graph, async () => { await verifyGraph(); await stableScope(root, observedMetadata, input); });
|
|
427
|
+
const observedGit = await observeGitFacts(root, files, baselineRef, input); const history = observedGit.facts;
|
|
368
428
|
const modules = [...new Map(graph.nodes.map(node => [node.boundary.path, node.boundary])).values()].sort((a, b) => a.path.localeCompare(b.path));
|
|
369
429
|
await stableScope(root, files, input);
|
|
370
430
|
await stableScope(root, modules.filter(module => module.manifestPath).map(module => ({ path: module.manifestPath, sha256: module.manifestDigest })), input);
|
|
@@ -377,3 +437,27 @@ export async function collectScopeFacts(root, requested, baselineRef, knownFiles
|
|
|
377
437
|
crossModule: modules.length > 1, risk: observedScopeRisk(files), importGraph: graph, git: history,
|
|
378
438
|
estimatedChangedLines };
|
|
379
439
|
}
|
|
440
|
+
async function contextEvidence(root, graph, input) {
|
|
441
|
+
if (input.contextEvidence === undefined) return;
|
|
442
|
+
if (!/^[a-f0-9]{64}$/.test(input.contextRevisionDigest ?? '') || !Array.isArray(input.contextEvidence)
|
|
443
|
+
|| input.contextEvidence.length > 200 || new Set(input.contextEvidence.map(file => file?.path)).size !== input.contextEvidence.length) {
|
|
444
|
+
scopeFail('ENTROROVE_SCOPE_CONTEXT_BINDING', 'Context evidence requires its bounded unique actual file hashes and revision');
|
|
445
|
+
}
|
|
446
|
+
const metadata = new Map(graph.workspaceMetadata.map(file => [file.path, file])); let bytes = 0;
|
|
447
|
+
for (const file of input.contextEvidence) {
|
|
448
|
+
if (!file || Object.keys(file).sort().join(',') !== 'path,sha256' || !/^[a-f0-9]{64}$/.test(file.sha256 ?? '')) {
|
|
449
|
+
scopeFail('ENTROROVE_SCOPE_CONTEXT_BINDING', 'Context evidence requires exact path and hash fields');
|
|
450
|
+
}
|
|
451
|
+
const target = await scopeTarget(root, file.path);
|
|
452
|
+
if (!target.stat.isFile() || target.stat.size > 1_048_576 || (bytes += target.stat.size) > 8_388_608) {
|
|
453
|
+
scopeFail('ENTROROVE_SCOPE_LIMIT', 'Context evidence exceeds its bounded actual file inspection');
|
|
454
|
+
}
|
|
455
|
+
const read = await scopeRead(root, file.path, input);
|
|
456
|
+
if (read.sha256 !== file.sha256 || metadata.has(file.path) && metadata.get(file.path).sha256 !== read.sha256) {
|
|
457
|
+
scopeFail('ENTROROVE_SCOPE_CHANGED', 'Actual context build evidence changed since its exact review');
|
|
458
|
+
}
|
|
459
|
+
if (!metadata.has(file.path)) metadata.set(file.path, { path: file.path, sha256: read.sha256 });
|
|
460
|
+
}
|
|
461
|
+
graph.workspaceMetadata = [...metadata.values()].sort((left, right) => left.path.localeCompare(right.path));
|
|
462
|
+
await stableScope(root, graph.workspaceMetadata, input);
|
|
463
|
+
}
|
|
@@ -2,9 +2,11 @@ import { createPrivateKey, createPublicKey, randomUUID, sign } from 'node:crypto
|
|
|
2
2
|
import { promises as fs } from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
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';
|
|
5
|
+
import { scopeFail, scopeRead, scopeControlRead, scopeRoot, stableScope } from './local-scope-files.mjs';
|
|
6
|
+
import { collectScopeFacts, verifyScopeInspectionFreshness } from './local-scope-repository.mjs';
|
|
7
7
|
import { readScopeSnapshot, verifyFreshScopeSnapshot } from './local-scope-snapshot.mjs';
|
|
8
|
+
import { scopeGitMetadata } from './local-scope-git-reads.mjs';
|
|
9
|
+
import { captureScopeReadSet } from './local-scope-read-set.mjs';
|
|
8
10
|
import { resolveScopePaths } from './local-scope-context.mjs';
|
|
9
11
|
import { rejectScopePrivileges, verifiedScopeCandidate } from './local-scope-candidate.mjs';
|
|
10
12
|
|
|
@@ -33,15 +35,45 @@ function binding(input, result) {
|
|
|
33
35
|
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
36
|
if (!digest.test(String(result.snapshotDigest ?? ''))) scopeFail('ENTROROVE_SCOPE_ATTESTATION_BINDING', 'scope signature requires a verified preserved snapshot digest');
|
|
35
37
|
}
|
|
36
|
-
|
|
38
|
+
async function signingChallenge(input, beforeSign, maximumExpiresAt) {
|
|
39
|
+
if (beforeSign === undefined) return { input, maximumExpiresAt };
|
|
40
|
+
if (typeof beforeSign !== 'function' || !input.contextRevisionDigest || Object.hasOwn(input, 'handoffCandidate')) {
|
|
41
|
+
scopeFail('ENTROROVE_SCOPE_ATTESTATION_BINDING', 'Challenge renewal is restricted to an original context observation');
|
|
42
|
+
}
|
|
43
|
+
const fresh = await beforeSign();
|
|
44
|
+
if (!fresh || Object.keys(fresh).sort().join(',') !== 'challenge,maximumExpiresAt'
|
|
45
|
+
|| !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$/.test(fresh.challenge ?? '')
|
|
46
|
+
|| !Number.isSafeInteger(fresh.maximumExpiresAt) || fresh.maximumExpiresAt <= Date.now()) {
|
|
47
|
+
scopeFail('ENTROROVE_SCOPE_ATTESTATION_BINDING', 'Actual scope signature requires a current exact challenge bound');
|
|
48
|
+
}
|
|
49
|
+
return { input: { ...input, challenge: fresh.challenge }, maximumExpiresAt: fresh.maximumExpiresAt };
|
|
50
|
+
}
|
|
51
|
+
async function verifySigningFreshness(input, root, actual) {
|
|
52
|
+
await verifyScopeInspectionFreshness(actual);
|
|
53
|
+
await verifyFreshScopeSnapshot(input, root, actual);
|
|
54
|
+
await stableScope(root, actual.importGraph.nodes, input);
|
|
55
|
+
await stableScope(root, actual.importGraph.workspaceMetadata, input);
|
|
56
|
+
await stableScope(root, actual.modules.filter(module => module.manifestPath)
|
|
57
|
+
.map(module => ({ path: module.manifestPath, sha256: module.manifestDigest })), input);
|
|
58
|
+
if (actual.git.available && (await scopeGitMetadata(root, ['rev-parse', '--verify', 'HEAD'], input, [128]))?.trim() !== actual.git.head) {
|
|
59
|
+
scopeFail('ENTROROVE_SCOPE_CHANGED', 'Git HEAD changed before the original context signature');
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
export async function attestScopeFacts(input, result, { maximumExpiresAt, beforeSign } = {}) {
|
|
37
63
|
if (maximumExpiresAt !== undefined && (!Number.isSafeInteger(maximumExpiresAt) || maximumExpiresAt < 1)) {
|
|
38
64
|
scopeFail('ENTROROVE_SCOPE_ATTESTATION_BINDING', 'scope signature deadline must be a finite exact bound');
|
|
39
65
|
}
|
|
40
66
|
rejectScopePrivileges(input);
|
|
67
|
+
if (Object.hasOwn(input, 'contextRevisionDigest') && (!/^[a-f0-9]{64}$/.test(input.contextRevisionDigest ?? '')
|
|
68
|
+
|| !Array.isArray(input.importContexts))) scopeFail('ENTROROVE_SCOPE_ATTESTATION_BINDING', 'Context proof requires its exact contexts and revision digest together');
|
|
41
69
|
if (input.signerConfigPath === undefined || input.signerConfigPath === null) return result;
|
|
42
70
|
binding(input, result);
|
|
71
|
+
const inspectedInput = canonical(input);
|
|
43
72
|
const root = await scopeRoot(input.repositoryRoot); const snapshot = await readScopeSnapshot(input, root);
|
|
44
|
-
const
|
|
73
|
+
const paths = await resolveScopePaths(input, root);
|
|
74
|
+
const inspect = () => collectScopeFacts(root, paths, input.baselineRef, snapshot.known, input);
|
|
75
|
+
const inspection = beforeSign ? await captureScopeReadSet(root, inspect) : { value: await inspect() };
|
|
76
|
+
const actual = inspection.value;
|
|
45
77
|
actual.snapshotEvidence = await verifyFreshScopeSnapshot(input, root, actual);
|
|
46
78
|
const candidate = await verifiedScopeCandidate(input, root, actual.snapshotEvidence.manifest);
|
|
47
79
|
if (root !== result.repositoryRoot || canonical(actual) !== canonical(result.facts)
|
|
@@ -60,15 +92,29 @@ export async function attestScopeFacts(input, result, { maximumExpiresAt } = {})
|
|
|
60
92
|
if (error.code === 'ENTROROVE_SCOPE_SIGNER_INVALID') throw error;
|
|
61
93
|
scopeFail('ENTROROVE_SCOPE_SIGNER_INVALID', 'scope signer configuration or signing key could not be read');
|
|
62
94
|
}
|
|
95
|
+
// The independent S scan is complete before requesting its short-lived signing challenge.
|
|
96
|
+
// The internal callback can change only the nonce and deadline, never observed facts or identity.
|
|
97
|
+
const originalInput = input;
|
|
98
|
+
({ input, maximumExpiresAt } = await signingChallenge(input, beforeSign, maximumExpiresAt));
|
|
99
|
+
if (canonical(originalInput) !== inspectedInput || root !== result.repositoryRoot
|
|
100
|
+
|| canonical(actual) !== canonical(result.facts) || actual.snapshotEvidence.snapshotDigest !== result.snapshotDigest) {
|
|
101
|
+
scopeFail('ENTROROVE_SCOPE_ATTESTATION_BINDING', 'scope signing preparation changed during challenge renewal');
|
|
102
|
+
}
|
|
103
|
+
if (beforeSign) { await inspection.verify(); await verifySigningFreshness(input, root, actual); }
|
|
104
|
+
if (canonical(originalInput) !== inspectedInput || root !== result.repositoryRoot
|
|
105
|
+
|| canonical(actual) !== canonical(result.facts) || actual.snapshotEvidence.snapshotDigest !== result.snapshotDigest) {
|
|
106
|
+
scopeFail('ENTROROVE_SCOPE_ATTESTATION_BINDING', 'scope signing preparation changed before its actual signature');
|
|
107
|
+
}
|
|
63
108
|
const now = Date.now();
|
|
64
109
|
const expiresAt = Math.min(now + config.receiptTtlMs, candidate ? Date.parse(candidate.expiresAt) : Infinity,
|
|
65
110
|
maximumExpiresAt ?? Infinity);
|
|
66
111
|
if (expiresAt <= now) scopeFail('ENTROROVE_SCOPE_ATTESTATION_BINDING', 'candidate challenge expired before its actual scope signature');
|
|
67
112
|
const core = { schemaVersion: 'entrorove.scope-facts/1.0', flowId: input.flowId, stepId: input.stepId,
|
|
68
113
|
taskDigest: input.taskDigest, ownerId: input.ownerId, chainDigest: input.chainDigest, repositoryId: input.repositoryId,
|
|
69
|
-
facts:
|
|
114
|
+
facts: actual, snapshotDigest: actual.snapshotEvidence.snapshotDigest, challenge: input.challenge,
|
|
70
115
|
issuedAt: new Date(now).toISOString(), expiresAt: new Date(expiresAt).toISOString(),
|
|
71
|
-
nonce: randomUUID(), keyId: config.keyId
|
|
116
|
+
nonce: randomUUID(), keyId: config.keyId,
|
|
117
|
+
...(input.contextRevisionDigest === undefined ? {} : { importContexts: input.importContexts, contextRevisionDigest: input.contextRevisionDigest }) };
|
|
72
118
|
const signature = sign(null, Buffer.from(canonical(core)), privateKey).toString('base64url');
|
|
73
119
|
return { ...result, attestation: { ...core, signature }, trusted: false, status: 'reported' };
|
|
74
120
|
}
|