@entrorove/skill 0.1.67 → 0.1.69

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,88 @@
1
+ import { constants, promises as fs } from 'node:fs';
2
+ import { createPrivateKey, createPublicKey } from 'node:crypto';
3
+ import path from 'node:path';
4
+ import { canonical, sha256 } from './local-integrity.mjs';
5
+ import { scopeRoot, scopePath } from './local-scope-files.mjs';
6
+ import { mutationHome14, mutationFile14, readMutationJson14, atomicMutationJson14,
7
+ privateMutationDirectory14 } from './local-mutation-files.mjs';
8
+ import { trustedOrigin } from './remote-transport.mjs';
9
+ import { contextExact, contextFail, contextList, validateContextAuthority } from './scope-context-wire.mjs';
10
+ import { hydrateCloudMutationAuthority, bootstrapCloudMutationAuthority } from './cloud-mutation-authority.mjs';
11
+
12
+ export async function contextPrivate(file, final = 'file', maximumBytes = 8_000_000) {
13
+ const item = await mutationFile14(file, { final });
14
+ if (process.getuid && item.stat.uid !== process.getuid() || process.platform !== 'win32' && item.stat.mode & 0o077
15
+ || final === 'file' && (item.stat.nlink !== 1 || item.stat.size > maximumBytes)) contextFail('Context recovery controls must be bounded private files owned by this OS account');
16
+ return item;
17
+ }
18
+ async function privateText(file) {
19
+ const before = await contextPrivate(file, 'file', 1_000_000);
20
+ const handle = await fs.open(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
21
+ try {
22
+ const opened = await handle.stat(), text = await handle.readFile('utf8'), after = await handle.stat(), latest = await contextPrivate(file);
23
+ if ([opened, after, latest.stat].some(stat => ['dev', 'ino', 'size', 'mtimeMs', 'ctimeMs', 'mode'].some(field => stat[field] !== before.stat[field]))) contextFail('Original context signer changed while reading');
24
+ return text;
25
+ } finally { await handle.close(); }
26
+ }
27
+ export async function scopeContextLocation(repositoryRoot, flowId, dependencies) {
28
+ const root = await scopeRoot(repositoryRoot), home = await mutationHome14(dependencies.home), apiOrigin = trustedOrigin(dependencies.apiOrigin());
29
+ const relative = path.relative(root, home);
30
+ if (!relative || relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)) contextFail('Context controls must remain outside the observed repository');
31
+ const stateRoot = path.join(home, '.entrorove'), base = path.join(stateRoot, 'scope-runners', sha256(`${apiOrigin}:${flowId}:${root}`)), controls = path.join(base, 'identity');
32
+ for (const directory of [stateRoot, path.dirname(base), base, controls]) await contextPrivate(directory, 'directory');
33
+ const binding = JSON.parse(await privateText(path.join(controls, 'binding.json')));
34
+ contextExact(binding, ['schemaVersion', 'flowId', 'repositoryRoot', 'apiOrigin', 'targetPaths', 'importContexts', 'runnerId', 'repositoryId', 'publicKey', 'keyId']);
35
+ if (binding.schemaVersion !== 'entrorove.local-scope-binding/1.0' || binding.flowId !== flowId || binding.repositoryRoot !== root || binding.apiOrigin !== apiOrigin
36
+ || !Array.isArray(binding.targetPaths) || !binding.targetPaths.length
37
+ || canonical(binding.targetPaths) !== canonical([...new Set(binding.targetPaths.map(scopePath))].sort())) contextFail('The original context task binding is unavailable');
38
+ contextList(binding.importContexts);
39
+ const signerConfigPath = path.join(controls, 'signer.json'), config = JSON.parse(await privateText(signerConfigPath));
40
+ contextExact(config, ['schemaVersion', 'privateKeyPath', 'keyId', 'receiptTtlMs']);
41
+ if (config.schemaVersion !== 'entrorove.scope-signer/1.0' || config.privateKeyPath !== path.join(controls, 'private.pem') || config.keyId !== binding.keyId
42
+ || !Number.isSafeInteger(config.receiptTtlMs) || config.receiptTtlMs < 1 || config.receiptTtlMs > 300_000) contextFail('Original context signer configuration changed');
43
+ const key = createPrivateKey(await privateText(config.privateKeyPath)), der = createPublicKey(key).export({ type: 'spki', format: 'der' });
44
+ if (key.asymmetricKeyType !== 'ed25519' || der.toString('base64') !== binding.publicKey || sha256(der) !== binding.keyId) contextFail('Original context signing key changed');
45
+ return { root, home, apiOrigin, stateRoot, base, binding, key, signerConfigPath, localBindingDigest: sha256(canonical(binding)),
46
+ directory: path.join(base, 'context-recovery'), file: path.join(base, 'context-recovery', 'journal.json') };
47
+ }
48
+ export async function effectiveScopeContexts(location) {
49
+ const directory = path.join(location.base, 'context-authority');
50
+ try { await contextPrivate(directory, 'directory'); } catch (error) { if (error.code === 'ENOENT') return null; throw error; }
51
+ const names = (await fs.readdir(directory)).sort(); let current = null;
52
+ if (names.length > 100 || names.some(name => !/^\d{6}-[a-f0-9]{64}\.json$/.test(name))) contextFail('Context authority history is invalid');
53
+ for (const name of names) {
54
+ const file = path.join(directory, name); await contextPrivate(file, 'file', 1_000_000);
55
+ const saved = await readMutationJson14(file), checked = validateContextAuthority(saved.record, location, current);
56
+ if (saved.revisionDigest !== checked.revisionDigest || name !== `${String(checked.record.revision).padStart(6, '0')}-${checked.revisionDigest}.json`) contextFail('Context history checksum or order changed');
57
+ current = checked;
58
+ }
59
+ return current;
60
+ }
61
+ export async function boundScopeContexts(base, binding, dependencies) {
62
+ try { await contextPrivate(path.join(base, 'context-authority'), 'directory'); }
63
+ catch (error) { if (error.code === 'ENOENT') return null; throw error; }
64
+ if (!await hydrateCloudMutationAuthority(dependencies.home, dependencies.apiOrigin())) await bootstrapCloudMutationAuthority(dependencies);
65
+ return effectiveScopeContexts({ base, binding, localBindingDigest: sha256(canonical(binding)) });
66
+ }
67
+ export async function retainContextAuthority(location, effective) {
68
+ const current = await effectiveScopeContexts(location);
69
+ if (current?.revisionDigest === effective?.revisionDigest && canonical(current.record) === canonical(effective.record)) return current;
70
+ const checked = validateContextAuthority(effective?.record, location, current);
71
+ if (effective.revisionDigest !== checked.revisionDigest) contextFail('Applied context authority checksum changed');
72
+ const directory = path.join(location.base, 'context-authority'), file = path.join(directory, `${String(checked.record.revision).padStart(6, '0')}-${checked.revisionDigest}.json`);
73
+ await atomicMutationJson14(file, checked, { privateFrom: location.stateRoot }); return checked;
74
+ }
75
+ export async function readContextJournal(location, input) {
76
+ try {
77
+ await contextPrivate(location.file);
78
+ const value = await readMutationJson14(location.file);
79
+ if (value.schemaVersion !== 'entrorove.scope-context-journal/1.0' || value.localBindingDigest !== location.localBindingDigest) contextFail('Context journal cannot replace its original identity');
80
+ if (value.pending && canonical(value.input) !== canonical(input)) contextFail('Recover the original pending context request before another operation');
81
+ return canonical(value.input) === canonical(input) ? value : { previous: value };
82
+ } catch (error) { if (error.code === 'ENOENT') return {}; throw error; }
83
+ }
84
+ export async function saveContextJournal(location, state) {
85
+ if (Buffer.byteLength(canonical(state)) > 8_000_000) contextFail('Context journal exceeds its bounded state limit');
86
+ await privateMutationDirectory14(location.directory, { privateFrom: location.stateRoot });
87
+ return atomicMutationJson14(location.file, state, { replace: true, privateFrom: location.stateRoot });
88
+ }
@@ -0,0 +1,84 @@
1
+ import { canonical, sha256 } from './local-integrity.mjs';
2
+ import { scopeFail, scopePath } from './local-scope-files.mjs';
3
+ import { scopeRevisionAuthority } from './scope-amendment-wire.mjs';
4
+
5
+ const id = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$/;
6
+ const hash = /^[a-f0-9]{64}$/;
7
+ export const contextFail = message => scopeFail('ENTROROVE_SCOPE_CONTEXT_BINDING', message);
8
+ export function contextExact(value, fields) {
9
+ if (!value || typeof value !== 'object' || Array.isArray(value)
10
+ || canonical(Object.keys(value).sort()) !== canonical([...fields].sort())) contextFail('Scope context fields are invalid');
11
+ }
12
+ export function contextList(values) {
13
+ if (!Array.isArray(values) || values.length > 100
14
+ || new Set(values.map(value => value?.sourcePrefix)).size !== values.length) contextFail('Scope contexts require unique source prefixes and at most 100 configurations');
15
+ for (const value of values) {
16
+ contextExact(value, ['sourcePrefix', 'configPath']); scopePath(value.sourcePrefix); scopePath(value.configPath);
17
+ if (!/(?:^|\/)(?:ts|js)config(?:\.[^/]+)?\.json$/.test(value.configPath)) contextFail('A context requires an actual tsconfig or jsconfig path');
18
+ }
19
+ return values;
20
+ }
21
+ export function retainedContexts(original, values) {
22
+ contextList(values);
23
+ if (original.some(prior => !values.some(value => canonical(value) === canonical(prior)))) {
24
+ contextFail('Context correction must retain every original build context unchanged');
25
+ }
26
+ }
27
+ function contextEvidence(values) {
28
+ if (!Array.isArray(values) || values.length > 200 || new Set(values.map(value => value?.path)).size !== values.length) contextFail('Context build evidence is invalid');
29
+ for (const value of values) {
30
+ contextExact(value, ['path', 'sha256']); scopePath(value.path);
31
+ if (!hash.test(value.sha256 ?? '')) contextFail('Context build evidence requires actual file hashes');
32
+ }
33
+ }
34
+ export function contextInput(input) {
35
+ const common = ['flowId', 'action'];
36
+ if (input?.action === 'review') common.push('importContexts', 'evidencePaths', 'reason');
37
+ else if (input?.action === 'apply') common.push('proposalId', 'proposalDigest');
38
+ else if (input?.action !== 'status') contextFail('Expected scope-context review, apply or status');
39
+ contextExact(input, common);
40
+ if (!id.test(input.flowId ?? '')) contextFail('Context recovery requires its original flow');
41
+ if (input.action === 'apply' && (!id.test(input.proposalId ?? '') || !hash.test(input.proposalDigest ?? ''))) contextFail('Context apply requires the original exact proposal');
42
+ if (input.action === 'review') {
43
+ contextList(input.importContexts);
44
+ if (!Array.isArray(input.evidencePaths) || !input.evidencePaths.length || input.evidencePaths.length > 100
45
+ || new Set(input.evidencePaths).size !== input.evidencePaths.length
46
+ || typeof input.reason !== 'string' || !input.reason.trim() || input.reason.length > 2000) contextFail('Context review requires actual bounded build evidence and a reason');
47
+ input.evidencePaths.forEach(scopePath);
48
+ }
49
+ return input;
50
+ }
51
+ export const originalContextDigest = location => sha256(canonical({ localBindingDigest: location.localBindingDigest,
52
+ importContexts: location.binding.importContexts }));
53
+ export function validateContextAuthority(value, location, previous) {
54
+ const record = scopeRevisionAuthority(value, 'entrorove.scope-context-authority/1.0');
55
+ const expectedRevision = (previous?.record.revision ?? 0) + 1;
56
+ if (['flowId', 'runnerId', 'repositoryId', 'keyId'].some(field => record[field] !== location.binding[field])
57
+ || record.localBindingDigest !== location.localBindingDigest || record.revision !== expectedRevision
58
+ || record.previousDigest !== (previous?.revisionDigest ?? originalContextDigest(location))
59
+ || canonical(record.originalImportContexts) !== canonical(location.binding.importContexts)
60
+ || canonical(record.targetPaths) !== canonical(location.binding.targetPaths)
61
+ || !id.test(record.ownerId ?? '') || !hash.test(record.taskDigest ?? '') || !hash.test(record.chainDigest ?? '')
62
+ || !id.test(record.proposalId ?? '') || !hash.test(record.proposalDigest ?? '')) contextFail('Context authority replaced its original signer, paths or revision chain');
63
+ retainedContexts(previous?.record.importContexts ?? location.binding.importContexts, record.importContexts);
64
+ contextEvidence(record.evidence);
65
+ if (previous && ['ownerId', 'taskDigest', 'chainDigest'].some(field => record[field] !== previous.record[field])
66
+ || record.importContexts.some(context => !record.evidence.some(file => file.path === context.configPath))) contextFail('Context authority lost its original task or actual configuration evidence');
67
+ return { record, revisionDigest: sha256(canonical(record)) };
68
+ }
69
+ export function contextChallenge(value, location, proposal, { allowExpired = false } = {}) {
70
+ const challenge = scopeRevisionAuthority(value, 'entrorove.scope-context-challenge/1.0');
71
+ if (['flowId', 'runnerId', 'repositoryId', 'keyId'].some(field => challenge[field] !== location.binding[field])
72
+ || challenge.localBindingDigest !== location.localBindingDigest
73
+ || ['proposalId', 'proposalDigest', 'expectedRevision', 'previousDigest', 'ownerId', 'taskDigest', 'chainDigest'].some(field => challenge[field] !== proposal[field])
74
+ || canonical(challenge.targetPaths) !== canonical(location.binding.targetPaths)
75
+ || canonical(challenge.importContexts) !== canonical(proposal.importContexts)
76
+ || canonical(challenge.evidence) !== canonical(proposal.evidence)
77
+ || ['stepId', 'challengeId', 'challenge'].some(field => !id.test(challenge[field] ?? '')) || !challenge.task) contextFail('Context challenge differs from its exact original proposal');
78
+ retainedContexts(location.binding.importContexts, challenge.importContexts);
79
+ contextEvidence(challenge.evidence);
80
+ const issued = Date.parse(challenge.issuedAt), expires = Date.parse(challenge.expiresAt);
81
+ if (!Number.isFinite(issued) || !Number.isFinite(expires) || issued > Date.now() + 5000
82
+ || expires <= issued || expires - issued > 90_000 || !allowExpired && expires <= Date.now()) contextFail('Context challenge is expired');
83
+ return challenge;
84
+ }
@@ -7,6 +7,7 @@ import { withStateLock } from './local-state-lock.mjs';
7
7
  import { snapshotPlan, snapshotVerify } from './local-execution.mjs';
