@entrorove/skill 0.1.52 → 0.1.53

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 (45) hide show
  1. package/CAPABILITY-MAP.md +14 -2
  2. package/CHANGELOG.md +7 -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-command.mjs +1 -1
  10. package/ide-hook.mjs +24 -10
  11. package/ide-results.mjs +8 -6
  12. package/ide-session.mjs +44 -1
  13. package/local-approved-plan.mjs +3 -21
  14. package/local-approved-process.mjs +54 -0
  15. package/local-approved-supervisor.mjs +99 -0
  16. package/local-cli.mjs +13 -2
  17. package/local-contracts.mjs +2 -0
  18. package/local-delegated-modify-analysis.mjs +232 -0
  19. package/local-discipline-actions.mjs +2 -0
  20. package/local-discipline-gate.mjs +3 -1
  21. package/local-error.mjs +3 -0
  22. package/local-execution.mjs +1 -1
  23. package/local-mutation-authority.mjs +23 -5
  24. package/local-mutation-batch.mjs +1 -0
  25. package/local-mutation-binding.mjs +14 -5
  26. package/local-mutation-consumption.mjs +93 -0
  27. package/local-mutation-pass-store.mjs +6 -4
  28. package/local-mutation-wire.mjs +21 -4
  29. package/local-preserved-mutation-protection.mjs +51 -0
  30. package/local-preserved-mutation-signer.mjs +81 -0
  31. package/local-preserved-mutation-state.mjs +96 -0
  32. package/local-preserved-mutation-wire.mjs +49 -0
  33. package/manifest.json +1 -1
  34. package/mutation-client.mjs +290 -0
  35. package/package.json +19 -2
  36. package/release-state.json +43 -26
  37. package/release.mjs +25 -0
  38. package/releases.json +6 -0
  39. package/remote-client.mjs +2 -1
  40. package/workflow-observation-client.mjs +44 -0
  41. package/workflow-observation-connection.mjs +35 -0
  42. package/workflow-observation-remote.mjs +84 -0
  43. package/workflow-observation-runner.mjs +87 -0
  44. package/workflow-observation-state.mjs +36 -0
  45. package/workflow-observation-wire.mjs +69 -0
