@entrorove/skill 0.1.52 → 0.1.54

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CAPABILITY-MAP.md +14 -2
  2. package/CHANGELOG.md +12 -0
  3. package/DELIVERY-BOUNDARIES.md +4 -2
  4. package/IDE-EXECUTION.md +25 -3
  5. package/SKILL.md +8 -4
  6. package/cli.mjs +4 -6
  7. package/cloud-mutation-authority.mjs +96 -0
  8. package/connected-cli.mjs +20 -0
  9. package/ide-code-mode.mjs +30 -8
  10. package/ide-command.mjs +27 -1
  11. package/ide-hook.mjs +70 -17
  12. package/ide-results.mjs +8 -6
  13. package/ide-session.mjs +44 -1
  14. package/local-approved-plan.mjs +3 -21
  15. package/local-approved-process.mjs +54 -0
  16. package/local-approved-supervisor.mjs +99 -0
  17. package/local-cli.mjs +13 -2
  18. package/local-contracts.mjs +2 -0
  19. package/local-delegated-modify-analysis.mjs +232 -0
  20. package/local-discipline-actions.mjs +2 -0
  21. package/local-discipline-gate.mjs +3 -1
  22. package/local-error.mjs +3 -0
  23. package/local-execution.mjs +1 -1
  24. package/local-mutation-authority.mjs +23 -5
  25. package/local-mutation-batch.mjs +1 -0
  26. package/local-mutation-binding.mjs +14 -5
  27. package/local-mutation-consumption.mjs +93 -0
  28. package/local-mutation-pass-store.mjs +6 -4
  29. package/local-mutation-wire.mjs +21 -4
  30. package/local-preserved-mutation-protection.mjs +51 -0
  31. package/local-preserved-mutation-signer.mjs +81 -0
  32. package/local-preserved-mutation-state.mjs +96 -0
  33. package/local-preserved-mutation-wire.mjs +49 -0
  34. package/local-scope-create-require.mjs +142 -0
  35. package/local-scope-import-expressions.mjs +2 -2
  36. package/local-scope-repository.mjs +42 -15
  37. package/local-scope-require-resolution.mjs +78 -0
  38. package/manifest.json +1 -1
  39. package/mutation-client.mjs +290 -0
  40. package/package.json +21 -2
  41. package/release-state.json +48 -29
  42. package/release.mjs +29 -0
  43. package/releases.json +12 -0
  44. package/remote-client.mjs +2 -1
  45. package/workflow-observation-client.mjs +44 -0
  46. package/workflow-observation-connection.mjs +35 -0
  47. package/workflow-observation-remote.mjs +84 -0
  48. package/workflow-observation-runner.mjs +87 -0
  49. package/workflow-observation-state.mjs +36 -0
  50. package/workflow-observation-wire.mjs +69 -0
