@entrorove/skill 0.1.69 → 0.1.71

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.
@@ -0,0 +1,73 @@
1
+ import path from 'node:path';
2
+ import ts from 'typescript';
3
+ import { protectedBuildRelativePath } from './local-scope-build-read.mjs';
4
+ import { uniConditionalError } from './local-scope-uni-expressions.mjs';
5
+
6
+ export const buildConfigurationNames = ['vite.config.js', 'vite.config.mjs', 'vite.config.cjs', 'vite.config.ts',
7
+ 'vite.config.mts', 'vite.config.cts', 'vue.config.js', 'vue.config.cjs', 'webpack.config.js', 'webpack.config.cjs',
8
+ 'webpack.config.mjs', 'webpack.config.ts', 'rollup.config.js', 'rollup.config.mjs', 'rollup.config.ts',
9
+ 'babel.config.js', 'babel.config.cjs', 'babel.config.json', '.babelrc', '.babelrc.json', '.babelrc.js',
10
+ 'next.config.js', 'next.config.mjs', 'next.config.ts', 'nuxt.config.js', 'nuxt.config.ts', 'svelte.config.js'];
11
+ export function buildDataPath(base, value) {
12
+ if (typeof value !== 'string' || value.includes('\\') || value.includes('\0') || path.posix.isAbsolute(value)) {
13
+ throw Object.assign(new Error('Build data references require literal repository-relative paths'), { code: 'ENTROROVE_PATH_UNSAFE' });
14
+ }
15
+ const relative = path.posix.normalize(path.posix.join(base, value)).replace(/\/$/, '');
16
+ if (relative === '..' || relative.startsWith('../')) throw Object.assign(new Error('Build data references cannot escape the original repository'), { code: 'ENTROROVE_PATH_UNSAFE' });
17
+ if (protectedBuildRelativePath(relative)) throw uniConditionalError(relative, 0, 'Build data cannot read environment or private control values');
18
+ return relative;
19
+ }
20
+ export function buildScriptReferences(manifest, filename = 'package.json') {
21
+ const references = new Set(); let opaque = false;
22
+ for (const [name, command] of Object.entries(manifest.scripts ?? {})) {
23
+ if (typeof command !== 'string') throw uniConditionalError(filename, 0, 'Build package scripts require string commands');
24
+ const relevant = /^(?:dev|build)(?::|$)/.test(name) || /(?:^|\s)(?:--(?:experimental-loader|loader|require|import)(?:=|\s)|-r\s)/.test(command);
25
+ if (!relevant) continue;
26
+ opaque = true;
27
+ // Literal references are data only, with no shell, cwd or execution claim.
28
+ if (/[$`\n\r|<>;]/.test(command) || /(?:^|&&)\s*cd\s/.test(command)) continue;
29
+ for (const segment of command.split('&&')) {
30
+ const words = segment.match(/"[^"]*"|'[^']*'|[^\s]+/g)?.map(word => /^['"]/.test(word) ? word.slice(1, -1) : word) ?? [];
31
+ let cursor = words[0] === 'cross-env' ? 1 : 0;
32
+ while (/^[A-Za-z_][A-Za-z0-9_]*=/.test(words[cursor] ?? '')) cursor++;
33
+ if (words[cursor] === 'node' && words[cursor + 1] && !words[cursor + 1].startsWith('-')) references.add(words[cursor + 1]);
34
+ for (let index = cursor + 1; index < words.length; index++) {
35
+ if (['--config', '-c', '--experimental-loader', '--loader', '--require', '--import', '-r'].includes(words[index]) && words[index + 1]) references.add(words[++index]);
36
+ else if (/^--(?:config|experimental-loader|loader|require|import)=/.test(words[index])) references.add(words[index].slice(words[index].indexOf('=') + 1));
37
+ }
38
+ }
39
+ }
40
+ return { opaque, references: [...references].sort() };
41
+ }
42
+ export function buildModuleData(source, filename) {
43
+ let ast;
44
+ try { ast = ts.createSourceFile(filename, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); }
45
+ catch (error) { if (!(error instanceof RangeError)) throw error; throw Object.assign(uniConditionalError(filename, 0, 'Build data syntax exceeds its parser bound'), { code: 'ENTROROVE_SCOPE_LIMIT' }); }
46
+ const references = new Set(); let nodes = 0;
47
+ const inspect = node => {
48
+ if (++nodes > 8192) throw Object.assign(uniConditionalError(filename, 0, 'Build data syntax exceeds its 8192-node bound'), { code: 'ENTROROVE_SCOPE_LIMIT' });
49
+ if ((ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && node.moduleSpecifier && ts.isStringLiteralLike(node.moduleSpecifier)) references.add(node.moduleSpecifier.text);
50
+ if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === 'require'
51
+ && node.arguments.length === 1 && ts.isStringLiteralLike(node.arguments[0])) references.add(node.arguments[0].text);
52
+ if (ts.isPropertyAssignment(node) && !ts.isComputedPropertyName(node.name) && ['loader', 'use'].includes(node.name.text)
53
+ && ts.isStringLiteralLike(node.initializer)) references.add(node.initializer.text);
54
+ // Function bodies remain opaque build code. Inspect module-level literal
55
+ // declarations without simulating callbacks or proving a runtime closure.
56
+ if (ts.isFunctionLike(node)) return;
57
+ ts.forEachChild(node, inspect);
58
+ };
59
+ inspect(ast);
60
+ const literalRoots = [];
61
+ for (const statement of ast.statements) {
62
+ let expression = ts.isExportAssignment(statement) ? statement.expression : ts.isExpressionStatement(statement)
63
+ && ts.isBinaryExpression(statement.expression) && statement.expression.left.getText(ast) === 'module.exports'
64
+ && statement.expression.operatorToken.kind === ts.SyntaxKind.EqualsToken ? statement.expression.right : undefined;
65
+ while (expression && (ts.isParenthesizedExpression(expression) || ts.isAsExpression(expression) || ts.isSatisfiesExpression(expression))) expression = expression.expression;
66
+ if (!expression || !ts.isObjectLiteralExpression(expression)) continue;
67
+ const literal = Object.create(null);
68
+ for (const property of expression.properties) if (ts.isPropertyAssignment(property) && !ts.isComputedPropertyName(property.name)
69
+ && ['root', 'envDir', 'context'].includes(property.name.text) && ts.isStringLiteralLike(property.initializer)) literal[property.name.text] = property.initializer.text;
70
+ literalRoots.push(literal);
71
+ }
72
+ return { references: [...references].filter(value => value.startsWith('.')).sort(), literalRoots };
73
+ }
@@ -0,0 +1,33 @@
1
+ import { packageBoundary, scopeFail, scopePath, scopeBuildSourceRead } from './local-scope-files.mjs';
2
+
3
+ // Opaque build programs are readonly origins. They never replace source identities
4
+ // or establish a complete compiled graph, and are never executed by inspection.
5
+ export async function observeBuildFacts(root, proof, input, graph) {
6
+ if (!proof || !Array.isArray(proof.observations) || !Array.isArray(proof.unresolvedBuildSources)) {
7
+ scopeFail('ENTROROVE_IMPORT_INVALID', 'Build inspection requires explicit observed files and uncertainty origins');
8
+ }
9
+ for (const file of proof.observations) {
10
+ scopePath(file.path);
11
+ if (!/^[a-f0-9]{64}$/.test(file.sha256 ?? '')) scopeFail('ENTROROVE_IMPORT_INVALID', 'Build observation has no actual file hash');
12
+ const previous = graph.metadata.get(file.path);
13
+ if (previous && previous.sha256 !== file.sha256) scopeFail('ENTROROVE_SCOPE_CHANGED', 'Build metadata changed during source inspection');
14
+ graph.metadata.set(file.path, { path: file.path, sha256: file.sha256 });
15
+ }
16
+ for (const origin of proof.unresolvedBuildSources) {
17
+ const file = graph.metadata.get(origin.path);
18
+ if (!file || file.sha256 !== origin.sha256 || typeof origin.reason !== 'string' || !/^[a-z][a-z-]{0,95}$/.test(origin.reason)) {
19
+ scopeFail('ENTROROVE_IMPORT_INVALID', 'Opaque build origin must match an actual readonly file observation');
20
+ }
21
+ if (!graph.visited.has(file.path)) {
22
+ if (graph.visited.size >= 1000) scopeFail('ENTROROVE_SCOPE_LIMIT', 'import graph exceeds the 1000-file inspection bound');
23
+ const boundary = await packageBoundary(root, file.path, input, scopeBuildSourceRead);
24
+ graph.visited.set(file.path, { ...file, module: boundary.path, boundary });
25
+ }
26
+ if (graph.visited.get(file.path).sha256 !== file.sha256) scopeFail('ENTROROVE_SCOPE_CHANGED', 'Opaque build origin differs from its actual source node');
27
+ if (graph.dynamicSources.some(item => item.path === file.path)) continue;
28
+ graph.dynamicSources.push(file);
29
+ graph.unresolvedImports.push({ path: file.path, line: 1, column: 1, reason: origin.reason,
30
+ message: 'Build behavior remains unproven; this origin is read-only and source projection grants no compiled dependency guarantee' });
31
+ }
32
+ return proof.unresolvedBuildSources.length > 0;
33
+ }
@@ -0,0 +1,57 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import { scopeFail, scopeProbeTarget, scopeTarget } from './local-scope-files.mjs';
3
+ import { isProtectedBuildPath, protectedBuildRelativePath as protectedPath } from './local-scope-build-read.mjs';
4
+
5
+ const dependency = relative => relative.split('/').includes('node_modules');
6
+
7
+ // Inspect names and physical destinations before a build helper can read bytes.
8
+ // A dependency symlink is never permission to read an environment/control file.
9
+ export function createScopeBuildIO(root, readMetadata, hashMetadata) {
10
+ const protectedDestination = actual => isProtectedBuildPath(root, actual);
11
+ const probe = async relative => {
12
+ if (relative === '.') return { exists: true, file: false, directory: true };
13
+ const target = await scopeProbeTarget(root, relative, dependency(relative));
14
+ return { exists: target.exists, file: Boolean(target.stat?.isFile()), directory: Boolean(target.stat?.isDirectory()) };
15
+ };
16
+ const readTarget = async (relative, operation) => {
17
+ if (protectedPath(relative)) scopeFail('ENTROROVE_IMPORT_UNSUPPORTED', 'Build inspection cannot read environment or control files');
18
+ const target = await scopeProbeTarget(root, relative, dependency(relative));
19
+ if (target.exists && protectedDestination(await fs.realpath(target.target))) {
20
+ scopeFail('ENTROROVE_IMPORT_UNSUPPORTED', 'Build dependency resolves to an environment or control file; values were not read');
21
+ }
22
+ return operation(relative);
23
+ };
24
+ const read = relative => readTarget(relative, readMetadata);
25
+ const inspectDirectory = async (relative, maximumEntries) => {
26
+ if (protectedPath(relative)) scopeFail('ENTROROVE_IMPORT_UNSUPPORTED', 'Build inspection cannot enter a private control directory');
27
+ const target = relative === '.' ? root : (await scopeTarget(root, relative)).target;
28
+ const before = await fs.lstat(target), realPath = await fs.realpath(target);
29
+ if (!before.isDirectory() || before.isSymbolicLink() || protectedDestination(realPath)) {
30
+ scopeFail('ENTROROVE_IMPORT_UNSUPPORTED', 'Build directory must be a real non-private directory');
31
+ }
32
+ const identity = { dev: before.dev, ino: before.ino, mode: before.mode & 0o777, realPath };
33
+ const verify = async () => {
34
+ const current = await fs.lstat(target), actual = await fs.realpath(target);
35
+ if (!current.isDirectory() || current.isSymbolicLink() || current.dev !== identity.dev || current.ino !== identity.ino
36
+ || (current.mode & 0o777) !== identity.mode || actual !== realPath || protectedDestination(actual)) {
37
+ scopeFail('ENTROROVE_SCOPE_CHANGED', 'Build directory identity changed during name observation');
38
+ }
39
+ };
40
+ await verify();
41
+ const directory = await fs.opendir(target), environmentFiles = [], children = []; let entries = 0;
42
+ let iterating = false;
43
+ try {
44
+ await verify(); iterating = true;
45
+ for await (const entry of directory) {
46
+ if (++entries > maximumEntries) scopeFail('ENTROROVE_SCOPE_LIMIT', 'Conditional build directory exceeds its bounded entry inspection');
47
+ if (entry.name === '.env' || entry.name.startsWith('.env.')) environmentFiles.push(entry.name);
48
+ children.push({ name: entry.name, kind: entry.isSymbolicLink() ? 'symlink' : entry.isDirectory() ? 'directory'
49
+ : entry.isFile() ? 'file' : 'unsupported' });
50
+ }
51
+ } finally { if (!iterating) await directory.close(); }
52
+ await verify();
53
+ children.sort((left, right) => left.name.localeCompare(right.name));
54
+ return { environmentFiles: environmentFiles.sort(), entries, children, identity };
55
+ };
56
+ return { read, probe, inspectDirectory, ...(hashMetadata ? { hashMetadata: relative => readTarget(relative, hashMetadata) } : {}) };
57
+ }
@@ -0,0 +1,22 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ export const protectedBuildRelativePath = value => value.split(/[\\/]/).some(part => part === '.env' || part.startsWith('.env.')
5
+ || ['.git', '.entrorove', '.aimlock', '.archguard', '.ssh', '.aws', '.gnupg', '.kube', '.azure',
6
+ '.codex', '.claude', '.agents', '.npmrc', '.netrc', '.pypirc', '.gitconfig', 'id_rsa', 'id_ed25519'].includes(part)
7
+ || /\.(?:pem|key)$/i.test(part));
8
+ export function isProtectedBuildPath(root, actual) {
9
+ const relative = path.relative(root, actual);
10
+ return protectedBuildRelativePath(relative === '..' || relative.startsWith(`..${path.sep}`) ? actual : relative);
11
+ }
12
+ export async function assertBuildReadTarget(root, relative, target, descriptor) {
13
+ if (protectedBuildRelativePath(relative) || isProtectedBuildPath(root, target)) {
14
+ throw Object.assign(new Error('Build dependency resolves to an environment or control file; values were not read'),
15
+ { code: 'ENTROROVE_IMPORT_UNSUPPORTED' });
16
+ }
17
+ const actual = await fs.stat(target);
18
+ if (!actual.isFile() || actual.dev !== descriptor.dev || actual.ino !== descriptor.ino) {
19
+ throw Object.assign(new Error('Build dependency destination changed before reading any source bytes'),
20
+ { code: 'ENTROROVE_SCOPE_CHANGED' });
21
+ }
22
+ }
@@ -0,0 +1,49 @@
1
+ import { uniConditionalError } from './local-scope-uni-expressions.mjs';
2
+ import { buildDataPath } from './local-scope-build-data.mjs';
3
+ import { sha256 } from './local-integrity.mjs';
4
+
5
+ const absent = error => ['ENOENT', 'ENOTDIR'].includes(error.code);
6
+ const changed = path => Object.assign(uniConditionalError(path, 0, 'Build data or candidate absence changed during inspection'), { code: 'ENTROROVE_SCOPE_CHANGED' });
7
+ const shape = value => value ? { entries: value.entries, environmentFiles: [...value.environmentFiles].sort(),
8
+ ...(value.identity ? { identity: { ...value.identity } } : {}),
9
+ ...(value.children ? { children: value.children.map(({ name, kind }) => ({ name, kind })).sort((a, b) => a.name.localeCompare(b.name)) } : {}) } : null;
10
+ export function createBuildDataReadset(read, inspectDirectory) {
11
+ const files = new Map(), directories = new Map(); let bytes = 0, entries = 0;
12
+ const observe = async relative => {
13
+ buildDataPath('.', relative); if (files.has(relative)) return files.get(relative);
14
+ if (files.size >= 2048) throw uniConditionalError(relative, 0, 'Build data exceeds its 2048-path inspection bound');
15
+ let item; try { item = await read(relative); } catch (error) { if (!absent(error)) throw error; }
16
+ if (item) {
17
+ const content = Buffer.from(item.contentBase64, 'base64'), size = content.length;
18
+ if (size > 1_048_576 || bytes + size > 8_388_608) throw Object.assign(uniConditionalError(relative, 0, 'Build data exceeds its 1-MiB/file or 8-MiB bound'), { code: 'ENTROROVE_SCOPE_LIMIT' });
19
+ if (item.path !== relative || !/^[a-f0-9]{64}$/.test(item.sha256) || item.sha256 !== sha256(content)) throw uniConditionalError(relative, 0, 'Build data observation has no actual path and hash');
20
+ bytes += size;
21
+ }
22
+ files.set(relative, item ?? null); return item ?? null;
23
+ };
24
+ const directory = async relative => {
25
+ if (relative !== '.') buildDataPath('.', relative);
26
+ if (directories.has(relative)) return directories.get(relative);
27
+ if (directories.size >= 128 || typeof inspectDirectory !== 'function') throw uniConditionalError(relative, 0, 'Build directory absence requires bounded actual inspection');
28
+ let value; try { value = await inspectDirectory(relative, Math.min(8192, 32768 - entries)); } catch (error) { if (!absent(error)) throw error; }
29
+ if (value) {
30
+ if (!Number.isInteger(value.entries) || value.entries < 0 || !Array.isArray(value.environmentFiles)
31
+ || value.entries > 8192 || entries + value.entries > 32768) throw Object.assign(uniConditionalError(relative, 0, 'Build directory exceeds its bounded name inspection'), { code: 'ENTROROVE_SCOPE_LIMIT' });
32
+ entries += value.entries;
33
+ }
34
+ const result = shape(value); directories.set(relative, result); return result;
35
+ };
36
+ const verify = async () => {
37
+ for (const [relative, expected] of files) {
38
+ let actual; try { actual = await read(relative); } catch (error) { if (!absent(error)) throw error; }
39
+ if ((actual?.sha256 ?? null) !== (expected?.sha256 ?? null) || actual?.realPath !== expected?.realPath
40
+ || actual?.mode !== expected?.mode || JSON.stringify(actual?.referenceRoot) !== JSON.stringify(expected?.referenceRoot)) throw changed(relative);
41
+ }
42
+ let checked = 0;
43
+ for (const [relative, expected] of directories) {
44
+ let actual; try { actual = await inspectDirectory(relative, Math.min(8192, 32768 - checked)); } catch (error) { if (!absent(error)) throw error; }
45
+ checked += actual?.entries ?? 0; if (JSON.stringify(shape(actual)) !== JSON.stringify(expected)) throw changed(relative);
46
+ }
47
+ };
48
+ return { observe, directory, verify, get observations() { return [...files.values()].filter(Boolean).map(({ path, sha256 }) => ({ path, sha256 })).sort((a, b) => a.path.localeCompare(b.path)); } };
49
+ }
@@ -4,6 +4,8 @@ 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';
8
+ import { assertBuildReadTarget, protectedBuildRelativePath } from './local-scope-build-read.mjs';
7
9
 
8
10
  export function scopeFail(code, message) { throw Object.assign(new Error(message), { code }); }
9
11
  export function observedScopeRisk(files) {
@@ -66,6 +68,7 @@ export async function scopeTarget(root, relative, allowMissing = false, allowDep
66
68
  current = path.join(current, part);
67
69
  let stat;
68
70
  try { stat = await fs.lstat(current); } catch (error) {
71
+ if (error.code === 'ENOENT') observeScopeAbsence(root, relative, () => scopeTarget(root, relative, true, allowDependency));
69
72
  if (error.code === 'ENOENT' && allowMissing) return { target: path.join(root, relative), exists: false, stat: null };
70
73
  throw error;
71
74
  }
@@ -82,7 +85,11 @@ export async function scopeTarget(root, relative, allowMissing = false, allowDep
82
85
  }
83
86
  }
84
87
  if (index < parts.length - 1 && !stat.isDirectory()) await invalidScopeTarget(root, relative, current, 'non-directory-ancestor');
85
- if (index === parts.length - 1) return { target: current, exists: true, stat, dependency: followDependency };
88
+ if (index === parts.length - 1) {
89
+ const target = { target: current, exists: true, stat, dependency: followDependency };
90
+ observeScopeTarget(root, relative, target, () => scopeTarget(root, relative, true, allowDependency));
91
+ return target;
92
+ }
86
93
  }
87
94
  }
88
95
  // Only speculative, read-only resolver candidates may treat a non-directory ancestor as absent.
@@ -90,10 +97,11 @@ export async function scopeProbeTarget(root, relative, allowDependency = false)
90
97
  try { return await scopeTarget(root, relative, true, allowDependency); }
91
98
  catch (error) {
92
99
  if (error.code !== 'ENTROROVE_FILE_INVALID' || error.reason !== 'non-directory-ancestor') throw error;
100
+ observeScopeAbsence(root, relative, () => scopeProbeTarget(root, relative, allowDependency));
93
101
  return { target: path.join(root, relative), exists: false, stat: null };
94
102
  }
95
103
  }
96
- async function safeScopeRead(root, relative, input, allowDependency = true) {
104
+ async function safeScopeRead(root, relative, input, allowDependency = true, verifyReadTarget) {
97
105
  const dependency = allowDependency && dependencyReference(relative), target = await scopeTarget(root, relative, false, dependency);
98
106
  if (!target.stat.isFile()) await invalidScopeTarget(root, relative, target.target, 'not-regular-file');
99
107
  if (target.stat.size > 16_777_216) scopeFail('ENTROROVE_SCOPE_LIMIT', 'scope source exceeds the 16 MiB inspection bound');
@@ -101,7 +109,15 @@ async function safeScopeRead(root, relative, input, allowDependency = true) {
101
109
  try {
102
110
  const before = await handle.stat();
103
111
  const beforeReal = dependency ? await fs.realpath(target.target) : null;
104
- const content = input ? await readBudgetedProjectFile(input, target.target, { sourcePath: relative }) : await handle.readFile();
112
+ if (verifyReadTarget) {
113
+ if (before.ino !== target.stat.ino || before.dev !== target.stat.dev) scopeFail('ENTROROVE_SCOPE_CHANGED', 'Build source changed before its descriptor was opened');
114
+ await verifyReadTarget({ target: beforeReal ?? target.target, descriptor: before });
115
+ }
116
+ const boundedVerify = verifyReadTarget ? async actual => {
117
+ if (actual.descriptor.ino !== before.ino || actual.descriptor.dev !== before.dev) scopeFail('ENTROROVE_SCOPE_CHANGED', 'Build source changed before its accounted descriptor was opened');
118
+ await verifyReadTarget(actual);
119
+ } : undefined;
120
+ const content = input ? await readBudgetedProjectFile(input, target.target, { sourcePath: relative, verifyReadTarget: boundedVerify }) : await handle.readFile();
105
121
  const after = await handle.stat();
106
122
  const latest = dependency ? await fs.stat(target.target) : await fs.lstat(target.target);
107
123
  const afterReal = dependency ? await fs.realpath(target.target) : null;
@@ -109,10 +125,19 @@ async function safeScopeRead(root, relative, input, allowDependency = true) {
109
125
  || after.mtimeMs !== before.mtimeMs || after.ctimeMs !== before.ctimeMs || latest.ino !== after.ino
110
126
  || latest.dev !== after.dev || !dependency && latest.isSymbolicLink() || beforeReal !== afterReal) scopeFail('ENTROROVE_SCOPE_CHANGED', 'scope source changed during inspection');
111
127
  await scopeTarget(root, relative, false, dependency);
112
- return { content, mode: after.mode & 0o777, sha256: sha256(content), bytes: content.length };
128
+ const digest = sha256(content);
129
+ observeScopeRead(root, relative, { sha256: digest, mode: after.mode & 0o777, realPath: afterReal ?? target.target }, async () => {
130
+ const actual = await safeScopeRead(root, relative, input, allowDependency, verifyReadTarget);
131
+ return { sha256: actual.sha256, mode: actual.mode, realPath: await fs.realpath(target.target) };
132
+ });
133
+ return { content, mode: after.mode & 0o777, sha256: digest, bytes: content.length };
113
134
  } finally { await handle.close(); }
114
135
  }
115
136
  export const scopeRead = (root, relative, input = { repositoryRoot: root }) => safeScopeRead(root, relative, input, true);
137
+ export function scopeBuildSourceRead(root, relative, input = { repositoryRoot: root }) {
138
+ if (protectedBuildRelativePath(relative)) scopeFail('ENTROROVE_IMPORT_UNSUPPORTED', 'Build inspection cannot read environment or credential values');
139
+ return safeScopeRead(root, relative, input, true, ({ target, descriptor }) => assertBuildReadTarget(root, relative, target, descriptor));
140
+ }
116
141
  // Internal control roles only: signed manifest metadata and already validated private signer files.
117
142
  export const scopeControlRead = (root, relative) => safeScopeRead(root, relative, null, false);
118
143
  const excluded = new Set(['.git', 'node_modules', '.entrorove', '.aimlock', '.archguard']);
@@ -140,12 +165,12 @@ export function lineCount(content) {
140
165
  if (!content.length || verifiedSfnt(content) || verifiedZip(content)) return 0;
141
166
  return content.toString('utf8').split(/\r\n|\n|\r/).length - (/[\r\n]$/.test(content.toString('utf8')) ? 1 : 0);
142
167
  }
143
- export async function packageBoundary(root, relative, input = { repositoryRoot: root }) {
168
+ export async function packageBoundary(root, relative, input = { repositoryRoot: root }, readSource = scopeRead) {
144
169
  let directory = path.posix.dirname(relative);
145
170
  while (true) {
146
171
  const manifestPath = directory === '.' ? 'package.json' : `${directory}/package.json`;
147
172
  try {
148
- const read = await scopeRead(root, manifestPath, input); let manifest;
173
+ const read = await readSource(root, manifestPath, input); let manifest;
149
174
  try { manifest = JSON.parse(read.content.toString('utf8')); } catch { scopeFail('ENTROROVE_PACKAGE_INVALID', 'package manifest is not valid JSON'); }
150
175
  return { path: directory, manifestPath, manifestDigest: read.sha256, name: typeof manifest.name === 'string' ? manifest.name : null };
151
176
  } catch (error) { if (error.code !== 'ENOENT') throw error; }
@@ -154,11 +179,11 @@ export async function packageBoundary(root, relative, input = { repositoryRoot:
154
179
  }
155
180
  return { path: relative.includes('/') ? relative.split('/')[0] : '.', manifestPath: null, manifestDigest: null, name: null };
156
181
  }
157
- export async function stableScope(root, files, input = { repositoryRoot: root }) {
182
+ export async function stableScope(root, files, input = { repositoryRoot: root }, readSource = scopeRead) {
158
183
  for (const file of files) {
159
184
  if (file.exists === false) {
160
185
  if ((await scopeTarget(root, file.path, true)).exists) scopeFail('ENTROROVE_SCOPE_CHANGED', 'a deleted target reappeared during inspection');
161
- } else if ((await scopeRead(root, file.path, input)).sha256 !== file.sha256) scopeFail('ENTROROVE_SCOPE_CHANGED', 'scope files changed before the observation completed');
186
+ } else if ((await readSource(root, file.path, input)).sha256 !== file.sha256) scopeFail('ENTROROVE_SCOPE_CHANGED', 'scope files changed before the observation completed');
162
187
  }
163
188
  }
164
189
  export const scopeDigest = value => sha256(canonical(value));
@@ -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
+ }