@entrorove/skill 0.1.70 → 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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.71 — 2026-10-20
4
+
5
+ - 通用分离源码依赖事实与未知构建行为:动态配置、宏及加载器保留真实哈希与只读来源,不执行项目代码,不把构建未知标为完整。原审批与独立 S/M 签名仅允许已知源码的非结构修改;隐私文件、路径、资源预算、未知目标及配置漂移继续明确拒绝,实际构建与测试仍需原流程验收。
6
+ - Separate raw source dependency facts from opaque build behavior across projects. Preserve actual hashes and read-only origins for dynamic configuration, macros and loaders without executing project code or claiming compiled completeness. Original approvals and separate S/M signatures permit only known-source nonstructural modifications; private files, unsafe paths, resource budgets, unknown targets and configuration drift remain explicit refusals. Actual builds and tests still require original-flow acceptance.
7
+
3
8
  ## 0.1.70 — 2026-10-20
4
9
 
5
10
  - 云端沙箱固定中英文输出语言,界面、通知和 Blueprint 文档正文按对应语种显示,原始证据单列来源并保留旧流程精确版本。修复 uni-app 默认条件编译分支解析与长扫描后的原任务签名挑战衔接,核对真实读集并保留原审批、完整路径、快照及失败历史。
package/SKILL.md CHANGED
@@ -5,7 +5,7 @@ description: Execute a task selected for Entrorove through explicit SDK operatio
5
5
 
6
6
  # entrorove · 熵巡
7
7
 
8
- Version: 0.1.70.
8
+ Version: 0.1.71.
9
9
 
10
10
  Entrorove applies only to the task for which it is selected; reading these instructions, discovering capabilities or installing the package never selects or governs another task. Hooks are optional. Reading this document or calling an entry once does not execute the task. On every later turn, including after compaction, continue the original task and its required next operation before dependent work. Keep the original goal, scope, account, flow, cloud task and failure records. Never describe planning, a reported step or model output as verified delivery.
11
11
 
@@ -22,7 +22,7 @@ Entrorove applies only to the task for which it is selected; reading these instr
22
22
  4. After real cloud acceptance, immediately display the returned entering status in the task’s selected language, including its **taskNumber**. Preserve `taskNumber` verbatim: it is the homepage sandbox tab number. Keep its UUID `taskId` separately. Run the returned `cloud-watch`, show each real public event and its cursor, and continue the original accepted flow from status. A command log alone is insufficient. No accepted cloud task means no cloud ID or model-execution claim.
23
23
  5. For an accepted `flowId`, execute the returned `nextStep` with its required payload. Discover the schema before constructing input. An empty payload is valid only when that operation's schema allows it. Reconnect and refresh only the original flow. A changed snapshot is a binding failure to investigate; do not invent a fresh successful baseline, rebuild a flow to evade it, or erase the old failure. If a fresh scope proof returns `ENTROROVE_SCOPE_PLANNING_REASSESSMENT_REQUIRED` before plan freeze, follow its explicit `workflow-scope-reassess` recovery and retry the original complete request; a local reassessment or snapshot refresh alone does not renew the accepted gate. If the terminal flow truly owns a stale task slot, use the explicit `flow-abandon` lifecycle operation described below before routing the original task again.
24
24
  6. All governed mutations use the original authority: `local guarded-write`, `local guarded-delete`, `local guarded-move`, or the original frozen approved runner plan. Native patches, scripts, terminal writes, database mutations and unknown MCP operations must not substitute for the controlled entry. User-authorized task messages use the host's message tool and remain subject to host approval; sending a report does not change the flow or grant execution authority. **A failed gate or unknown outcome must never be replaced with another executor or reported as local success.** Query the original request read-only, repair the cause, and obtain the original flow's valid authority before proceeding. If the original scope challenge preparation is absent from authenticated status, `scope-amend` may use the discovered `scope-challenge-retire` lifecycle operation. The server validates the same approved proposal and receiver, atomically fences only an unadmitted challenge, and records a signed retirement without executing that challenge. Recover and acknowledge the original retirement result with GET before a fresh challenge attempt; retain both requests and all failures. An admitted request, issued nonce, scope application or mutation has no missing-challenge retirement path. An uncertain retirement itself permits only its original GET.