@@ -0,0 +1,84 @@
1
+ import { canonical } from './local-integrity.mjs';
2
+ import { buildInvocation, invokeRemoteResult } from './remote-client.mjs';
3
+ import { requestTransport, parseTransportResponse } from './remote-transport.mjs';
4
+ import { ideClientHeaders } from './ide-presentation.mjs';
5
+ import { acknowledgeAutomaticFeedback, queryAutomaticFeedback, verifyAutomaticFeedback } from './automatic-feedback-client.mjs';
6
+ import { saveObservationState } from './workflow-observation-state.mjs';
7
+ import { observationFail } from './workflow-observation-wire.mjs';
8
+ import { readInvocations } from './local-invocations.mjs';
9
+
10
+ const unknown = requestId => Object.assign(new Error('Original local observation request outcome is unknown; recover its GET without repeating execution'),
11
+ { code: 'ENTROROVE_LOCAL_OBSERVATION_UNKNOWN', outcomeUnknown: true, requestId });
12
+ const notSent = requestId => Object.assign(new Error('Original local observation was not transmitted'),
13
+ { code: 'ENTROROVE_LOCAL_OBSERVATION_NOT_SENT', knownBeforeTransmission: true, requestId });
14
+ async function knownNotSent(pending, home) {
15
+ try {
16
+ const records = (await readInvocations(home)).filter(value => value.kind === 'remote'
17
+ && value.operation === pending.operation && value.requestId === pending.requestId);
18
+ return !records.length || records.at(-1).event === 'not-sent';
19
+ } catch (error) { return error?.code === 'ENOENT'; }
20
+ }
21
+ async function recover(pending, dependencies) {
22
+ const token = await dependencies.readCredential(dependencies.home), origin = dependencies.apiOrigin();
23
+ let response, result;
24
+ try {
25
+ response = await requestTransport(origin, `/api/skill/status/${pending.requestId}`, { headers: {
26
+ authorization: `Bearer ${token}`, ...ideClientHeaders() } });
27
+ result = parseTransportResponse(response, 'original local observation status');
28
+ if (!response.ok || result?.output?.requestId !== pending.requestId
29
+ || ['pending', 'running'].includes(result.output.status)) throw unknown(pending.requestId);
30
+ result.feedback ??= await queryAutomaticFeedback(pending.requestId, dependencies);
31
+ verifyAutomaticFeedback(result.feedback, pending.requestId, pending.requestId);
32
+ result.feedback = await acknowledgeAutomaticFeedback(result.feedback, origin, token, ideClientHeaders());
33
+ } catch { throw unknown(pending.requestId); }
34
+ return { response, result };
35
+ }
36
+ function prerequisite(output) {
37
+ return output?.status === 'blocked' && output.recoverable === true && (output.observationPrerequisite?.effect === 'none'
38
+ || ['ENTROROVE_SCOPE_RECEIPT_EXPIRED', 'ENTROROVE_SCOPE_RUNNER_REQUIRED', 'ENTROROVE_SCOPE_SNAPSHOT_REQUIRED',
39
+ 'ENTROROVE_SCOPE_AFTER_WRITE_REQUIRED'].includes(output.code));
40
+ }
41
+ export async function observationRemote(stage, operation, input, state, location, dependencies) {
42
+ const requestId = buildInvocation(operation, input).requestId;
43
+ let response, result;
44
+ if (state.pending) {
45
+ if (state.pending.stage !== stage || state.pending.operation !== operation || state.pending.requestId !== requestId
46
+ || canonical(state.pending.input) !== canonical(input)) observationFail('Original observation request must finish before another operation');
47
+ ({ response, result } = await recover(state.pending, dependencies));
48
+ } else {
49
+ state.pending = { stage, operation, input, requestId };
50
+ try { await saveObservationState(location, state); } catch { throw notSent(requestId); }
51
+ try { ({ response, result } = await invokeRemoteResult({ operation, input }, dependencies)); }
52
+ catch {
53
+ if (await knownNotSent(state.pending, dependencies.home)) {
54
+ delete state.pending;
55
+ try { await saveObservationState(location, state); } catch { throw notSent(requestId); }
56
+ throw notSent(requestId);
57
+ }
58
+ throw unknown(requestId);
59
+ }
60
+ }
61
+ if (!response.ok || !['reported', 'succeeded'].includes(result?.output?.status)) {
62
+ if (operation === 'member-observation-admit' && prerequisite(result?.output)) {
63
+ delete state.pending; await saveObservationState(location, state);
64
+ throw Object.assign(new Error(result.output.message ?? 'Original local observation prerequisite is unresolved'),
65
+ { code: result.output.code, output: result.output });
66
+ }
67
+ throw unknown(requestId);
68
+ }
69
+ state[stage] = result.output; delete state.pending; await saveObservationState(location, state);
70
+ return result.output;
71
+ }
72
+ export async function authorizeObservation(admission, dependencies, launching) {
73
+ const token = await dependencies.readCredential(dependencies.home);
74
+ const response = await requestTransport(dependencies.apiOrigin(), `/api/skill/flows/${admission.flowId}/status`, {
75
+ headers: { authorization: `Bearer ${token}`, ...ideClientHeaders() } });
76
+ const status = parseTransportResponse(response, 'original local observation account');
77
+ if (!response.ok || status?.schemaVersion !== 'entrorove.flow-status/1.0'
78
+ || status.flow?.flowId !== admission.flowId || status.flow.ownerId !== admission.ownerId
79
+ || launching && (status.flow.stepId !== admission.stepId || status.flow.status !== 'running'
80
+ || status.workflow?.phase !== 'execute' || status.workflow.terminalAt
81
+ || ['pending', 'declined'].includes(status.workflow.authorization))) {
82
+ observationFail('Current account cannot confirm its original local observation task and execution step');
83
+ }
84
+ }
@@ -0,0 +1,87 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import { canonical, sha256 } from './local-integrity.mjs';
3
+ import { scopeRead, scopeTarget } from './local-scope-files.mjs';
4
+ import { regular } from './local-task-handoff-storage.mjs';
5
+ import { assertBudgetRunnerAllowed, withBudgetReadContext } from './local-budget-reads.mjs';
6
+ import { disciplineRepositoryObservation, disciplineRepositoryDifferences } from './local-discipline-repository.mjs';
7
+ import { runApprovedProcess } from './local-approved-process.mjs';
8
+ import { observationAdmission, observationFail } from './workflow-observation-wire.mjs';
9
+ import { saveObservationState } from './workflow-observation-state.mjs';
10
+
11
+ async function observeFile(input, expected) {
12
+ const before = await scopeTarget(input.repositoryRoot, expected.path, false, true);
13
+ const read = await scopeRead(input.repositoryRoot, expected.path, input);
14
+ const after = await scopeTarget(input.repositoryRoot, expected.path, false, true);
15
+ if (read.sha256 !== expected.sha256 || expected.mode !== undefined && expected.mode !== null && (after.stat.mode & 511) !== expected.mode
16
+ || before.stat.dev !== after.stat.dev || before.stat.ino !== after.stat.ino || before.stat.mode !== after.stat.mode) {
17
+ observationFail('Local observation source or dependency differs from its admitted bytes and permissions');
18
+ }
19
+ return { path: expected.path, sha256: read.sha256, mode: after.stat.mode & 511,
20
+ realPath: await fs.realpath(after.target), dev: after.stat.dev, ino: after.stat.ino };
21
+ }
22
+ async function files(input, manifest) {
23
+ const result = [];
24
+ for (const expected of manifest) result.push(await observeFile(input, expected));
25
+ return result;
26
+ }
27
+ async function executable(admission) {
28
+ const { bytes, stat } = await regular(admission.command.executable, 256 * 1024 * 1024);
29
+ if (sha256(bytes) !== admission.command.executableSha256) observationFail('Local observation executable differs from its frozen command hash');
30
+ return { mode: stat.mode, dev: stat.dev, ino: stat.ino };
31
+ }
32
+ const same = (before, after) => canonical(before) === canonical(after);
33
+ async function remains(action, expected) { try { return same(expected, await action()); } catch { return false; } }
34
+ function processMetadata(process) {
35
+ return Object.fromEntries(['status', 'started', 'startedAt', 'finishedAt', 'exitCode', 'signal', 'timedOut',
36
+ 'outputLimitExceeded', 'durationMs', 'stdoutSha256', 'stderrSha256', 'capturedBytes', 'droppedBytes'].map(key => [key, process[key]]));
37
+ }
38
+ async function execute(input, admission, state, location, options) {
39
+ if (state.run) observationFail('Original local observation already started or has an unknown outcome; a new process is forbidden');
40
+ observationAdmission(admission, input);
41
+ await assertBudgetRunnerAllowed(input);
42
+ const originalSources = await files(input, admission.sourceManifest), originalProtected = await files(input, admission.protectedFiles);
43
+ const originalExecutable = await executable(admission);
44
+ const before = (await disciplineRepositoryObservation(input, input.repositoryRoot)).comparison;
45
+ observationAdmission(admission, input);
46
+ state.run = { phase: 'running', pid: process.pid, startedAt: new Date().toISOString() };
47
+ await saveObservationState(location, state);
48
+ let processResult;
49
+ try {
50
+ await assertBudgetRunnerAllowed(input);
51
+ if (!same(originalSources, await files(input, admission.sourceManifest))
52
+ || !same(originalProtected, await files(input, admission.protectedFiles))
53
+ || !same(originalExecutable, await executable(admission))) observationFail('Local observation inputs changed immediately before execution');
54
+ observationAdmission(admission, input);
55
+ if (options.authorize) await options.authorize();
56
+ observationAdmission(admission, input);
57
+ processResult = await runApprovedProcess({ root: input.repositoryRoot, policy: admission.command,
58
+ objectPlan: true, startDeadlineAt: admission.expiresAt });
59
+ } catch (error) {
60
+ if (!error?.execution?.process) {
61
+ state.run.phase = 'aborted'; await saveObservationState(location, state);
62
+ observationFail('Local observation stopped before a completed process; recover the original observation without rerunning');
63
+ }
64
+ processResult = error.execution.process;
65
+ }
66
+ let after = before, budgetAllowed = true;
67
+ try { await assertBudgetRunnerAllowed(input); after = (await disciplineRepositoryObservation(input, input.repositoryRoot)).comparison; }
68
+ catch { budgetAllowed = false; }
69
+ const checks = { sourceManifestUnchanged: budgetAllowed && await remains(() => files(input, admission.sourceManifest), originalSources),
70
+ protectedFilesUnchanged: budgetAllowed && await remains(() => files(input, admission.protectedFiles), originalProtected),
71
+ repositoryUnchanged: budgetAllowed && disciplineRepositoryDifferences(before, after).length === 0
72
+ && await remains(() => executable(admission), originalExecutable) };
73
+ state.result = { schemaVersion: 'entrorove.local-observation-result/1.0', observationId: admission.observationId,
74
+ admissionDigest: sha256(canonical(admission)), status: processResult.status === 'succeeded' && Object.values(checks).every(Boolean) ? 'incomplete' : 'failed',
75
+ independentValidation: 'pending', runner: 'local', receipt: null,
76
+ executionIsolation: 'host-permissions', externalEffects: 'unverified', process: processMetadata(processResult),
77
+ sourceManifestDigest: sha256(canonical(admission.sourceManifest)), checks,
78
+ beforeRepositoryDigest: sha256(canonical(before)), afterRepositoryDigest: sha256(canonical(after)) };
79
+ state.resultDigest = sha256(canonical(state.result)); state.run.phase = 'finished';
80
+ await saveObservationState(location, state);
81
+ return state.result;
82
+ }
83
+ export async function runWorkflowObservation(input, admission, state, location, options = {}) {
84
+ observationAdmission(admission, input, { allowExpired: Boolean(state.result) });
85
+ if (state.result) return state.result;
86
+ return withBudgetReadContext(input, () => execute(input, admission, state, location, options));
87
+ }
@@ -0,0 +1,36 @@
1
+ import path from 'node:path';
2
+ import { canonical, sha256 } from './local-integrity.mjs';
3
+ import { mutationRoot14, mutationHome14, mutationStateRoot14, privateMutationDirectory14,
4
+ readMutationJson14, atomicMutationJson14 } from './local-mutation-files.mjs';
5
+ import { observationExact, observationFail } from './workflow-observation-wire.mjs';
6
+
7
+ export async function observationLocation(repositoryRoot, input, home, apiOrigin) {
8
+ const root = await mutationRoot14(repositoryRoot), actualHome = await mutationHome14(home);
9
+ const relative = path.relative(root, actualHome);
10
+ if (!relative || relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)) {
11
+ observationFail('Local observation state must be outside the observed repository');
12
+ }
13
+ const identity = { ...input, repositoryRoot: root, home: actualHome, apiOrigin };
14
+ const stateRoot = await mutationStateRoot14({ home: actualHome });
15
+ const directory = path.join(stateRoot, 'local-observations', sha256(root).slice(0, 32), input.flowId, sha256(canonical(identity)));
16
+ await privateMutationDirectory14(directory, { privateFrom: stateRoot });
17
+ return { root, home: actualHome, identity, stateRoot, file: path.join(directory, 'journal.json') };
18
+ }
19
+ export async function readObservationState(location) {
20
+ let state;
21
+ try { state = await readMutationJson14(location.file); }
22
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
23
+ if (!state) return { schemaVersion: 'entrorove.local-observation-state/1.0', identity: location.identity };
24
+ if (state.schemaVersion !== 'entrorove.local-observation-state/1.0' || canonical(state.identity) !== canonical(location.identity)) {
25
+ observationFail('Local observation journal lost its original repository, home, task or settlement');
26
+ }
27
+ if (state.result && state.resultDigest !== sha256(canonical(state.result))) observationFail('Local observation stored result integrity changed');
28
+ if (state.run) {
29
+ observationExact(state.run, ['phase', 'pid', 'startedAt']);
30
+ if (!['running', 'finished', 'aborted'].includes(state.run.phase) || !Number.isSafeInteger(state.run.pid)
31
+ || !Number.isFinite(Date.parse(state.run.startedAt))) observationFail('Local observation running state is invalid');
32
+ }
33
+ return state;
34
+ }
35
+ export const saveObservationState = (location, state) => atomicMutationJson14(location.file, state,
36
+ { privateFrom: location.stateRoot, replace: true });
@@ -0,0 +1,69 @@
1
+ import { createPublicKey, verify } from 'node:crypto';
2
+ import { canonical, sha256 } from './local-integrity.mjs';
3
+ import { mutationId14, mutationPaths14 } from './local-mutation-files.mjs';
4
+
5
+ export const observationFail = message => { throw Object.assign(new Error(message), { code: 'ENTROROVE_LOCAL_OBSERVATION' }); };
6
+ export const observationIdentityFields = ['taskId', 'flowId', 'stepId', 'leaseId', 'generation', 'settlementId'];
7
+ export function observationExact(value, fields) {
8
+ if (!value || typeof value !== 'object' || Array.isArray(value)
9
+ || canonical(Object.keys(value).sort()) !== canonical([...fields].sort())) observationFail('Local observation fields are invalid');
10
+ }
11
+ export function observationInput(input) {
12
+ observationExact(input, observationIdentityFields);
13
+ for (const field of observationIdentityFields.filter(field => field !== 'generation')) mutationId14(input[field], field);
14
+ if (!Number.isSafeInteger(input.generation) || input.generation < 1) observationFail('Local observation generation is invalid');
15
+ return input;
16
+ }
17
+ function command(value) {
18
+ observationExact(value, ['executable', 'executableSha256', 'args', 'environment', 'timeoutMs', 'maxOutputBytes', 'requiredExitCode']);
19
+ if (typeof value.executable !== 'string' || !value.executable.startsWith('/') || /[\u0000-\u001f\u007f]/.test(value.executable)
20
+ || !/^[a-f0-9]{64}$/.test(value.executableSha256) || !Array.isArray(value.args) || value.args.length > 128
21
+ || value.args.some(arg => typeof arg !== 'string' || arg.length > 4096 || arg.includes('\0'))
22
+ || !value.environment || typeof value.environment !== 'object' || Array.isArray(value.environment)
23
+ || Object.keys(value.environment).length > 64 || Object.entries(value.environment).some(([key, val]) =>
24
+ !/^[A-Za-z_][A-Za-z0-9_]*$/.test(key) || typeof val !== 'string' || val.length > 16_384 || val.includes('\0'))
25
+ || !Number.isSafeInteger(value.timeoutMs) || value.timeoutMs < 1 || value.timeoutMs > 300_000
26
+ || !Number.isSafeInteger(value.maxOutputBytes) || value.maxOutputBytes < 1 || value.maxOutputBytes > 1_048_576
27
+ || value.requiredExitCode !== 0) observationFail('Local observation command differs from its bounded frozen policy');
28
+ }
29
+ function files(values, modes) {
30
+ if (!Array.isArray(values) || !values.length || values.length > (modes ? 512 : 2_000)) observationFail('Local observation manifest is invalid');
31
+ const paths = mutationPaths14(values.map(file => file.path));
32
+ if (canonical(paths) !== canonical(values.map(file => file.path))) observationFail('Local observation manifest must be sorted and unique');
33
+ for (const file of values) {
34
+ observationExact(file, ['path', 'sha256', ...(modes ? ['mode'] : [])]);
35
+ if (!/^[a-f0-9]{64}$/.test(file.sha256) || modes && file.mode !== null
36
+ && (!Number.isSafeInteger(file.mode) || file.mode < 0 || file.mode > 511)) observationFail('Local observation file baseline is invalid');
37
+ }
38
+ }
39
+ function signature(value) {
40
+ try {
41
+ const encoded = process.env.ENTROROVE_WORKFLOW_PLAN_PUBLIC_KEY, der = Buffer.from(encoded ?? '', 'base64');
42
+ const key = createPublicKey({ key: der, type: 'spki', format: 'der' }), receipt = value.authorityReceipt;
43
+ observationExact(receipt, ['schemaVersion', 'keyId', 'payloadDigest', 'signature']);
44
+ const { authorityReceipt: _, ...core } = value, bytes = Buffer.from(canonical(core));
45
+ const signed = Buffer.from(receipt.signature, 'base64url');
46
+ if (key.asymmetricKeyType !== 'ed25519' || key.export({ type: 'spki', format: 'der' }).toString('base64') !== encoded
47
+ || receipt.schemaVersion !== 'entrorove.workflow-authority-receipt/1.0' || receipt.keyId !== sha256(der)
48
+ || receipt.payloadDigest !== sha256(bytes) || bytes.length > 1_048_576
49
+ || signed.length !== 64 || signed.toString('base64url') !== receipt.signature || !verify(null, bytes, key, signed)) throw new Error();
50
+ } catch { observationFail('Local observation requires its original pinned public cloud admission signature'); }
51
+ }
52
+ export function observationAdmission(value, input, { allowExpired = false } = {}) {
53
+ observationExact(value, [...observationIdentityFields, 'schemaVersion', 'purpose', 'observationId', 'ownerId', 'taskDigest',
54
+ 'chainDigest', 'epoch', 'planId', 'planDigest', 'executionPlanSha256', 'repositoryDigest', 'settlementDigest', 'command',
55
+ 'sourceManifest', 'protectedFiles', 'issuedAt', 'expiresAt', 'authorityReceipt']);
56
+ signature(value);
57
+ const start = Date.parse(value.issuedAt), end = Date.parse(value.expiresAt);
58
+ if (value.schemaVersion !== 'entrorove.local-observation-admission/1.0' || value.purpose !== 'observe-approved-command'
59
+ || value.epoch !== 0 || observationIdentityFields.some(field => value[field] !== input[field])
60
+ || value.repositoryDigest !== sha256(input.repositoryRoot).slice(0, 32) || !Number.isFinite(start) || !Number.isFinite(end)
61
+ || start > Date.now() || end <= start || end - start > 600_000 || !allowExpired && end <= Date.now()
62
+ || !/^[a-f0-9-]{36}$/.test(value.ownerId)
63
+ || ['taskDigest', 'chainDigest', 'planDigest', 'executionPlanSha256', 'settlementDigest'].some(field => !/^[a-f0-9]{64}$/.test(value[field]))) {
64
+ observationFail('Local observation admission is expired or lost its original task, repository or settlement');
65
+ }
66
+ mutationId14(value.observationId, 'observationId'); mutationId14(value.planId, 'planId');
67
+ command(value.command); files(value.sourceManifest, true); files(value.protectedFiles, false);
68
+ return value;
69
+ }