8
8
  import { probe } from './local-probe.mjs';
9
9
  import { invokeRemoteResult } from './remote-client.mjs';
10
+ import { boundScopeContexts } from './scope-context-state.mjs';
10
11
 
11
12
  async function privateDirectory(directory) {
12
13
  await fs.mkdir(directory, { recursive: true, mode: 0o700 });
@@ -23,7 +24,7 @@ async function readPrivate(file) {
23
24
  const handle = await fs.open(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
24
25
  try { return await handle.readFile('utf8'); } finally { await handle.close(); }
25
26
  }
26
- async function signer(directory, identity) {
27
+ async function signer(directory, identity, dependencies) {
27
28
  const controls = path.join(directory, 'identity'), file = path.join(controls, 'binding.json');
28
29
  let binding;
29
30
  try { binding = JSON.parse(await readPrivate(file)); }
@@ -41,7 +42,9 @@ async function signer(directory, identity) {
41
42
  await fs.rename(stage, controls);
42
43
  } finally { await fs.rm(stage, { recursive: true, force: true }); }
43
44
  }
44
- if (Object.keys(identity).some(field => canonical(binding[field]) !== canonical(identity[field]))) {
45
+ const contextAuthority = await boundScopeContexts(directory, binding, dependencies);
46
+ if (Object.keys(identity).filter(field => field !== 'importContexts').some(field => canonical(binding[field]) !== canonical(identity[field]))
47
+ || canonical(contextAuthority?.record.importContexts ?? binding.importContexts) !== canonical(identity.importContexts)) {
45
48
  scopeFail('ENTROROVE_SCOPE_REPLACEMENT', 'The original local task signer, repository and path set cannot be replaced');
46
49
  }
47
50
  await privateDirectory(controls);
@@ -49,7 +52,7 @@ async function signer(directory, identity) {
49
52
  if (key.asymmetricKeyType !== 'ed25519' || sha256(createPublicKey(key).export({ type: 'spki', format: 'der' })) !== binding.keyId) {
50
53
  scopeFail('ENTROROVE_SCOPE_STATE', 'Original scope signer key identity changed');
51
54
  }
52
- return { binding, key, signerConfigPath: path.join(controls, 'signer.json') };
55
+ return { binding, key, signerConfigPath: path.join(controls, 'signer.json'), contextAuthority };
53
56
  }
54
57
  async function remote(operation, input, dependencies) {
55
58
  const { response, result } = await invokeRemoteResult({ operation, input }, dependencies);
@@ -60,7 +63,7 @@ async function remote(operation, input, dependencies) {
60
63
  return result.output;
61
64
  }
62
65
  async function connect(identity, directory, dependencies) {
63
- const { binding, key, signerConfigPath } = await signer(directory, identity);
66
+ const { binding, key, signerConfigPath, contextAuthority } = await signer(directory, identity, dependencies);
64
67
  const prepared = await remote('scope-runner-prepare', { flowId: identity.flowId, attemptId: randomUUID(), runnerId: binding.runnerId,
65
68
  repositoryId: binding.repositoryId, publicKey: binding.publicKey, targetPaths: identity.targetPaths }, dependencies);
66
69
  const enrollment = prepared.enrollment;
@@ -76,7 +79,8 @@ async function connect(identity, directory, dependencies) {
76
79
  scopeFail('ENTROROVE_SCOPE_BINDING', 'Cloud scope challenge differs from the original account and accepted task');
77
80
  }
78
81
  const input = { repositoryRoot: identity.repositoryRoot, home: dependencies.home, flowId: identity.flowId,
79
- stepId: challenge.stepId, paths: identity.targetPaths, snapshotId: `snapshot-${randomUUID()}`, importContexts: identity.importContexts };
82
+ stepId: challenge.stepId, paths: identity.targetPaths, snapshotId: `snapshot-${randomUUID()}`, importContexts: identity.importContexts,
83
+ ...(contextAuthority ? { contextRevisionDigest: contextAuthority.revisionDigest, contextEvidence: contextAuthority.record.evidence } : {}) };
80
84
  const planned = await snapshotPlan(input), verified = await snapshotVerify(input);
81
85
  if (verified.verified !== true || verified.receipt.snapshotDigest !== planned.snapshotDigest) scopeFail('ENTROROVE_SCOPE_SNAPSHOT', 'Actual copied snapshot did not verify');
82
86
  const observed = await probe({ ...input, ownerId: challenge.ownerId, task: challenge.task, taskDigest: challenge.taskDigest,