25
- For a member scope, follow a returned `localAction` before submitting execution evidence. `mutation-connect` obtains only the original task's limited editing pass; it keeps unresolved loader files and project configuration read only. After actual writes, refresh the original scope and run `mutation-settle`. Use the returned `previousPassId` for further edits. In a signed member-local plan, `workflow-observe-step` runs the original approved command and tests without a validator private key. A server-signed `localAcceptance` advances local dependencies; a failed test does not. Preserve the raw observation and its absent independent receipt. See [IDE-EXECUTION.md](IDE-EXECUTION.md#local-ide-acceptance).
25
+ For a member scope, follow a returned `localAction` before submitting execution evidence. `mutation-connect` obtains only the original task's limited editing pass. Its non-structural modify role requires known target source imports, preserves source imports and public contracts, and keeps unresolved origins, other observed dependencies and project configuration read only. Build scripts, macros and generated sources can remain opaque; source observations and this editing role never prove a complete compiled dependency graph or successful tests. After actual writes, refresh the original scope and run `mutation-settle`. Use the returned `previousPassId` for further edits. In a signed member-local plan, `workflow-observe-step` runs the original frozen command and tests without a validator private key. A server-signed `localAcceptance` advances local dependencies; a failed test does not. A frozen independent plan retains its independent validation requirement. Preserve the raw observation and its absent independent receipt. See [IDE-EXECUTION.md](IDE-EXECUTION.md#local-ide-acceptance).
26
26
  7. Read [REUSE-DISCIPLINE.md](REUSE-DISCIPLINE.md) when modifying an existing system. The comparison baseline is the existing implementation in that same repository. It never means another project, another account, or copying external systems.
27
27
  Never overwrite a newer package with older bytes, relabel an old archive as new, or roll back because the current state is uncertain. Package identity/version checks and immutable historical byte observations run in the guarded entries. A refusal requires preserved inputs and diagnosis; a `force`, `rollback`, timestamp or filename is not evidence. Intentional rollback uses the original independently signed merge rollback authorization, transaction journal, exact current/restore hashes and fresh original mutation pass. Preserve old failures, receipts and unknown effects; a successful rollback never makes the failed operation successful.
28
28
  **Before preparing or writing any UI implementation, read [DESIGN-GATES.md](DESIGN-GATES.md).** Record the whole-site flow first, fetch current mainstream layout references and the specified framework's official rules, create actual mockups or prototypes using the host image/design tool, and show them to the user. Respect their choice of visual format; a prototype is a valid approval baseline. Obtain the original persisted design confirmation before UI code preparation. Carry that exact design approval through the original scope, plan and mutation gates. Neither a caller flag nor a text-only description substitutes for this sequence. Finish real functional tests, actual browser interaction/layout/style checks and original user visual acceptance before delivery. Keep Entrorove unchanged in every language.