@@ -0,0 +1,51 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import { canonical } from './local-integrity.mjs';
3
+ import { scopeRead, scopeTarget } from './local-scope-files.mjs';
4
+ import { readDisciplineSnapshot } from './local-discipline-snapshot.mjs';
5
+ import { preservedMutationDelegation, preservedMutationFail } from './local-preserved-mutation-wire.mjs';
6
+ import { preservedMutationProtectedFiles } from './local-preserved-mutation-state.mjs';
7
+ import { scopeCandidateImports } from './local-scope-repository.mjs';
8
+
9
+ function unknownTarget(path, site) {
10
+ const reason = typeof site?.reason === 'string' && /^[a-z-]+$/.test(site.reason) ? site.reason : 'unknown-import-source';
11
+ throw Object.assign(new Error(`Preserved mutation cannot modify unknown dependency source ${JSON.stringify(path)}: ${reason}`),
12
+ { code: 'ENTROROVE_MUTATION_DELEGATION_INVALID', path, reason });
13
+ }
14
+
15
+ async function observe(input, root, expected) {
16
+ const before = await scopeTarget(root, expected.path, false, true), resolvedPath = await fs.realpath(before.target);
17
+ const read = await scopeRead(root, expected.path, input), after = await scopeTarget(root, expected.path, false, true);
18
+ if (read.sha256 !== expected.sha256 || before.stat.dev !== after.stat.dev || before.stat.ino !== after.stat.ino
19
+ || before.stat.mode !== after.stat.mode || resolvedPath !== await fs.realpath(after.target)) {
20
+ preservedMutationFail(`Preserved mutation dependency ${JSON.stringify(expected.path)} changed bytes, permissions or physical identity`);
21
+ }
22
+ return { ...expected, mode: after.stat.mode & 0o777, resolvedPath };
23
+ }
24
+ export async function capturePreservedMutationProtection(input, root, authority, snapshot) {
25
+ const delegation = preservedMutationDelegation(authority), review = await readDisciplineSnapshot(snapshot);
26
+ for (const expected of delegation.targetFiles) {
27
+ const file = snapshot.manifest.files.find(item => item.path === expected.path);
28
+ if (!file?.exists || file.sha256 !== expected.sha256) preservedMutationFail('Preserved mutation target must retain its exact existing gate baseline');
29
+ const imports = await scopeCandidateImports(root, { path: expected.path,
30
+ content: (await scopeRead(root, expected.path, input)).content.toString('utf8') }, input);
31
+ const declared = delegation.targetImports.find(item => item.path === expected.path);
32
+ if (imports.dynamic) unknownTarget(expected.path, imports.unresolvedImports?.[0]);
33
+ if (canonical([...new Set(imports.edges.map(edge => edge.specifier))].sort()) !== canonical(declared.specifiers)) {
34
+ preservedMutationFail('Preserved mutation targets cannot contain unknown or changed imports');
35
+ }
36
+ }
37
+ const unknown = review.analysis.unresolved.find(item => authority.paths.includes(item.path) && /(?:^import-|dynamic-import|dynamic-reference)/.test(item.reason));
38
+ if (unknown) unknownTarget(unknown.path, unknown);
39
+ const result = [];
40
+ for (const expected of delegation.protectedFiles) result.push(await observe(input, root, expected));
41
+ return result;
42
+ }
43
+ export async function assertPreservedMutationProtection(input, root, authority) {
44
+ const delegation = preservedMutationDelegation(authority), original = await preservedMutationProtectedFiles(input, authority);
45
+ if (canonical(original.map(({ path, sha256 }) => ({ path, sha256 }))) !== canonical(delegation.protectedFiles)) {
46
+ preservedMutationFail('Preserved mutation protected file manifest differs from its signed delegation');
47
+ }
48
+ for (const expected of original) {
49
+ if (canonical(await observe(input, root, expected)) !== canonical(expected)) preservedMutationFail(`Preserved mutation dependency ${JSON.stringify(expected.path)} changed permissions or physical identity`);
50
+ }
51
+ }
@@ -0,0 +1,81 @@
1
+ import { createPrivateKey, createPublicKey, generateKeyPairSync, randomUUID, sign } from 'node:crypto';
2
+ import { promises as fs } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { canonical, sha256 } from './local-integrity.mjs';
5
+ import { mutationHome14, mutationRoot14, mutationStateRoot14, mutationId14, privateMutationDirectory14,
6
+ readMutationJson14, atomicMutationJson14, mutationTarget14 } from './local-mutation-files.mjs';
7
+ import { preservedMutationFail, preservedMutationPublicKey } from './local-preserved-mutation-wire.mjs';
8
+ import { withStateLock } from './local-state-lock.mjs';
9
+ import { regular } from './local-task-handoff-storage.mjs';
10
+
11
+ export async function preservedMutationLocation(input) {
12
+ const root = await mutationRoot14(input.repositoryRoot), home = await mutationHome14(input.home);
13
+ const relative = path.relative(root, home);
14
+ if (!relative || relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)) {
15
+ preservedMutationFail('Preserved mutation signer home must be outside the repository');
16
+ }
17
+ const stateRoot = await mutationStateRoot14({ ...input, home });
18
+ const repositoryDigest = sha256(root).slice(0, 32), flowId = mutationId14(input.flowId, 'flowId');
19
+ const directory = path.join(stateRoot, 'mutation-delegates', repositoryDigest, flowId);
20
+ await privateMutationDirectory14(directory, { privateFrom: stateRoot });
21
+ return { root, home, stateRoot, repositoryDigest, flowId, directory, bindingPath: path.join(directory, 'binding.json'),
22
+ signerConfigPath: path.join(directory, 'signer.json'), fencePath: path.join(directory, 'fence.json') };
23
+ }
24
+ export async function readPreservedMutationBinding(input) {
25
+ const location = await preservedMutationLocation(input), binding = await readMutationJson14(location.bindingPath);
26
+ const publicKey = preservedMutationPublicKey(binding.publicKey);
27
+ if (canonical(Object.keys(binding).sort()) !== canonical(['schemaVersion', 'flowId', 'repositoryDigest', 'home', 'keyId', 'publicKey'].sort())
28
+ || binding.schemaVersion !== 'entrorove.mutation-delegate-binding/1.0' || binding.flowId !== location.flowId
29
+ || binding.repositoryDigest !== location.repositoryDigest || binding.home !== location.home || binding.keyId !== publicKey.keyId) {
30
+ preservedMutationFail('Preserved mutation signer lost its original repository, home or flow binding');
31
+ }
32
+ return { ...location, binding };
33
+ }
34
+ export async function preparePreservedMutationSigner(input) {
35
+ const location = await preservedMutationLocation(input);
36
+ return withStateLock(path.join(location.directory, 'prepare'), () => prepareSigner(input, location));
37
+ }
38
+ async function prepareSigner(input, location) {
39
+ try { await mutationTarget14(location.directory, 'binding.json'); }
40
+ catch (error) {
41
+ if (error.code !== 'ENOENT') throw error;
42
+ const pair = generateKeyPairSync('ed25519'), publicKey = pair.publicKey.export({ format: 'der', type: 'spki' }).toString('base64');
43
+ const stage = path.join(location.directory, `.prepare-${randomUUID()}`);
44
+ await privateMutationDirectory14(stage, { exclusive: true, privateFrom: location.stateRoot });
45
+ const privateKeyPath = path.join(location.directory, 'private.pem');
46
+ try {
47
+ await fs.writeFile(path.join(stage, 'private.pem'), pair.privateKey.export({ format: 'pem', type: 'pkcs8' }), { flag: 'wx', mode: 0o600 });
48
+ await atomicMutationJson14(path.join(stage, 'signer.json'), { schemaVersion: 'entrorove.mutation-delegate-signer/1.0',
49
+ privateKeyPath, keyId: sha256(Buffer.from(publicKey, 'base64')), receiptTtlMs: 60_000 }, { privateFrom: location.stateRoot });
50
+ await atomicMutationJson14(path.join(stage, 'binding.json'), { schemaVersion: 'entrorove.mutation-delegate-binding/1.0',
51
+ flowId: location.flowId, repositoryDigest: location.repositoryDigest, home: location.home,
52
+ publicKey, keyId: sha256(Buffer.from(publicKey, 'base64')) }, { privateFrom: location.stateRoot });
53
+ const fence = { schemaVersion: 'entrorove.preserved-mutation-fence/1.0', epoch: 0, identity: null, grants: [] };
54
+ await atomicMutationJson14(path.join(stage, 'fence.json'), { ...fence,
55
+ signature: sign(null, Buffer.from(canonical(fence)), pair.privateKey).toString('base64url') }, { privateFrom: location.stateRoot });
56
+ for (const file of ['private.pem', 'signer.json', 'binding.json', 'fence.json']) await fs.link(path.join(stage, file), path.join(location.directory, file));
57
+ } finally { await fs.rm(stage, { recursive: true, force: true }); }
58
+ }
59
+ const actual = await readPreservedMutationBinding(input);
60
+ await mutationTarget14(actual.directory, 'fence.json');
61
+ await loadPreservedMutationSigner(input, actual.signerConfigPath, actual.binding.keyId);
62
+ return { schemaVersion: 'entrorove.local-execution/1.0', status: 'reported', purpose: 'preserved-existing-modify',
63
+ mutationPublicKey: actual.binding.publicKey, mutationKeyId: actual.binding.keyId, keyId: actual.binding.keyId, signerConfigPath: actual.signerConfigPath };
64
+ }
65
+ export async function loadPreservedMutationSigner(input, file, keyId) {
66
+ const location = await readPreservedMutationBinding(input);
67
+ if (file !== location.signerConfigPath || location.binding.keyId !== keyId) preservedMutationFail('Preserved mutation signer differs from the delegated physical key');
68
+ const config = await readMutationJson14(file);
69
+ if (canonical(Object.keys(config).sort()) !== canonical(['schemaVersion', 'privateKeyPath', 'keyId', 'receiptTtlMs'].sort())
70
+ || config.schemaVersion !== 'entrorove.mutation-delegate-signer/1.0' || config.keyId !== keyId
71
+ || config.privateKeyPath !== path.join(location.directory, 'private.pem') || !Number.isSafeInteger(config.receiptTtlMs)
72
+ || config.receiptTtlMs < 1 || config.receiptTtlMs > 90_000) preservedMutationFail('Preserved mutation signer configuration is invalid');
73
+ const target = await mutationTarget14(location.directory, 'private.pem'), { bytes, stat } = await regular(target.target, 16 * 1024);
74
+ try {
75
+ if (stat.nlink !== 1 || process.platform !== 'win32' && (stat.mode & 0o077)
76
+ || process.getuid && stat.uid !== process.getuid()) preservedMutationFail('Preserved mutation signer must be an owned private file');
77
+ const privateKey = createPrivateKey(bytes), der = createPublicKey(privateKey).export({ format: 'der', type: 'spki' });
78
+ if (privateKey.asymmetricKeyType !== 'ed25519' || sha256(der) !== keyId) preservedMutationFail('Preserved mutation signer key fingerprint is invalid');
79
+ return { privateKey, config };
80
+ } finally { bytes.fill(0); }
81
+ }
@@ -0,0 +1,96 @@
1
+ import path from 'node:path';
2
+ import { promises as fs } from 'node:fs';
3
+ import { sign, verify } from 'node:crypto';
4
+ import { canonical, sha256 } from './local-integrity.mjs';
5
+ import { withStateLock } from './local-state-lock.mjs';
6
+ import { readMutationJson14, atomicMutationJson14 } from './local-mutation-files.mjs';
7
+ import { readPreservedMutationBinding, loadPreservedMutationSigner } from './local-preserved-mutation-signer.mjs';
8
+ import { preservedMutationDelegation, preservedMutationFail, preservedMutationPublicKey } from './local-preserved-mutation-wire.mjs';
9
+
10
+ const identity = authority => Object.fromEntries(['flowId', 'taskId', 'ownerId', 'taskDigest', 'chainDigest',
11
+ 'repositoryDigest', 'runnerKeyId', 'runnerId', 'repositoryId', 'receiverDigest'].map(field => [field, authority[field]]));
12
+ async function rejectGenericHistory(location) {
13
+ const anchor = process.env.ENTROROVE_TASK_HANDOFF_STATE_DIRECTORY;
14
+ if (!anchor) return;
15
+ const file = path.join(anchor, 'flows', location.repositoryDigest, location.flowId, 'fence.json');
16
+ try {
17
+ await fs.lstat(file);
18
+ preservedMutationFail('Preserved mutation cannot replace an existing generic task execution or handoff history');
19
+ } catch (error) { if (error.code !== 'ENOENT') throw error; }
20
+ }
21
+ export async function preservedMutationOriginalBinding(input, authority) {
22
+ const location = await readPreservedMutationBinding(input), delegation = preservedMutationDelegation(authority);
23
+ if (authority.flowId !== input.flowId || authority.stepId !== input.stepId || authority.repositoryDigest !== location.repositoryDigest
24
+ || input.taskId !== undefined && input.taskId !== authority.taskId || input.generation !== undefined && input.generation !== authority.generation
25
+ || input.leaseId !== undefined && input.leaseId !== authority.leaseId || input.executionAttemptNonce !== undefined
26
+ || delegation.mutationKeyId !== location.binding.keyId || delegation.mutationPublicKey !== location.binding.publicKey) {
27
+ preservedMutationFail('Preserved mutation lost its original physical signer, task, lease or repository binding');
28
+ }
29
+ await rejectGenericHistory(location);
30
+ let state;
31
+ try { state = await readMutationJson14(location.fencePath); }
32
+ catch (error) { if (error.code !== 'ENOENT') throw error; preservedMutationFail('Preserved mutation fence history is missing; automatic reinitialization is forbidden'); }
33
+ if (state) {
34
+ const { signature, ...core } = state, bytes = typeof signature === 'string' ? Buffer.from(signature, 'base64url') : Buffer.alloc(0);
35
+ if (bytes.length !== 64 || bytes.toString('base64url') !== signature
36
+ || !verify(null, Buffer.from(canonical(core)), preservedMutationPublicKey(location.binding.publicKey).key, bytes)) {
37
+ preservedMutationFail('Preserved mutation fence integrity is invalid');
38
+ }
39
+ }
40
+ if (state.schemaVersion !== 'entrorove.preserved-mutation-fence/1.0' || state.epoch !== 0
41
+ || state.identity !== null && canonical(state.identity) !== canonical(identity(authority))
42
+ || !Array.isArray(state.grants) || state.grants.length > 10000 || state.identity === null && state.grants.length) {
43
+ preservedMutationFail('Preserved mutation fence cannot replace original task identity or epoch history');
44
+ }
45
+ return { ...location, context: null, state: { epoch: 0, status: 'active' }, preservedState: state };
46
+ }
47
+ function grantFor(state, authority) {
48
+ const grant = state.grants.find(item => item.challengeId === authority.challengeId);
49
+ const nonce = authority.passId ? authority.challengeNonce : authority.nonce;
50
+ const digest = authority.passId ? authority.challengeDigest : sha256(canonical(authority));
51
+ if (state.grants.some(item => item.nonce === nonce && item !== grant)
52
+ || grant && (grant.nonce !== nonce || grant.challengeDigest !== digest || grant.leaseId !== authority.leaseId
53
+ || grant.generation !== authority.generation || grant.stepId !== authority.stepId)) {
54
+ preservedMutationFail('Preserved mutation nonce cannot replace its original challenge or lease');
55
+ }
56
+ return grant;
57
+ }
58
+ export async function assertPreservedMutationIssued(input, pass) {
59
+ const binding = await preservedMutationOriginalBinding(input, pass), grant = grantFor(binding.preservedState, pass);
60
+ if (!grant || grant.passDigest !== sha256(canonical(pass))) preservedMutationFail('Preserved mutation requires its original issued pass and nonce fence');
61
+ return binding;
62
+ }
63
+ export async function preservedMutationProtectedFiles(input, authority) {
64
+ const binding = await preservedMutationOriginalBinding(input, authority), grant = grantFor(binding.preservedState, authority);
65
+ if (!grant?.protectedFiles) preservedMutationFail('Preserved mutation requires its original protected file observations');
66
+ return grant.protectedFiles;
67
+ }
68
+ async function persist(input, binding, state) {
69
+ const { signature: previous, ...core } = state;
70
+ const signer = await loadPreservedMutationSigner(input, binding.signerConfigPath, binding.binding.keyId);
71
+ const signature = sign(null, Buffer.from(canonical(core)), signer.privateKey).toString('base64url');
72
+ await atomicMutationJson14(binding.fencePath, { ...core, signature }, { privateFrom: binding.stateRoot, replace: true });
73
+ }
74
+ export async function withPreservedMutationFence(input, authority, action, { attest = false, issue = false, protectedFiles } = {}) {
75
+ const original = await preservedMutationOriginalBinding(input, authority);
76
+ return withStateLock(original.fencePath, async () => {
77
+ const binding = await preservedMutationOriginalBinding(input, authority), state = binding.preservedState;
78
+ if (state.identity === null) state.identity = identity(authority);
79
+ let grant = grantFor(state, authority);
80
+ if (!grant) {
81
+ if (!attest || authority.passId) preservedMutationFail('Preserved mutation has no original attested challenge');
82
+ grant = { challengeId: authority.challengeId, nonce: authority.nonce, challengeDigest: sha256(canonical(authority)),
83
+ leaseId: authority.leaseId, generation: authority.generation, stepId: authority.stepId, passDigest: null, protectedFiles };
84
+ state.grants.push(grant);
85
+ }
86
+ if (attest && canonical(grant.protectedFiles) !== canonical(protectedFiles)) preservedMutationFail('Preserved mutation protected baseline changed during attestation');
87
+ if (authority.passId) {
88
+ const passDigest = sha256(canonical(authority));
89
+ if (grant.passDigest !== passDigest && (!issue || grant.passDigest !== null)) preservedMutationFail('Preserved mutation challenge was already bound to another pass');
90
+ if (issue) grant.passDigest = passDigest;
91
+ else if (!grant.passDigest) preservedMutationFail('Preserved mutation pass is not issued');
92
+ }
93
+ await persist(input, binding, state);
94
+ return action(binding.state);
95
+ });
96
+ }
@@ -0,0 +1,49 @@
1
+ import { createPublicKey } from 'node:crypto';
2
+ import { canonical, sha256 } from './local-integrity.mjs';
3
+ import { mutationFail14, mutationPath14 } from './local-mutation-files.mjs';
4
+
5
+ export const preservedMutationFail = message => mutationFail14('ENTROROVE_MUTATION_DELEGATION_INVALID', message);
6
+ function exact(value, names) {
7
+ if (!value || typeof value !== 'object' || Array.isArray(value)
8
+ || canonical(Object.keys(value).sort()) !== canonical([...names].sort())) preservedMutationFail('Preserved mutation delegation fields are invalid');
9
+ }
10
+ export function preservedMutationPublicKey(encoded) {
11
+ try {
12
+ const bytes = Buffer.from(encoded, 'base64'), key = createPublicKey({ key: bytes, format: 'der', type: 'spki' });
13
+ if (typeof encoded !== 'string' || bytes.toString('base64') !== encoded || key.asymmetricKeyType !== 'ed25519'
14
+ || key.export({ format: 'der', type: 'spki' }).toString('base64') !== encoded) throw new Error();
15
+ return { key, keyId: sha256(bytes) };
16
+ } catch { preservedMutationFail('Preserved mutation requires a canonical Ed25519 public key'); }
17
+ }
18
+ function files(values) {
19
+ if (!Array.isArray(values) || values.length > 2000) preservedMutationFail('Preserved mutation file manifest is invalid');
20
+ for (const [index, file] of values.entries()) {
21
+ exact(file, ['path', 'sha256']); mutationPath14(file.path);
22
+ if (!/^[0-9a-f]{64}$/.test(file.sha256) || index && values[index - 1].path >= file.path) {
23
+ preservedMutationFail('Preserved mutation file manifest must be sorted and unique');
24
+ }
25
+ }
26
+ return values;
27
+ }
28
+ export function preservedMutationDelegation(authority) {
29
+ const value = authority.writeDelegation;
30
+ exact(value, ['schemaVersion', 'purpose', 'scopeKeyId', 'mutationKeyId', 'mutationPublicKey',
31
+ 'gateEvidenceDigest', 'importGraphDigest', 'protectedFiles', 'targetFiles', 'targetImports']);
32
+ const publicKey = preservedMutationPublicKey(value.mutationPublicKey);
33
+ if (value.schemaVersion !== 'entrorove.preserved-write-delegation/1.0' || value.purpose !== 'preserved-existing-modify'
34
+ || ['scopeKeyId', 'mutationKeyId', 'gateEvidenceDigest', 'importGraphDigest'].some(field => !/^[0-9a-f]{64}$/.test(value[field]))
35
+ || publicKey.keyId !== value.mutationKeyId || new Set([authority.runnerKeyId, value.scopeKeyId, value.mutationKeyId]).size !== 3
36
+ || authority.epoch !== 0 || authority.newFilePaths.length !== 0) preservedMutationFail('Preserved mutation role or original epoch is invalid');
37
+ files(value.protectedFiles); files(value.targetFiles);
38
+ if (canonical(value.targetFiles.map(file => file.path)) !== canonical(authority.paths)
39
+ || value.protectedFiles.some(file => authority.paths.includes(file.path))
40
+ || !Array.isArray(value.targetImports) || canonical(value.targetImports.map(file => file?.path)) !== canonical(authority.paths)) {
41
+ preservedMutationFail('Preserved mutation must retain its exact existing target and protected path sets');
42
+ }
43
+ for (const file of value.targetImports) {
44
+ exact(file, ['path', 'specifiers']);
45
+ if (!Array.isArray(file.specifiers) || file.specifiers.some(item => typeof item !== 'string' || !item || /[\u0000-\u001f\u007f]/.test(item))
46
+ || canonical([...new Set(file.specifiers)].sort()) !== canonical(file.specifiers)) preservedMutationFail('Preserved mutation import manifest is invalid');
47
+ }
48
+ return value;
49
+ }
@@ -0,0 +1,142 @@
1
+ import path from 'node:path';
2
+ import { fileURLToPath, pathToFileURL } from 'node:url';
3
+ import ts from 'typescript';
4
+ import { createScopeImportResolver, scopeSourceChecker } from './local-scope-import-expressions.mjs';
5
+ import { scopeFail, scopePath } from './local-scope-files.mjs';
6
+
7
+ const purePathMethods = new Set(['join', 'resolve', 'basename', 'dirname', 'extname', 'isAbsolute', 'normalize',
8
+ 'relative', 'parse', 'format', 'toNamespacedPath', 'matchesGlob']);
9
+ function unwrap(node) { while (node && ts.isParenthesizedExpression(node)) node = node.expression; return node; }
10
+ function imported(declaration) {
11
+ if (ts.isImportSpecifier(declaration)) return { imported: declaration.propertyName?.text ?? declaration.name.text,
12
+ module: declaration.parent.parent.parent.moduleSpecifier.text };
13
+ if (ts.isImportClause(declaration)) return { imported: 'default', module: declaration.parent.moduleSpecifier.text };
14
+ if (ts.isNamespaceImport(declaration)) return { imported: '*', module: declaration.parent.parent.moduleSpecifier.text };
15
+ return null;
16
+ }
17
+ function importName(node) {
18
+ for (let parent = node; parent; parent = parent.parent) {
19
+ if (ts.isImportDeclaration(parent)) return true;
20
+ if (ts.isStatement(parent)) return false;
21
+ }
22
+ return false;
23
+ }
24
+ function immutable(declaration, source) {
25
+ return declaration && ts.isVariableDeclaration(declaration) && ts.isVariableDeclarationList(declaration.parent)
26
+ && declaration.parent.flags & ts.NodeFlags.Const && declaration.initializer && declaration.getSourceFile() === source
27
+ && !(ts.getCombinedModifierFlags(declaration) & ts.ModifierFlags.Ambient) && !source.isDeclarationFile;
28
+ }
29
+ function staticAnchor(call, source, repositoryRoot, filename, declaration, pathMethod) {
30
+ if (call.arguments.length !== 1) return null;
31
+ let visited = 0;
32
+ const value = (raw, seen = new Set(), depth = 0) => {
33
+ const node = unwrap(raw);
34
+ if (!node || depth > 32 || ++visited > 256) return null;
35
+ if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return node.text;
36
+ if (ts.isPropertyAccessExpression(node) && ts.isMetaProperty(node.expression)
37
+ && node.expression.keywordToken === ts.SyntaxKind.ImportKeyword && node.expression.name.text === 'meta') {
38
+ const file = path.resolve(repositoryRoot, filename);
39
+ return node.name.text === 'dirname' ? path.dirname(file) : node.name.text === 'filename' ? file
40
+ : node.name.text === 'url' ? pathToFileURL(file).href : null;
41
+ }
42
+ if (ts.isCallExpression(node)) {
43
+ const method = pathMethod(node.expression);
44
+ if (!['join', 'resolve'].includes(method) || node.arguments.length < 1 || node.arguments.length > 32) return null;
45
+ const args = node.arguments.map(item => value(item, seen, depth + 1));
46
+ if (args.some(item => typeof item !== 'string') || method === 'resolve' && !args.some(item => path.isAbsolute(item))) return null;
47
+ return path[method](...args);
48
+ }
49
+ if (!ts.isIdentifier(node)) return null;
50
+ const bound = declaration(node);
51
+ return immutable(bound, source) && !seen.has(bound) ? value(bound.initializer, new Set([...seen, bound]), depth + 1) : null;
52
+ };
53
+ let absolute = value(call.arguments[0]);
54
+ if (typeof absolute !== 'string' || !absolute || /[\u0000-\u001f\u007f]/.test(absolute)) return null;
55
+ if (absolute.startsWith('file:')) { try { absolute = fileURLToPath(absolute); } catch { return null; } }
56
+ if (!path.isAbsolute(absolute)) return null;
57
+ const relative = path.relative(repositoryRoot, absolute).split(path.sep).join('/');
58
+ if (!relative || relative === '..' || relative.startsWith('../') || path.isAbsolute(relative)
59
+ || relative.split('/').some(part => ['.git', '.entrorove', '.aimlock', '.archguard'].includes(part))) {
60
+ scopeFail('ENTROROVE_PATH_UNSAFE', `createRequire in "${filename}" resolves its anchor outside the authorized repository or into a reserved control directory`);
61
+ }
62
+ return scopePath(relative);
63
+ }
64
+ /** Infer only lexical builtin factories and immutable path expressions; never run scanned code. */
65
+ export function createScopeRequireContexts(source, repositoryRoot, filename) {
66
+ let checker, safety;
67
+ const declaration = node => {
68
+ checker ??= scopeSourceChecker(source);
69
+ const declarations = checker.getSymbolAtLocation(node)?.declarations;
70
+ return declarations?.length === 1 && declarations[0].getSourceFile() === source ? declarations[0] : null;
71
+ };
72
+ const origin = node => ts.isIdentifier(node) ? imported(declaration(node) ?? source) : null;
73
+ const factory = node => { const value = node && origin(unwrap(node));
74
+ return value?.imported === 'createRequire' && ['node:module', 'module'].includes(value.module); };
75
+ const pathObject = node => { const value = node && origin(unwrap(node));
76
+ return ['default', '*'].includes(value?.imported) && ['node:path', 'path'].includes(value?.module); };
77
+ const pathMethod = node => {
78
+ node = unwrap(node);
79
+ if (node && ts.isPropertyAccessExpression(node) && pathObject(node.expression)) return node.name.text;
80
+ const value = node && origin(node);
81
+ return value && ['node:path', 'path'].includes(value.module) ? value.imported : null;
82
+ };
83
+ const factoryCall = node => { node = unwrap(node);
84
+ return node && ts.isCallExpression(node) && factory(node.expression) ? node : null; };
85
+ const moduleArguments = createScopeImportResolver(source);
86
+ const mutableBuiltinLoad = node => {
87
+ if (!ts.isCallExpression(node) || !node.arguments.length) return false;
88
+ const callee = unwrap(node.expression), bound = ts.isIdentifier(callee) && declaration(callee);
89
+ const loading = callee.kind === ts.SyntaxKind.ImportKeyword || ts.isIdentifier(callee) && callee.text === 'require'
90
+ || factoryCall(callee) || bound && ts.isVariableDeclaration(bound) && factoryCall(bound.initializer);
91
+ return loading && moduleArguments(node.arguments[0])?.some(name => ['path', 'node:path', 'module', 'node:module'].includes(name));
92
+ };
93
+ function safeBuiltins() {
94
+ if (safety !== undefined) return safety;
95
+ safety = true;
96
+ const visit = node => {
97
+ if (mutableBuiltinLoad(node)) safety = false;
98
+ if (ts.isIdentifier(node) && !importName(node)) {
99
+ if (node.text === 'syncBuiltinESMExports') safety = false;
100
+ if (pathObject(node)) {
101
+ const member = node.parent, call = member?.parent;
102
+ if (!ts.isPropertyAccessExpression(member) || member.expression !== node
103
+ || !(purePathMethods.has(member.name.text) && ts.isCallExpression(call) && call.expression === member)
104
+ && !['sep', 'delimiter'].includes(member.name.text)) safety = false;
105
+ }
106
+ const method = origin(node);
107
+ if (method && ['node:module', 'module'].includes(method.module)
108
+ && ['default', '*', 'register', 'registerHooks', 'syncBuiltinESMExports'].includes(method.imported)) safety = false;
109
+ if (method && ['node:path', 'path'].includes(method.module) && purePathMethods.has(method.imported)
110
+ && !(ts.isCallExpression(node.parent) && node.parent.expression === node)) safety = false;
111
+ if (factory(node) && !(ts.isCallExpression(node.parent) && node.parent.expression === node)) safety = false;
112
+ }
113
+ ts.forEachChild(node, visit);
114
+ };
115
+ visit(source); return safety;
116
+ }
117
+ const anchor = call => repositoryRoot && safeBuiltins() ? staticAnchor(call, source, repositoryRoot, filename, declaration, pathMethod) : null;
118
+ function loader(node) {
119
+ node = unwrap(node);
120
+ const direct = factoryCall(node);
121
+ if (direct) return { anchor: anchor(direct), call: direct };
122
+ if (!node || !ts.isIdentifier(node)) return null;
123
+ const bound = declaration(node), creation = bound && ts.isVariableDeclaration(bound) && factoryCall(bound.initializer);
124
+ if (creation) return { anchor: immutable(bound, source) ? anchor(creation) : null, call: creation, declaration: bound };
125
+ if (node.text === 'require' && bound) return { anchor: null };
126
+ return null;
127
+ }
128
+ return { loader,
129
+ knownFactoryReference(node) {
130
+ const named = ts.isImportSpecifier(node.parent) && imported(node.parent);
131
+ return importName(node) && (factory(node) || named?.imported === 'createRequire' && ['node:module', 'module'].includes(named.module))
132
+ || factory(node) && ts.isCallExpression(node.parent)
133
+ && node.parent.expression === node && anchor(node.parent) !== null;
134
+ },
135
+ loaderReference(node) {
136
+ if (!ts.isIdentifier(node) || importName(node)) return null;
137
+ const found = loader(node);
138
+ if (!found) return null;
139
+ const declared = found.declaration?.name === node;
140
+ return { safe: Boolean(found.anchor) && (declared || ts.isCallExpression(node.parent) && node.parent.expression === node) };
141
+ } };
142
+ }
@@ -7,7 +7,7 @@ function unwrap(node) {
7
7
  || ts.isTypeAssertionExpression(node) || ts.isNonNullExpression(node))) node = node.expression;