@@ -102,7 +102,7 @@ export async function readBudgetedProjectFile(input, target, options = {}) {
102
102
  const root = await fs.realpath(input.repositoryRoot);
103
103
  const sourcePath = options.sourcePath ?? path.relative(root, target).split(path.sep).join('/');
104
104
  const before = await fs.lstat(target, { bigint: true });
105
- const result = await budgetReadBuffer({ ...binding, path: sourcePath }, { target });
105
+ const result = await budgetReadBuffer({ ...binding, path: sourcePath }, { target, verifyReadTarget: options.verifyReadTarget });
106
106
  const after = await fs.lstat(target, { bigint: true });
107
107
  if (!sameSourceStat(before, after)) fail('ENTROROVE_SCOPE_CHANGED', 'budgeted project source changed during its bound observation');
108
108
  projectBuffers.set(result.content, Object.freeze({ home: await fs.realpath(binding.home ?? os.homedir()),
@@ -110,7 +110,12 @@ export async function readBudgetedProjectFile(input, target, options = {}) {
110
110
  return options.encoding ? result.content.toString(options.encoding) : result.content;
111
111
  }
112
112
  const handle = await fs.open(target, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
113
- try { if (!(await handle.stat()).isFile()) fail('ENTROROVE_FILE_INVALID', 'source must be a regular file'); return await handle.readFile(options.encoding ? { encoding: options.encoding } : undefined); }
113
+ try {
114
+ const descriptor = await handle.stat();
115
+ if (!descriptor.isFile()) fail('ENTROROVE_FILE_INVALID', 'source must be a regular file');
116
+ if (options.verifyReadTarget) await options.verifyReadTarget({ target: await fs.realpath(target), descriptor });
117
+ return await handle.readFile(options.encoding ? { encoding: options.encoding } : undefined);
118
+ }
114
119
  finally { await handle.close(); }
115
120
  }
116
121
 
package/local-budget.mjs CHANGED
@@ -91,7 +91,7 @@ function assertReadAllowed(state, relative, tokens, now = Date.now()) {
91
91
  if (tokens > state.maxTokenEstimate - state.tokenEstimate) fail('ENTROROVE_BUDGET_EXHAUSTED', 'read token budget exhausted');
92
92
  if (now - Date.parse(state.startedAt) >= state.maxDurationMs) fail('ENTROROVE_BUDGET_EXHAUSTED', 'read duration budget exhausted');
93
93
  }
94
- async function readSource(location, state, relative, target, received) {
94
+ async function readSource(location, state, relative, target, received, verifyReadTarget) {
95
95
  const binding = { home: location.home, repositoryRoot: location.root, flowId: location.flowId, path: relative };
96
96
  await assertBudgetHandoffReadAllowed(binding, state);
97
97
  const actualTarget = await budgetSourceTarget(location, relative, target);
@@ -101,6 +101,7 @@ async function readSource(location, state, relative, target, received) {
101
101
  try {
102
102
  const stat = await handle.stat(); if (!stat.isFile()) fail('ENTROROVE_FILE_INVALID', 'budget source must be a regular file');
103
103
  await assertDescriptorTarget(target, actualTarget, stat);
104
+ if (verifyReadTarget) await verifyReadTarget({ target: actualTarget, descriptor: stat });
104
105
  assertReadAllowed(state, relative, Math.ceil(stat.size / 4));
105
106
  const cap = state.enforced ? Math.max(0, (state.maxTokenEstimate - state.tokenEstimate) * 4) : Number.MAX_SAFE_INTEGER;
106
107
  const chunks = []; let size = 0;
@@ -131,7 +132,7 @@ export async function budgetReadBuffer(input, options = {}) {
131
132
  if (state.grant?.purpose === 'handoff-read') requireLiveBudget(budgetHandoffReadValidationState(state));
132
133
  else state = consumeBudgetRenewal({ state });
133
134
  const target = options.target ?? path.join(location.root, relative);
134
- const content = await readSource(location, state, relative, target, received); const tokens = Math.ceil(content.length / 4);
135
+ const content = await readSource(location, state, relative, target, received, options.verifyReadTarget); const tokens = Math.ceil(content.length / 4);
135
136
  const updated = { ...state, files: state.files.includes(relative) ? state.files : [...state.files, relative], readCalls: state.readCalls + 1, tokenEstimate: state.tokenEstimate + tokens };
136
137
  const committed = await commitBudget(location, loaded, updated, 'budget-read', { path: relative, tokens, bytes: content.length,
137
138
  readId, renewedIntervals: (state.autoRenew?.renewalsUsed ?? 0) - (loaded.state.autoRenew?.renewalsUsed ?? 0), reason: state.autoRenew?.lastRenewalReason ?? null });
@@ -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
+ }
@@ -5,6 +5,7 @@ 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
7
  import { observeScopeAbsence, observeScopeRead, observeScopeTarget } from './local-scope-read-set.mjs';
8
+ import { assertBuildReadTarget, protectedBuildRelativePath } from './local-scope-build-read.mjs';
8
9
 
9
10
  export function scopeFail(code, message) { throw Object.assign(new Error(message), { code }); }
10
11
  export function observedScopeRisk(files) {
@@ -100,7 +101,7 @@ export async function scopeProbeTarget(root, relative, allowDependency = false)
100
101
  return { target: path.join(root, relative), exists: false, stat: null };
101
102
  }
102
103
  }
103
- async function safeScopeRead(root, relative, input, allowDependency = true) {
104
+ async function safeScopeRead(root, relative, input, allowDependency = true, verifyReadTarget) {
104
105
  const dependency = allowDependency && dependencyReference(relative), target = await scopeTarget(root, relative, false, dependency);
105
106
  if (!target.stat.isFile()) await invalidScopeTarget(root, relative, target.target, 'not-regular-file');
106
107
  if (target.stat.size > 16_777_216) scopeFail('ENTROROVE_SCOPE_LIMIT', 'scope source exceeds the 16 MiB inspection bound');
@@ -108,7 +109,15 @@ async function safeScopeRead(root, relative, input, allowDependency = true) {
108
109
  try {
109
110
  const before = await handle.stat();
110
111
  const beforeReal = dependency ? await fs.realpath(target.target) : null;
111
- 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();
112
121
  const after = await handle.stat();
113
122
  const latest = dependency ? await fs.stat(target.target) : await fs.lstat(target.target);
114
123
  const afterReal = dependency ? await fs.realpath(target.target) : null;
@@ -118,13 +127,17 @@ async function safeScopeRead(root, relative, input, allowDependency = true) {
118
127
  await scopeTarget(root, relative, false, dependency);
119
128
  const digest = sha256(content);
120
129
  observeScopeRead(root, relative, { sha256: digest, mode: after.mode & 0o777, realPath: afterReal ?? target.target }, async () => {
121
- const actual = await safeScopeRead(root, relative, input, allowDependency);
130
+ const actual = await safeScopeRead(root, relative, input, allowDependency, verifyReadTarget);
122
131
  return { sha256: actual.sha256, mode: actual.mode, realPath: await fs.realpath(target.target) };
123
132
  });
124
133
  return { content, mode: after.mode & 0o777, sha256: digest, bytes: content.length };
125
134
  } finally { await handle.close(); }
126
135
  }
127
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
+ }
128
141
  // Internal control roles only: signed manifest metadata and already validated private signer files.
129
142
  export const scopeControlRead = (root, relative) => safeScopeRead(root, relative, null, false);
130
143
  const excluded = new Set(['.git', 'node_modules', '.entrorove', '.aimlock', '.archguard']);
@@ -152,12 +165,12 @@ export function lineCount(content) {
152
165
  if (!content.length || verifiedSfnt(content) || verifiedZip(content)) return 0;
153
166
  return content.toString('utf8').split(/\r\n|\n|\r/).length - (/[\r\n]$/.test(content.toString('utf8')) ? 1 : 0);
154
167
  }
155
- export async function packageBoundary(root, relative, input = { repositoryRoot: root }) {
168
+ export async function packageBoundary(root, relative, input = { repositoryRoot: root }, readSource = scopeRead) {
156
169
  let directory = path.posix.dirname(relative);
157
170
  while (true) {
158
171
  const manifestPath = directory === '.' ? 'package.json' : `${directory}/package.json`;
159
172
  try {
160
- const read = await scopeRead(root, manifestPath, input); let manifest;
173
+ const read = await readSource(root, manifestPath, input); let manifest;
161
174
  try { manifest = JSON.parse(read.content.toString('utf8')); } catch { scopeFail('ENTROROVE_PACKAGE_INVALID', 'package manifest is not valid JSON'); }
162
175
  return { path: directory, manifestPath, manifestDigest: read.sha256, name: typeof manifest.name === 'string' ? manifest.name : null };
163
176
  } catch (error) { if (error.code !== 'ENOENT') throw error; }
@@ -166,11 +179,11 @@ export async function packageBoundary(root, relative, input = { repositoryRoot:
166
179
  }
167
180
  return { path: relative.includes('/') ? relative.split('/')[0] : '.', manifestPath: null, manifestDigest: null, name: null };
168
181
  }
169
- export async function stableScope(root, files, input = { repositoryRoot: root }) {
182
+ export async function stableScope(root, files, input = { repositoryRoot: root }, readSource = scopeRead) {
170
183
  for (const file of files) {
171
184
  if (file.exists === false) {
172
185
  if ((await scopeTarget(root, file.path, true)).exists) scopeFail('ENTROROVE_SCOPE_CHANGED', 'a deleted target reappeared during inspection');
173
- } 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');
174
187
  }
175
188
  }
176
189
  export const scopeDigest = value => sha256(canonical(value));