8
8
  return node;
9
9
  }
10
- function sourceChecker(source) {
10
+ export function scopeSourceChecker(source) {
11
11
  const name = path.posix.resolve('/', source.fileName.replaceAll('\\', '/'));
12
12
  const matches = value => path.posix.resolve('/', value.replaceAll('\\', '/')) === name;
13
13
  const host = { getSourceFile: filename => matches(filename) ? source : undefined,
@@ -47,7 +47,7 @@ export function createScopeImportResolver(source, suppliedChecker) {
47
47
  return result;
48
48
  }
49
49
  if (!ts.isIdentifier(node)) return null;
50
- checker ??= sourceChecker(source);
50
+ checker ??= scopeSourceChecker(source);
51
51
  const declarations = checker.getSymbolAtLocation(node)?.declarations;
52
52
  if (declarations?.length !== 1) return null;
53
53
  const declaration = declarations[0];
@@ -15,6 +15,8 @@ import { scopeTsconfigImports } from './local-scope-config-imports.mjs';
15
15
  import { scopeGitExecutable } from './local-git-executable.mjs';
16
16
  import { createScopeImportResolver, unresolvedImportSite } from './local-scope-import-expressions.mjs';
17
17
  import { isTypeOnlyReference, isDynamicFunctionReference } from './local-typescript-function-reference.mjs';
18
+ import { createScopeRequireContexts } from './local-scope-create-require.mjs';
19
+ import { resolveCreatedRequire } from './local-scope-require-resolution.mjs';
18
20
 
19
21
  const execute = promisify(execFile);
20
22
  async function git(root, args, allowed = [], input = { repositoryRoot: root }) {
@@ -107,11 +109,17 @@ function scriptImports(source, filename, context = {}) {
107
109
  const parsed = ts.createSourceFile(filename, source, ts.ScriptTarget.Latest, true,
108
110
  scriptKinds.get(path.extname(filename).toLowerCase()) ?? ts.ScriptKind.TS);
109
111
  if (parsed.parseDiagnostics.length) scopeFail('ENTROROVE_IMPORT_INVALID', 'source has invalid or unterminated syntax');
110
- const found = new Set(), unresolvedImports = [], resolve = createScopeImportResolver(parsed); let dynamic = false;
111
- const add = expression => {
112
+ const found = new Set(), requires = new Map(), unresolvedImports = [], resolve = createScopeImportResolver(parsed); let dynamic = false;
113
+ const contexts = createScopeRequireContexts(parsed, context.repositoryRoot, context.sourceFilename ?? filename);
114
+ const unresolved = (expression, reason, message) => { dynamic = true;
115
+ unresolvedImports.push({ ...unresolvedImportSite(parsed, expression), reason, message }); };
116
+ const add = (expression, anchor) => {
112
117
  const values = resolve(expression);
113
118
  if (values === null) { dynamic = true; unresolvedImports.push(unresolvedImportSite(parsed, expression)); }
114
- else for (const value of values) found.add(value);
119
+ else for (const value of values) {
120
+ if (anchor) requires.set(`${anchor}\0${value}`, { anchor, specifier: value });
121
+ else found.add(value);
122
+ }
115
123
  };
116
124
  const visit = node => {
117
125
  if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) { if (node.moduleSpecifier) add(node.moduleSpecifier); }
@@ -120,21 +128,28 @@ function scriptImports(source, filename, context = {}) {
120
128
  else if (ts.isCallExpression(node)) {
121
129
  const importer = node.expression.kind === ts.SyntaxKind.ImportKeyword;
122
130
  const requireCall = ts.isIdentifier(node.expression) && node.expression.text === 'require';
123
- if (importer || requireCall) {
131
+ const created = importer ? null : contexts.loader(node.expression);
132
+ if (importer || requireCall || created) {
124
133
  if (!node.arguments.length || node.arguments.length > (importer ? 2 : 1)) {
125
134
  dynamic = true;
126
135
  unresolvedImports.push({ ...unresolvedImportSite(parsed, node), reason: 'import-argument-list-not-supported',
127
136
  message: 'Import inspection requires one module argument and at most one import-attributes argument' });
128
137
  }
129
- else add(node.arguments[0]);
138
+ else if (created && !created.anchor) unresolved(node, 'create-require-context-not-statically-proven',
139
+ 'Require uses a shadowed, mutable or unproven factory/path context; no dependency lookup is authorized');
140
+ else add(node.arguments[0], created?.anchor);
130
141
  }
131
142
  }
132
143
  const runtimeReference = !isTypeOnlyReference(node);
133
144
  if (runtimeReference && ts.isIdentifier(node)) {
134
- if (dynamicNames.has(node.text) && (node.text !== 'Function' || isDynamicFunctionReference(node, context))) dynamic = true;
145
+ if (dynamicNames.has(node.text) && (node.text !== 'Function' || isDynamicFunctionReference(node, context))
146
+ && !(node.text === 'createRequire' && contexts.knownFactoryReference(node))) dynamic = true;
147
+ const loader = contexts.loaderReference(node);
148
+ if (loader && !loader.safe) unresolved(node, 'require-loader-escapes-static-context',
149
+ 'Require loader aliases, mutable bindings and uses outside direct calls remain unresolved');
135
150
  if (node.text === 'require' && !(ts.isCallExpression(node.parent) && node.parent.expression === node)
136
151
  && !(ts.isPropertyAccessExpression(node.parent) && node.parent.name === node)
137
- && !(ts.isMethodDeclaration(node.parent) && node.parent.name === node)) dynamic = true;
152
+ && !(ts.isMethodDeclaration(node.parent) && node.parent.name === node) && !loader?.safe) dynamic = true;
138
153
  }
139
154
  if (runtimeReference && ts.isElementAccessExpression(node)) {
140
155
  const member = staticString(node.argumentExpression);
@@ -143,7 +158,7 @@ function scriptImports(source, filename, context = {}) {
143
158
  if (runtimeReference && ts.isPropertyAccessExpression(node) && node.name.text === 'require' && globalObject(node.expression)) dynamic = true;
144
159
  ts.forEachChild(node, visit);
145
160
  };
146
- visit(parsed); return { specifiers: [...found].sort(), dynamic, unresolvedImports };
161
+ visit(parsed); return { specifiers: [...found].sort(), requires: [...requires.values()], dynamic, unresolvedImports };
147
162
  }
148
163
  function vueScripts(source, filename) {
149
164
  const { descriptor, errors } = parseVue(source, { filename, sourceMap: false,
@@ -176,14 +191,15 @@ function vueScripts(source, filename) {
176
191
  };
177
192
  walk(descriptor.template?.ast); return scripts;
178
193
  }
179
- function importSpecifiers(content, filename) {
194
+ function importSpecifiers(content, filename, repositoryRoot) {
180
195
  try {
181
196
  const source = content.toString('utf8'), parts = path.extname(filename).toLowerCase() === '.vue' ? vueScripts(source, filename) : [{ source, filename }];
182
- const specifiers = new Set(), unresolvedImports = []; let dynamic = false;
183
- for (const part of parts) { const parsed = scriptImports(part.source, part.filename, part); dynamic ||= parsed.dynamic;
197
+ const specifiers = new Set(), requires = new Map(), unresolvedImports = []; let dynamic = false;
198
+ for (const part of parts) { const parsed = scriptImports(part.source, part.filename, { ...part, repositoryRoot, sourceFilename: filename }); dynamic ||= parsed.dynamic;
184
199
  unresolvedImports.push(...parsed.unresolvedImports.map(site => ({ ...site, line: site.line + (part.lineOffset ?? 0) })));
200
+ for (const require of parsed.requires) requires.set(`${require.anchor}\0${require.specifier}`, require);
185
201
  for (const specifier of parsed.specifiers) specifiers.add(specifier); }
186
- return { specifiers: [...specifiers].sort(), dynamic, unresolvedImports };
202
+ return { specifiers: [...specifiers].sort(), requires: [...requires.values()], dynamic, unresolvedImports };
187
203
  } catch (error) {
188
204
  if (typeof error?.code === 'string' && error.code.startsWith('ENTROROVE_')) throw error;
189
205
  if (error instanceof RangeError) scopeFail('ENTROROVE_SCOPE_LIMIT', 'dependency syntax exceeds the supported parser or traversal bound');
@@ -280,7 +296,7 @@ export async function importGraph(root, originals, input = { repositoryRoot: roo
280
296
  const aliases = await scopeTsconfigImports(target => configMetadata(root, target, input), relative, configPath);
281
297
  for (const observed of aliases.observations) metadata.set(observed.path, observed);
282
298
  const declarations = declarationModules(read.content, relative);
283
- const imports = importSpecifiers(read.content, relative); dynamic ||= imports.dynamic;
299
+ const imports = importSpecifiers(read.content, relative, root); dynamic ||= imports.dynamic;
284
300
  unresolvedImports.push(...imports.unresolvedImports.map(site => ({ path: relative, ...site })));
285
301
  if (imports.dynamic) dynamicSources.push({ path: relative, sha256: read.sha256 });
286
302
  for (const specifier of imports.specifiers.sort()) {
@@ -289,6 +305,12 @@ export async function importGraph(root, originals, input = { repositoryRoot: roo
289
305
  ...(declarations.has(specifier) ? { declarationOnly: true, declarationPath: relative } : {}) });
290
306
  if (target !== null && !visited.has(target)) pending.push(target);
291
307
  }
308
+ for (const request of imports.requires) {
309
+ const resolved = await resolveCreatedRequire(relative, request.anchor, request.specifier, target => configMetadata(root, target, input));
310
+ for (const file of resolved.metadata) metadata.set(file.path, file);
311
+ edges.push({ from: relative, to: resolved.external ? null : resolved.target, external: resolved.external, specifier: request.specifier });
312
+ if (!resolved.external && resolved.target !== null && !visited.has(resolved.target)) pending.push(resolved.target);
313
+ }
292
314
  }
293
315
  const nodes = [...visited.values()].sort((a, b) => a.path.localeCompare(b.path));
294
316
  await stableScope(root, nodes, input);
@@ -299,7 +321,7 @@ export async function importGraph(root, originals, input = { repositoryRoot: roo
299
321
  ...(unresolvedImports.length ? { unresolvedImports } : {}) };
300
322
  }
301
323
  export async function scopeCandidateImports(root, file, input = { repositoryRoot: root }) {
302
- const parsed = importSpecifiers(Buffer.from(file.content), file.path);
324
+ const parsed = importSpecifiers(Buffer.from(file.content), file.path, root);
303
325
  const workspace = await workspaceIndex(root, input);
304
326
  const context = (input.importContexts ?? []).filter(item => file.path === item.sourcePrefix || file.path.startsWith(`${item.sourcePrefix}/`))
305
327
  .sort((a, b) => b.sourcePrefix.length - a.sourcePrefix.length)[0];
@@ -309,7 +331,12 @@ export async function scopeCandidateImports(root, file, input = { repositoryRoot
309
331
  const target = await resolveImport(root, file.path, specifier, workspace, input, aliases, context?.configPath, declarations);
310
332
  edges.push({ from: file.path, to: target, external: target === null, specifier });
311
333
  }
312
- return { edges, dynamic: parsed.dynamic,
334
+ for (const request of parsed.requires) {
335
+ const resolved = await resolveCreatedRequire(file.path, request.anchor, request.specifier, target => configMetadata(root, target, input));
336
+ await stableScope(root, resolved.metadata, input);
337
+ edges.push({ from: file.path, to: resolved.external ? null : resolved.target, external: resolved.external, specifier: request.specifier });
338
+ }
339
+ return { edges: edges.sort((a, b) => `${a.from}:${a.specifier}`.localeCompare(`${b.from}:${b.specifier}`)), dynamic: parsed.dynamic,
313
340
  ...(parsed.unresolvedImports.length ? { unresolvedImports: parsed.unresolvedImports.map(site => ({ path: file.path, ...site })) } : {}) };
314
341
  }
315
342
  export async function collectScopeFacts(root, requested, baselineRef, knownFiles = [], input = { repositoryRoot: root }) {