@entrorove/skill 0.1.61 → 0.1.64
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/CAPABILITY-MAP.md +7 -2
- package/CHANGELOG.md +15 -0
- package/IDE-EXECUTION.md +39 -6
- package/REUSE-DISCIPLINE.md +1 -1
- package/SKILL.md +6 -2
- package/cli-input.mjs +22 -0
- package/cli.mjs +2 -8
- package/cloud-task-client.mjs +10 -2
- package/cloud-task-images.mjs +39 -0
- package/ide-exit.mjs +49 -9
- package/ide-hook.mjs +2 -2
- package/local-contracts.mjs +4 -1
- package/local-discipline-gate.mjs +14 -3
- package/local-discipline-repository.mjs +11 -5
- package/local-finder-plist.mjs +87 -0
- package/local-finder-store.mjs +115 -0
- package/local-mutation-authority.mjs +11 -0
- package/local-mutation-completion-evidence.mjs +83 -0
- package/local-mutation-consumption.mjs +39 -14
- package/local-mutation-environment-observation.mjs +85 -0
- package/local-mutation-finder-observation.mjs +58 -0
- package/local-mutation-wire.mjs +28 -9
- package/local-preserved-mutation-protection.mjs +8 -1
- package/local-preserved-mutation-wire.mjs +7 -3
- package/local-scope-files.mjs +3 -1
- package/local-scope-font.mjs +45 -0
- package/local-scope-repository.mjs +16 -3
- package/local-scope-zip.mjs +93 -0
- package/manifest.json +1 -1
- package/member-producer-repair-authority.mjs +48 -0
- package/model-images.d.mts +9 -0
- package/model-images.mjs +87 -0
- package/mutation-challenge-refresh.mjs +51 -0
- package/mutation-client.mjs +45 -27
- package/mutation-connection-state.mjs +17 -0
- package/package.json +18 -2
- package/release-state.json +52 -36
- package/release.mjs +12 -4
- package/releases.json +18 -0
- package/remote-client.mjs +1 -1
- package/remote-transport.mjs +48 -16
- package/scope-amendment-client.mjs +1 -1
- package/scope-amendment-consumption.mjs +32 -0
- package/scope-amendment-mutation.mjs +3 -4
- package/scope-amendment-observer.mjs +7 -3
- package/scope-amendment-remote.mjs +12 -2
- package/scope-amendment-retirement.mjs +54 -0
- package/scope-amendment-state.mjs +7 -4
- package/scope-amendment-wire.mjs +3 -2
- package/task-entry-client.mjs +1 -0
- package/workflow-observation-client.mjs +11 -0
- package/workflow-observation-connection.mjs +2 -1
- package/workflow-observation-remote.mjs +26 -1
- package/workflow-observation-wire.mjs +6 -3
package/local-mutation-wire.mjs
CHANGED
|
@@ -39,6 +39,21 @@ function pinnedAuthority() {
|
|
|
39
39
|
return { key, keyId: sha256(der) };
|
|
40
40
|
} catch { mutationFail14('ENTROROVE_MUTATION_AUTHORITY_INVALID', 'Pinned cloud mutation authority is unavailable or invalid'); }
|
|
41
41
|
}
|
|
42
|
+
function originalReceiver(payload) {
|
|
43
|
+
const receiver = payload.receiver;
|
|
44
|
+
const original = receiver?.schemaVersion === 'entrorove.original-task-mutation-receiver/1.0';
|
|
45
|
+
exact(receiver, original ? ['schemaVersion', 'taskId', 'ownerId', 'flowId', 'originalRequestId', 'taskDigest',
|
|
46
|
+
'routeDigest', 'acceptanceReceiptId', 'acceptanceDigest']
|
|
47
|
+
: ['taskId', 'ownerId', 'projectId', 'hostId', 'threadId', 'descriptionDigest', 'taskDigest']);
|
|
48
|
+
if (receiver.taskId !== payload.taskId || receiver.ownerId !== payload.ownerId || receiver.taskDigest !== payload.taskDigest
|
|
49
|
+
|| sha256(canonical(receiver)) !== payload.receiverDigest || (original
|
|
50
|
+
? typeof receiver.originalRequestId !== 'string' || !ID.test(receiver.originalRequestId)
|
|
51
|
+
|| ['flowId', 'routeDigest', 'acceptanceReceiptId', 'acceptanceDigest']
|
|
52
|
+
.some(field => receiver[field] !== payload[field])
|
|
53
|
+
: !SHA.test(receiver.descriptionDigest) || ['projectId', 'hostId', 'threadId'].some(field => !ID.test(String(receiver[field]))))) {
|
|
54
|
+
mutationFail14('ENTROROVE_MUTATION_AUTHORITY_INVALID', 'Mutation authority lost its original accepted receiver');
|
|
55
|
+
}
|
|
56
|
+
}
|
|
42
57
|
function identity(payload) {
|
|
43
58
|
if (Object.hasOwn(payload, 'scopeRevisionDigest') && !SHA.test(payload.scopeRevisionDigest)) {
|
|
44
59
|
mutationFail14('ENTROROVE_MUTATION_AUTHORITY_INVALID', 'Mutation authority scope revision is invalid');
|
|
@@ -74,13 +89,7 @@ function identity(payload) {
|
|
|
74
89
|
|| canonical([...payload.newFilePaths].sort()) !== canonical(payload.newFilePaths)) {
|
|
75
90
|
mutationFail14('ENTROROVE_MUTATION_AUTHORITY_INVALID', 'Mutation authority path set is invalid');
|
|
76
91
|
}
|
|
77
|
-
|
|
78
|
-
if (payload.receiver.taskId !== payload.taskId || payload.receiver.ownerId !== payload.ownerId
|
|
79
|
-
|| payload.receiver.taskDigest !== payload.taskDigest || !SHA.test(payload.receiver.descriptionDigest)
|
|
80
|
-
|| sha256(canonical(payload.receiver)) !== payload.receiverDigest
|
|
81
|
-
|| ['projectId', 'hostId', 'threadId'].some(field => !ID.test(String(payload.receiver[field])))) {
|
|
82
|
-
mutationFail14('ENTROROVE_MUTATION_AUTHORITY_INVALID', 'Mutation authority lost its original cloud receiver');
|
|
83
|
-
}
|
|
92
|
+
originalReceiver(payload);
|
|
84
93
|
if (delegated) preservedMutationDelegation(payload);
|
|
85
94
|
}
|
|
86
95
|
function authority(value, fields, schema, options) {
|
|
@@ -101,7 +110,16 @@ function authority(value, fields, schema, options) {
|
|
|
101
110
|
identity(payload); mutationLifetime14(payload, options); return value;
|
|
102
111
|
}
|
|
103
112
|
export function mutationChallenge14(value, options) {
|
|
104
|
-
|
|
113
|
+
const fields = authorityFields(value, CHALLENGE_FIELDS);
|
|
114
|
+
if (Object.hasOwn(value ?? {}, 'previousChallenge')) {
|
|
115
|
+
exact(value.previousChallenge, ['challengeId', 'challengeDigest']);
|
|
116
|
+
if (!ID.test(value.previousChallenge.challengeId) || !SHA.test(value.previousChallenge.challengeDigest)
|
|
117
|
+
|| value.verificationMode !== 'member-local' || !value.writeDelegation) {
|
|
118
|
+
mutationFail14('ENTROROVE_MUTATION_AUTHORITY_INVALID', 'Expired challenge predecessor requires its exact member-local binding');
|
|
119
|
+
}
|
|
120
|
+
fields.push('previousChallenge');
|
|
121
|
+
}
|
|
122
|
+
return authority(value, fields, 'entrorove.mutation-snapshot-challenge/1.0', options);
|
|
105
123
|
}
|
|
106
124
|
function authorityFields(value, fields) {
|
|
107
125
|
const original = Object.hasOwn(value ?? {}, 'writeDelegation') ? [...fields.filter(field =>
|
|
@@ -114,7 +132,8 @@ export function mutationPass14(value, options) {
|
|
|
114
132
|
if (pass.disciplinePolicy) disciplineApprovedPolicy(pass.disciplinePolicy);
|
|
115
133
|
if (pass.writeDelegation && (!pass.disciplinePolicy || pass.parallelPaths || pass.disciplinePolicy.dataOperations?.length
|
|
116
134
|
|| pass.paths.some(target => { const write = pass.disciplinePolicy.writes.find(item => item.targetPath === target);
|
|
117
|
-
return !write || write.kind !== 'modify'
|
|
135
|
+
return !write || write.kind !== (pass.writeDelegation.purpose !== 'preserved-existing-modify' ? 'create' : 'modify')
|
|
136
|
+
|| write.deletedSymbols?.length || write.exceptions?.length; }))) {
|
|
118
137
|
mutationFail14('ENTROROVE_MUTATION_DELEGATION_INVALID', 'Preserved mutation permits only the exact existing modification policy');
|
|
119
138
|
}
|
|
120
139
|
if (pass.parallelPaths && !validatorParallelDeclarationsValid(pass.parallelPaths)) mutationFail14('ENTROROVE_MUTATION_AUTHORITY_INVALID', 'Parallel paths must match the frozen equivalence contract');
|
|
@@ -25,7 +25,14 @@ export async function capturePreservedMutationProtection(input, root, authority,
|
|
|
25
25
|
const delegation = preservedMutationDelegation(authority), review = await readDisciplineSnapshot(snapshot);
|
|
26
26
|
for (const expected of delegation.targetFiles) {
|
|
27
27
|
const file = snapshot.manifest.files.find(item => item.path === expected.path);
|
|
28
|
+
if (delegation.purpose === 'planned-file-create') {
|
|
29
|
+
if (!file || file.exists !== false || file.sha256 !== expected.sha256 || file.mode !== null) {
|
|
30
|
+
preservedMutationFail('Planned creation requires an actual absent target in the original M snapshot');
|
|
31
|
+
}
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
28
34
|
if (!file?.exists || file.sha256 !== expected.sha256) preservedMutationFail('Preserved mutation target must retain its exact existing gate baseline');
|
|
35
|
+
if (delegation.purpose === 'planned-file-modify') continue;
|
|
29
36
|
const imports = await scopeCandidateImports(root, { path: expected.path,
|
|
30
37
|
content: (await scopeRead(root, expected.path, input)).content.toString('utf8') }, input);
|
|
31
38
|
const declared = delegation.targetImports.find(item => item.path === expected.path);
|
|
@@ -35,7 +42,7 @@ export async function capturePreservedMutationProtection(input, root, authority,
|
|
|
35
42
|
}
|
|
36
43
|
}
|
|
37
44
|
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);
|
|
45
|
+
if (unknown && delegation.purpose === 'preserved-existing-modify') unknownTarget(unknown.path, unknown);
|
|
39
46
|
const result = [];
|
|
40
47
|
for (const expected of delegation.protectedFiles) result.push(await observe(input, root, expected));
|
|
41
48
|
return result;
|
|
@@ -37,14 +37,18 @@ export function preservedMutationDelegation(authority) {
|
|
|
37
37
|
|| !local && authority.runnerKeyId === authority.authorityReceipt?.keyId) {
|
|
38
38
|
preservedMutationFail('Preserved mutation cannot promote scope, mutation or workflow signing keys to independent validation');
|
|
39
39
|
}
|
|
40
|
-
|
|
40
|
+
const create = value.purpose === 'planned-file-create';
|
|
41
|
+
if (value.schemaVersion !== 'entrorove.preserved-write-delegation/1.0' || !['preserved-existing-modify', 'planned-file-create', 'planned-file-modify'].includes(value.purpose)
|
|
41
42
|
|| ['scopeKeyId', 'mutationKeyId', 'gateEvidenceDigest', 'importGraphDigest'].some(field => !/^[0-9a-f]{64}$/.test(value[field]))
|
|
42
43
|
|| publicKey.keyId !== value.mutationKeyId || new Set(roleKeys).size !== roleKeys.length
|
|
43
|
-
|| authority.epoch !== 0 || authority.newFilePaths
|
|
44
|
+
|| authority.epoch !== 0 || (create ? canonical(authority.newFilePaths) !== canonical(authority.paths)
|
|
45
|
+
|| value.targetFiles.some(file => file.sha256 !== sha256('entrorove.missing-file/1.0'))
|
|
46
|
+
: authority.newFilePaths.length !== 0)) preservedMutationFail('Preserved mutation role or original epoch is invalid');
|
|
44
47
|
files(value.protectedFiles); files(value.targetFiles);
|
|
45
48
|
if (canonical(value.targetFiles.map(file => file.path)) !== canonical(authority.paths)
|
|
46
49
|
|| value.protectedFiles.some(file => authority.paths.includes(file.path))
|
|
47
|
-
|| !Array.isArray(value.targetImports) ||
|
|
50
|
+
|| !Array.isArray(value.targetImports) || (value.purpose !== 'preserved-existing-modify' ? value.targetImports.length !== 0
|
|
51
|
+
: canonical(value.targetImports.map(file => file?.path)) !== canonical(authority.paths))) {
|
|
48
52
|
preservedMutationFail('Preserved mutation must retain its exact existing target and protected path sets');
|
|
49
53
|
}
|
|
50
54
|
for (const file of value.targetImports) {
|
package/local-scope-files.mjs
CHANGED
|
@@ -2,6 +2,8 @@ import { constants, promises as fs } from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { canonical, sha256 } from './local-integrity.mjs';
|
|
4
4
|
import { readBudgetedProjectFile } from './local-budget-reads.mjs';
|
|
5
|
+
import { verifiedSfnt } from './local-scope-font.mjs';
|
|
6
|
+
import { verifiedZip } from './local-scope-zip.mjs';
|
|
5
7
|
|
|
6
8
|
export function scopeFail(code, message) { throw Object.assign(new Error(message), { code }); }
|
|
7
9
|
export function observedScopeRisk(files) {
|
|
@@ -135,7 +137,7 @@ export async function scopeFiles(root, requested, knownFiles = []) {
|
|
|
135
137
|
return [...found].sort();
|
|
136
138
|
}
|
|
137
139
|
export function lineCount(content) {
|
|
138
|
-
if (!content.length) return 0;
|
|
140
|
+
if (!content.length || verifiedSfnt(content) || verifiedZip(content)) return 0;
|
|
139
141
|
return content.toString('utf8').split(/\r\n|\n|\r/).length - (/[\r\n]$/.test(content.toString('utf8')) ? 1 : 0);
|
|
140
142
|
}
|
|
141
143
|
export async function packageBoundary(root, relative, input = { repositoryRoot: root }) {
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
function checksum(bytes, start, length, adjustment = -1) {
|
|
2
|
+
let sum = 0;
|
|
3
|
+
for (let offset = start; offset < start + length; offset += 4) {
|
|
4
|
+
let word = 0;
|
|
5
|
+
for (let i = 0; i < 4; i++) word = word * 256 + (offset + i < start + length ? bytes[offset + i] : 0);
|
|
6
|
+
if (offset !== adjustment) sum = (sum + word) >>> 0;
|
|
7
|
+
}
|
|
8
|
+
return sum;
|
|
9
|
+
}
|
|
10
|
+
// Recognize an sfnt container from its complete bytes, never its name. Unknown
|
|
11
|
+
// or damaged data keeps the conservative text count; a magic prefix is no proof.
|
|
12
|
+
export function verifiedSfnt(bytes) {
|
|
13
|
+
if (bytes.length < 28 || ![0x4f54544f, 0x00010000].includes(bytes.readUInt32BE(0))) return false;
|
|
14
|
+
const count = bytes.readUInt16BE(4), end = 12 + count * 16;
|
|
15
|
+
if (!count || count > 4096 || end > bytes.length) return false;
|
|
16
|
+
const selector = Math.floor(Math.log2(count)), search = 16 * 2 ** selector;
|
|
17
|
+
if (bytes.readUInt16BE(6) !== search || bytes.readUInt16BE(8) !== selector
|
|
18
|
+
|| bytes.readUInt16BE(10) !== count * 16 - search) return false;
|
|
19
|
+
const tables = []; let previous = ''; let head;
|
|
20
|
+
for (let index = 12; index < end; index += 16) {
|
|
21
|
+
if (bytes.subarray(index, index + 4).some(byte => byte < 0x20 || byte > 0x7e)) return false;
|
|
22
|
+
const tag = bytes.toString('ascii', index, index + 4);
|
|
23
|
+
if (!/^[\x20-\x7e]{4}$/.test(tag) || tag <= previous) return false;
|
|
24
|
+
previous = tag;
|
|
25
|
+
const offset = bytes.readUInt32BE(index + 8), length = bytes.readUInt32BE(index + 12);
|
|
26
|
+
if (offset % 4 || offset < end || offset + length > bytes.length) return false;
|
|
27
|
+
const adjustment = tag === 'head' ? offset + 8 : -1;
|
|
28
|
+
if (tag === 'head') {
|
|
29
|
+
if (length !== 54 || bytes.readUInt32BE(offset) !== 0x00010000
|
|
30
|
+
|| bytes.readUInt32BE(offset + 12) !== 0x5f0f3cf5) return false;
|
|
31
|
+
head = offset;
|
|
32
|
+
}
|
|
33
|
+
if (checksum(bytes, offset, length, adjustment) !== bytes.readUInt32BE(index + 4)) return false;
|
|
34
|
+
tables.push({ offset, length });
|
|
35
|
+
}
|
|
36
|
+
if (head === undefined || checksum(bytes, 0, bytes.length) !== 0xb1b0afba) return false;
|
|
37
|
+
let covered = end;
|
|
38
|
+
for (const table of tables.sort((a, b) => a.offset - b.offset)) {
|
|
39
|
+
if (table.offset < covered) return false;
|
|
40
|
+
for (; covered < table.offset; covered++) if (bytes[covered] !== 0) return false;
|
|
41
|
+
covered = table.offset + table.length;
|
|
42
|
+
}
|
|
43
|
+
for (; covered < bytes.length; covered++) if (bytes[covered] !== 0) return false;
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
@@ -264,17 +264,20 @@ async function configMetadata(root, relative, input) {
|
|
|
264
264
|
async function resolveLocalImport(root, relative, required = true) {
|
|
265
265
|
if (relative.startsWith('../') || relative === '..') scopeFail('ENTROROVE_PATH_UNSAFE', 'local import escapes the repository');
|
|
266
266
|
const replacements = /\.js$/.test(relative) ? [relative.replace(/\.js$/, '.ts'), relative.replace(/\.js$/, '.tsx')] : [];
|
|
267
|
-
|
|
267
|
+
const candidates = [relative, ...replacements, ...['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.vue', '.json', '.d.ts', '.d.mts', '.d.cts', '/index.js', '/index.ts', '/index.d.ts'].map(ext => relative + ext)];
|
|
268
|
+
for (const candidate of candidates) {
|
|
268
269
|
const item = await scopeProbeTarget(root, candidate, dependencyReference(candidate));
|
|
269
270
|
if (item.exists && item.stat.isFile()) return candidate;
|
|
270
271
|
}
|
|
271
|
-
if (required)
|
|
272
|
+
if (required) throw Object.assign(new Error('a local import has no resolvable source file'),
|
|
273
|
+
{ code: 'ENTROROVE_IMPORT_UNRESOLVED', missingCandidates: candidates });
|
|
272
274
|
return null;
|
|
273
275
|
}
|
|
274
276
|
export async function importGraph(root, originals, input = { repositoryRoot: root }, limits = {}) {
|
|
275
277
|
const pending = originals.filter(file => file.exists !== false).map(file => file.path); const visited = new Map(); const edges = []; let dynamic = false;
|
|
276
278
|
let sourceBytes = 0;
|
|
277
279
|
const dynamicSources = [], unresolvedImports = [];
|
|
280
|
+
const pendingCreates = new Set(originals.filter(file => file.exists === false && input.scopeCreatePaths?.includes(file.path)).map(file => file.path));
|
|
278
281
|
const workspace = await workspaceIndex(root, input);
|
|
279
282
|
const metadata = new Map(workspace.metadata.map(item => [item.path, item]));
|
|
280
283
|
while (pending.length) {
|
|
@@ -300,7 +303,17 @@ export async function importGraph(root, originals, input = { repositoryRoot: roo
|
|
|
300
303
|
unresolvedImports.push(...imports.unresolvedImports.map(site => ({ path: relative, ...site })));
|
|
301
304
|
if (imports.dynamic) dynamicSources.push({ path: relative, sha256: read.sha256 });
|
|
302
305
|
for (const specifier of imports.specifiers.sort()) {
|
|
303
|
-
|
|
306
|
+
let target;
|
|
307
|
+
try { target = await resolveImport(root, relative, specifier, workspace, input, aliases, configPath, declarations); }
|
|
308
|
+
catch (error) {
|
|
309
|
+
const missing = error.code === 'ENTROROVE_IMPORT_UNRESOLVED' && specifier.startsWith('.')
|
|
310
|
+
? error.missingCandidates?.filter(candidate => pendingCreates.has(candidate)) : null;
|
|
311
|
+
if (!missing?.length) throw error;
|
|
312
|
+
unresolvedImports.push({ path: relative, specifier, reason: 'approved-create-not-present', candidatePaths: missing });
|
|
313
|
+
dynamic = true;
|
|
314
|
+
if (!dynamicSources.some(source => source.path === relative)) dynamicSources.push({ path: relative, sha256: read.sha256 });
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
304
317
|
edges.push({ from: relative, to: target, external: target === null, specifier,
|
|
305
318
|
...(declarations.has(specifier) ? { declarationOnly: true, declarationPath: relative } : {}) });
|
|
306
319
|
if (target !== null && !visited.has(target)) pending.push(target);
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { inflateRawSync } from 'node:zlib';
|
|
2
|
+
|
|
3
|
+
const MAX_ENTRY_BYTES = 16 * 1024 * 1024, MAX_TOTAL_BYTES = 64 * 1024 * 1024;
|
|
4
|
+
const crcTable = Uint32Array.from({ length: 256 }, (_, value) => {
|
|
5
|
+
for (let bit = 0; bit < 8; bit++) value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1;
|
|
6
|
+
return value >>> 0;
|
|
7
|
+
});
|
|
8
|
+
function crc32(bytes) {
|
|
9
|
+
let value = 0xffffffff;
|
|
10
|
+
for (const byte of bytes) value = crcTable[(value ^ byte) & 255] ^ (value >>> 8);
|
|
11
|
+
return (value ^ 0xffffffff) >>> 0;
|
|
12
|
+
}
|
|
13
|
+
function plainExtra(bytes) {
|
|
14
|
+
let offset = 0;
|
|
15
|
+
while (offset + 4 <= bytes.length) {
|
|
16
|
+
const kind = bytes.readUInt16LE(offset), size = bytes.readUInt16LE(offset + 2);
|
|
17
|
+
if ([1, 0x9901].includes(kind) || offset + 4 + size > bytes.length) return false;
|
|
18
|
+
offset += 4 + size;
|
|
19
|
+
}
|
|
20
|
+
return offset === bytes.length;
|
|
21
|
+
}
|
|
22
|
+
function directory(bytes) {
|
|
23
|
+
const floor = Math.max(0, bytes.length - 65557); let end = bytes.length - 22;
|
|
24
|
+
for (; end >= floor; end--) {
|
|
25
|
+
if (bytes.readUInt32LE(end) === 0x06054b50 && end + 22 + bytes.readUInt16LE(end + 20) === bytes.length) break;
|
|
26
|
+
}
|
|
27
|
+
if (end < floor || bytes.readUInt16LE(end + 4) || bytes.readUInt16LE(end + 6)) return null;
|
|
28
|
+
const count = bytes.readUInt16LE(end + 10), offset = bytes.readUInt32LE(end + 16), size = bytes.readUInt32LE(end + 12);
|
|
29
|
+
if (count > 10_000 || count !== bytes.readUInt16LE(end + 8) || offset + size !== end) return null;
|
|
30
|
+
const entries = []; let cursor = offset, total = 0;
|
|
31
|
+
for (let index = 0; index < count; index++) {
|
|
32
|
+
if (cursor + 46 > end || bytes.readUInt32LE(cursor) !== 0x02014b50) return null;
|
|
33
|
+
const nameLength = bytes.readUInt16LE(cursor + 28), extraLength = bytes.readUInt16LE(cursor + 30);
|
|
34
|
+
const next = cursor + 46 + nameLength + extraLength + bytes.readUInt16LE(cursor + 32);
|
|
35
|
+
const flags = bytes.readUInt16LE(cursor + 8), method = bytes.readUInt16LE(cursor + 10);
|
|
36
|
+
const compressed = bytes.readUInt32LE(cursor + 20), expanded = bytes.readUInt32LE(cursor + 24);
|
|
37
|
+
if (!nameLength || next > end || flags & ~0x080e || ![0, 8].includes(method) || bytes.readUInt16LE(cursor + 34)
|
|
38
|
+
|| compressed === 0xffffffff || expanded > MAX_ENTRY_BYTES || (total += expanded) > MAX_TOTAL_BYTES
|
|
39
|
+
|| !plainExtra(bytes.subarray(cursor + 46 + nameLength, cursor + 46 + nameLength + extraLength))) return null;
|
|
40
|
+
entries.push({ central: cursor, local: bytes.readUInt32LE(cursor + 42), flags, method, compressed, expanded,
|
|
41
|
+
crc: bytes.readUInt32LE(cursor + 16), name: bytes.subarray(cursor + 46, cursor + 46 + nameLength) });
|
|
42
|
+
cursor = next;
|
|
43
|
+
}
|
|
44
|
+
return cursor === end ? { entries, offset } : null;
|
|
45
|
+
}
|
|
46
|
+
function descriptorEnd(bytes, offset, entry, limit) {
|
|
47
|
+
for (const length of [16, 12]) {
|
|
48
|
+
const start = offset + length - 12;
|
|
49
|
+
if (offset + length > limit || length === 16 && bytes.readUInt32LE(offset) !== 0x08074b50) continue;
|
|
50
|
+
if (bytes.readUInt32LE(start) === entry.crc && bytes.readUInt32LE(start + 4) === entry.compressed
|
|
51
|
+
&& bytes.readUInt32LE(start + 8) === entry.expanded) return offset + length;
|
|
52
|
+
}
|
|
53
|
+
return -1;
|
|
54
|
+
}
|
|
55
|
+
function entryEnd(bytes, entry, limit) {
|
|
56
|
+
const start = entry.local;
|
|
57
|
+
if (start + 30 > limit || bytes.readUInt32LE(start) !== 0x04034b50) return -1;
|
|
58
|
+
const nameLength = bytes.readUInt16LE(start + 26), extraLength = bytes.readUInt16LE(start + 28);
|
|
59
|
+
const data = start + 30 + nameLength + extraLength, end = data + entry.compressed;
|
|
60
|
+
if (end > limit || !bytes.subarray(start + 30, start + 30 + nameLength).equals(entry.name)
|
|
61
|
+
|| !plainExtra(bytes.subarray(start + 30 + nameLength, data))) return -1;
|
|
62
|
+
for (const [local, central] of [[4, 6], [6, 8], [8, 10], [10, 12], [12, 14]]) {
|
|
63
|
+
if (bytes.readUInt16LE(start + local) !== bytes.readUInt16LE(entry.central + central)) return -1;
|
|
64
|
+
}
|
|
65
|
+
for (const [field, expected] of [[14, entry.crc], [18, entry.compressed], [22, entry.expanded]]) {
|
|
66
|
+
const actual = bytes.readUInt32LE(start + field);
|
|
67
|
+
if (actual !== expected && (!(entry.flags & 8) || actual !== 0)) return -1;
|
|
68
|
+
}
|
|
69
|
+
const compressed = bytes.subarray(data, end); let expanded;
|
|
70
|
+
if (entry.method === 0) expanded = compressed;
|
|
71
|
+
else {
|
|
72
|
+
try {
|
|
73
|
+
const result = inflateRawSync(compressed, { maxOutputLength: Math.max(1, entry.expanded), info: true });
|
|
74
|
+
if (result.engine.bytesWritten !== compressed.length) return -1;
|
|
75
|
+
expanded = result.buffer;
|
|
76
|
+
} catch { return -1; }
|
|
77
|
+
}
|
|
78
|
+
if (expanded.length !== entry.expanded || crc32(expanded) !== entry.crc) return -1;
|
|
79
|
+
return entry.flags & 8 ? descriptorEnd(bytes, end, entry, limit) : end;
|
|
80
|
+
}
|
|
81
|
+
// PKWARE APPNOTE 6.3.10, 4.3: verify complete ordinary ZIP records and CRCs
|
|
82
|
+
// in bounded memory. Never extract, execute entries or grant their paths access.
|
|
83
|
+
export function verifiedZip(bytes) {
|
|
84
|
+
if (bytes.length < 22 || ![0x04034b50, 0x06054b50].includes(bytes.readUInt32LE(0))) return false;
|
|
85
|
+
const found = directory(bytes); if (!found) return false;
|
|
86
|
+
let covered = 0;
|
|
87
|
+
for (const entry of found.entries.sort((a, b) => a.local - b.local)) {
|
|
88
|
+
if (entry.local !== covered) return false;
|
|
89
|
+
covered = entryEnd(bytes, entry, found.offset);
|
|
90
|
+
if (covered < 0) return false;
|
|
91
|
+
}
|
|
92
|
+
return covered === found.offset;
|
|
93
|
+
}
|
package/manifest.json
CHANGED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { canonical } from './local-integrity.mjs';
|
|
2
|
+
import { observationAuthoritySignature } from './workflow-observation-wire.mjs';
|
|
3
|
+
|
|
4
|
+
const keys = ['schemaVersion', 'repairId', 'flowId', 'ownerId', 'taskId', 'stepId', 'leaseId', 'generation', 'leaseDigest',
|
|
5
|
+
'planId', 'planDigest', 'executionPlanSha256', 'failedObservationId', 'failureResultDigest', 'previousAcceptanceDigest',
|
|
6
|
+
'kind', 'paths', 'dependencyRepairDigest', 'failureStepId', 'baselineSettlementId', 'baselineSettlementDigest',
|
|
7
|
+
'grantId', 'sequence', 'previousGrantDigest', 'scopeDigest', 'requestId', 'issuedAt', 'expiresAt', 'authorityReceipt'];
|
|
8
|
+
const identity = ['flowId', 'ownerId', 'taskId', 'stepId', 'leaseId', 'generation', 'planId', 'planDigest', 'executionPlanSha256'];
|
|
9
|
+
const shas = ['leaseDigest', 'planDigest', 'executionPlanSha256', 'failureResultDigest', 'previousAcceptanceDigest', 'scopeDigest', 'requestId'];
|
|
10
|
+
export function memberProducerRepairStepMatches(status, authority, { settledPass = false } = {}) {
|
|
11
|
+
try {
|
|
12
|
+
const values = status?.producerRepairAuthorities;
|
|
13
|
+
if (!Array.isArray(values) || values.length > 128) return false;
|
|
14
|
+
const matching = values.filter(p => p.stepId === authority.stepId);
|
|
15
|
+
if (matching.length !== 1) return false;
|
|
16
|
+
const p = matching[0], expected = [...keys, ...(Object.hasOwn(p, 'scopeRevisionDigest') ? ['scopeRevisionDigest'] : [])];
|
|
17
|
+
if (canonical(Object.keys(p).sort()) !== canonical(expected.sort())) return false;
|
|
18
|
+
observationAuthoritySignature(p);
|
|
19
|
+
const start = Date.parse(p.issuedAt), end = Date.parse(p.expiresAt), at = Date.parse(authority.issuedAt);
|
|
20
|
+
if (p.schemaVersion !== 'entrorove.member-producer-repair/1.0' || identity.some(k => p[k] !== authority[k])
|
|
21
|
+
|| status.flow?.stepId !== p.failureStepId || status.flow.ownerId !== p.ownerId
|
|
22
|
+
|| status.flow.flowId !== p.flowId || status.flow.status !== 'running' || status.workflow?.phase !== 'execute'
|
|
23
|
+
|| status.workflow.terminalAt || ['pending', 'declined'].includes(status.workflow.authorization)
|
|
24
|
+
|| !['producer-correction', 'dependency-revalidation'].includes(p.kind)
|
|
25
|
+
|| shas.some(k => !/^[a-f0-9]{64}$/.test(p[k])) || authority.leaseDigest && authority.leaseDigest !== p.leaseDigest
|
|
26
|
+
|| !Array.isArray(p.paths) || p.paths.length > 512 || new Set(p.paths).size !== p.paths.length
|
|
27
|
+
|| canonical(p.paths) !== canonical([...p.paths].sort()) || (p.kind === 'producer-correction') !== (p.paths.length > 0)
|
|
28
|
+
|| p.paths.some(path => typeof path !== 'string' || !path || path.startsWith('/') || /[\\:\u0000-\u001f\u007f]/.test(path)
|
|
29
|
+
|| path.split('/').some(part => !part || part === '.' || part === '..'))
|
|
30
|
+
|| !Number.isSafeInteger(p.sequence) || p.sequence < 1 || !Number.isSafeInteger(p.generation) || p.generation < 1
|
|
31
|
+
|| ['grantId', 'repairId', 'failedObservationId', 'failureStepId'].some(k => typeof p[k] !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(p[k]))
|
|
32
|
+
|| (p.sequence === 1 ? p.previousGrantDigest !== null : !/^[a-f0-9]{64}$/.test(p.previousGrantDigest))
|
|
33
|
+
|| !Number.isFinite(start) || !Number.isFinite(end) || start > Date.now() || end <= start || end - start > 600000
|
|
34
|
+
|| !Number.isFinite(at) || at < start || at >= end || Date.parse(authority.expiresAt) > end
|
|
35
|
+
|| !settledPass && end <= Date.now()
|
|
36
|
+
|| (p.dependencyRepairDigest !== null && !/^[a-f0-9]{64}$/.test(p.dependencyRepairDigest))
|
|
37
|
+
|| (Object.hasOwn(p, 'scopeRevisionDigest') && p.scopeRevisionDigest !== authority.scopeRevisionDigest
|
|
38
|
+
&& Object.hasOwn(authority, 'scopeRevisionDigest'))) return false;
|
|
39
|
+
if (settledPass && (authority.verificationMode !== 'member-local' || authority.runnerKeyId !== null
|
|
40
|
+
|| !authority.writeDelegation || p.kind !== 'producer-correction' || !Array.isArray(authority.paths)
|
|
41
|
+
|| !authority.paths.length || authority.paths.some(path => !p.paths.includes(path)))) return false;
|
|
42
|
+
return true;
|
|
43
|
+
} catch { return false; }
|
|
44
|
+
}
|
|
45
|
+
export function memberProducerRepairDependency(status, pass) {
|
|
46
|
+
if (!memberProducerRepairStepMatches(status, pass, { settledPass: true })) return null;
|
|
47
|
+
return status.producerRepairAuthorities.find(p => p.stepId === pass.stepId).dependencyRepairDigest;
|
|
48
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export type ModelImageInput = { url: string; detail?: 'auto'|'low'|'high'|'original' };
|
|
2
|
+
export type ModelImageMetadata = { sha256: string; mimeType: string; bytes: number; detail: 'auto'|'low'|'high'|'original' };
|
|
3
|
+
export type ModelImage = ModelImageInput & ModelImageMetadata;
|
|
4
|
+
export const MODEL_IMAGE_LIMITS: Readonly<{count: number; perImageBytes: number; totalBytes: number; requestBytes: number; maxSide: number}>;
|
|
5
|
+
export function inspectModelImage(bytes: Buffer): Omit<ModelImageMetadata,'detail'>;
|
|
6
|
+
export function normalizeModelImages(value?: unknown): ModelImage[];
|
|
7
|
+
export function modelImageManifest(images: readonly ModelImage[]): ModelImageMetadata[];
|
|
8
|
+
export function modelUserContent(goal: string, images: readonly ModelImage[]): string | Array<
|
|
9
|
+
{ type: 'text'; text: string } | { type: 'image_url'; image_url: { url: string; detail: ModelImageMetadata['detail'] } }>;
|
package/model-images.mjs
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
export const MODEL_IMAGE_LIMITS = Object.freeze({ count: 4, perImageBytes: 2 * 1024 * 1024,
|
|
4
|
+
totalBytes: 8 * 1024 * 1024, requestBytes: 12 * 1024 * 1024, maxSide: 8192 });
|
|
5
|
+
const DETAILS = new Set(['auto', 'low', 'high', 'original']);
|
|
6
|
+
const invalid = () => new Error('Images require bounded JPEG, PNG, GIF or WebP content with valid dimensions');
|
|
7
|
+
|
|
8
|
+
function jpegSize(bytes) {
|
|
9
|
+
let offset = 2;
|
|
10
|
+
while (offset + 4 <= bytes.length) {
|
|
11
|
+
if (bytes[offset++] !== 255) throw invalid();
|
|
12
|
+
while (bytes[offset] === 255) offset++;
|
|
13
|
+
if (offset + 3 > bytes.length) throw invalid();
|
|
14
|
+
const marker = bytes[offset++];
|
|
15
|
+
if (marker === 0xda || marker === 0xd9) break;
|
|
16
|
+
const length = bytes.readUInt16BE(offset);
|
|
17
|
+
if (length < 2 || offset + length > bytes.length) throw invalid();
|
|
18
|
+
if ([0xc0,0xc1,0xc2,0xc3,0xc5,0xc6,0xc7,0xc9,0xca,0xcb,0xcd,0xce,0xcf].includes(marker)) {
|
|
19
|
+
if (length < 8) throw invalid();
|
|
20
|
+
return [bytes.readUInt16BE(offset + 5), bytes.readUInt16BE(offset + 3)];
|
|
21
|
+
}
|
|
22
|
+
offset += length;
|
|
23
|
+
}
|
|
24
|
+
throw invalid();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function webpSize(bytes) {
|
|
28
|
+
if (bytes.length < 30 || bytes.readUInt32LE(4) + 8 !== bytes.length) throw invalid();
|
|
29
|
+
const chunk = bytes.toString('ascii', 12, 16), length = bytes.readUInt32LE(16);
|
|
30
|
+
if (20 + length + (length % 2) > bytes.length) throw invalid();
|
|
31
|
+
if (chunk === 'VP8X' && length === 10) return [bytes.readUIntLE(24, 3) + 1, bytes.readUIntLE(27, 3) + 1];
|
|
32
|
+
if (chunk === 'VP8 ' && length >= 10 && bytes.subarray(23,26).equals(Buffer.from([0x9d,1,0x2a]))) {
|
|
33
|
+
return [bytes.readUInt16LE(26) & 0x3fff, bytes.readUInt16LE(28) & 0x3fff];
|
|
34
|
+
}
|
|
35
|
+
if (chunk === 'VP8L' && length >= 5 && bytes[20] === 0x2f) {
|
|
36
|
+
const packed = bytes.readUInt32LE(21);
|
|
37
|
+
return [(packed & 0x3fff) + 1, ((packed >>> 14) & 0x3fff) + 1];
|
|
38
|
+
}
|
|
39
|
+
throw invalid();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Inspect content headers and dimensions; the provider still decodes the actual image. */
|
|
43
|
+
export function inspectModelImage(bytes) {
|
|
44
|
+
if (!Buffer.isBuffer(bytes) || bytes.length < 26 || bytes.length > MODEL_IMAGE_LIMITS.perImageBytes) throw invalid();
|
|
45
|
+
let mimeType, size;
|
|
46
|
+
if (bytes.subarray(0,8).equals(Buffer.from([137,80,78,71,13,10,26,10]))
|
|
47
|
+
&& bytes.readUInt32BE(8) === 13 && bytes.toString('ascii',12,16) === 'IHDR') {
|
|
48
|
+
mimeType = 'image/png'; size = [bytes.readUInt32BE(16), bytes.readUInt32BE(20)];
|
|
49
|
+
} else if (bytes[0] === 255 && bytes[1] === 216) {
|
|
50
|
+
mimeType = 'image/jpeg'; size = jpegSize(bytes);
|
|
51
|
+
} else if (['GIF87a','GIF89a'].includes(bytes.toString('ascii',0,6))) {
|
|
52
|
+
mimeType = 'image/gif'; size = [bytes.readUInt16LE(6), bytes.readUInt16LE(8)];
|
|
53
|
+
} else if (bytes.toString('ascii',0,4) === 'RIFF' && bytes.toString('ascii',8,12) === 'WEBP') {
|
|
54
|
+
mimeType = 'image/webp'; size = webpSize(bytes);
|
|
55
|
+
} else throw invalid();
|
|
56
|
+
if (size.some(value => !Number.isInteger(value) || value < 1 || value > MODEL_IMAGE_LIMITS.maxSide)) throw invalid();
|
|
57
|
+
return { mimeType, bytes: bytes.length, sha256: createHash('sha256').update(bytes).digest('hex') };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function normalizeModelImages(value = []) {
|
|
61
|
+
if (!Array.isArray(value) || value.length > MODEL_IMAGE_LIMITS.count) throw new Error('At most four input images are supported');
|
|
62
|
+
let total = 0;
|
|
63
|
+
return value.map(image => {
|
|
64
|
+
if (!image || typeof image !== 'object' || Array.isArray(image)
|
|
65
|
+
|| Object.keys(image).some(key => !['url','detail'].includes(key))
|
|
66
|
+
|| typeof image.url !== 'string' || !DETAILS.has(image.detail ?? 'auto')) throw invalid();
|
|
67
|
+
if (image.url.length > Math.ceil(MODEL_IMAGE_LIMITS.perImageBytes / 3) * 4 + 64) throw invalid();
|
|
68
|
+
const match = /^data:(image\/(?:jpeg|png|gif|webp));base64,([A-Za-z0-9+/]+={0,2})$/.exec(image.url);
|
|
69
|
+
if (!match) throw new Error('Input images must use inline base64 data URLs; remote URLs are not fetched');
|
|
70
|
+
const bytes = Buffer.from(match[2], 'base64');
|
|
71
|
+
if (bytes.toString('base64') !== match[2]) throw invalid();
|
|
72
|
+
const metadata = inspectModelImage(bytes);
|
|
73
|
+
if (metadata.mimeType !== match[1]) throw new Error('Image media type differs from its actual content');
|
|
74
|
+
total += bytes.length;
|
|
75
|
+
if (total > MODEL_IMAGE_LIMITS.totalBytes) throw new Error('Input images exceed the total byte limit');
|
|
76
|
+
return Object.freeze({ url: image.url, detail: image.detail ?? 'auto', ...metadata });
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function modelImageManifest(images) {
|
|
81
|
+
return images.map(({ sha256, mimeType, bytes, detail }) => ({ sha256, mimeType, bytes, detail }));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function modelUserContent(goal, images) {
|
|
85
|
+
return images.length ? [{ type: 'text', text: goal }, ...images.map(image => ({ type: 'image_url',
|
|
86
|
+
image_url: { url: image.url, detail: image.detail } }))] : goal;
|
|
87
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { canonical, sha256 } from './local-integrity.mjs';
|
|
2
|
+
import { buildInvocation } from './remote-client.mjs';
|
|
3
|
+
import { mutationFail14 } from './local-mutation-files.mjs';
|
|
4
|
+
|
|
5
|
+
const fail = message => mutationFail14('ENTROROVE_MUTATION_CHALLENGE_REFRESH', message);
|
|
6
|
+
export async function refreshExpiredMutationChallenge(context, callbacks) {
|
|
7
|
+
const { state, identity, mutator, location, dependencies, fields } = context;
|
|
8
|
+
const { remote, save, challengeBinding } = callbacks, old = state.challenged.challenge;
|
|
9
|
+
if (state.issued || state.gatePassPath || Date.parse(old.expiresAt) > Date.now()) return state.challenged;
|
|
10
|
+
challengeBinding(old, identity, mutator, { live: false });
|
|
11
|
+
if (old.verificationMode !== 'member-local' || !old.writeDelegation || old.runnerKeyId !== null) {
|
|
12
|
+
fail('Expired challenge recovery requires the original member-local editing authority');
|
|
13
|
+
}
|
|
14
|
+
if (!state.refreshOriginal && state.attestation) {
|
|
15
|
+
const input = { challengeId: old.challengeId, attestation: state.attestation };
|
|
16
|
+
if (!state.pending) {
|
|
17
|
+
state.pending = { stage: 'issued', operation: 'mutation-pass-issue', input,
|
|
18
|
+
requestId: buildInvocation('mutation-pass-issue', input).requestId };
|
|
19
|
+
await save(location, state);
|
|
20
|
+
}
|
|
21
|
+
if (state.pending.stage === 'issued') {
|
|
22
|
+
try { await remote('issued', 'mutation-pass-issue', input, state, location, dependencies); }
|
|
23
|
+
catch (error) {
|
|
24
|
+
if (state.pending || error.output?.status !== 'blocked'
|
|
25
|
+
|| !['ENTROROVE_MUTATION_LEASE', 'ENTROROVE_MUTATION_EXPIRED'].includes(error.output.code)
|
|
26
|
+
|| state.feedback?.acknowledgement?.stored !== true) throw error;
|
|
27
|
+
}
|
|
28
|
+
if (state.issued) return state.challenged;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
if (state.pending && state.pending.stage !== 'refreshed') fail('Recover only the original pending mutation request before challenge refresh');
|
|
32
|
+
if (!Array.isArray(state.attempts ?? []) || (state.attempts?.length ?? 0) >= 512) fail('Original mutation challenge history reached its bound');
|
|
33
|
+
if (!state.refreshOriginal) {
|
|
34
|
+
state.refreshOriginal = Object.fromEntries(['challenged', 'planned', 'planStarted', 'attestation', 'feedback', 'failures']
|
|
35
|
+
.filter(key => Object.hasOwn(state, key)).map(key => [key, structuredClone(state[key])]));
|
|
36
|
+
await save(location, state);
|
|
37
|
+
}
|
|
38
|
+
if (canonical(state.refreshOriginal.challenged.challenge) !== canonical(old)) fail('Original archived challenge binding changed');
|
|
39
|
+
const ref = { challengeId: old.challengeId, challengeDigest: sha256(canonical(old)) };
|
|
40
|
+
const input = { ...Object.fromEntries(fields.map(field => [field, identity[field]])),
|
|
41
|
+
...(identity.previousPassId ? { previousPassId: identity.previousPassId } : {}),
|
|
42
|
+
mutationPublicKey: mutator.mutationPublicKey, refreshExpiredChallenge: ref };
|
|
43
|
+
const refreshed = state.refreshed ?? await remote('refreshed', 'mutation-snapshot-challenge', input, state, location, dependencies);
|
|
44
|
+
challengeBinding(refreshed.challenge, identity, mutator, { live: false });
|
|
45
|
+
if (canonical(refreshed.challenge.previousChallenge) !== canonical(ref)) fail('Fresh challenge lost its exact expired predecessor');
|
|
46
|
+
state.attempts = [...(state.attempts ?? []), { ...state.refreshOriginal, archivedAt: new Date().toISOString() }];
|
|
47
|
+
for (const key of ['challenged', 'planned', 'planStarted', 'attestation', 'failures', 'refreshOriginal', 'refreshed']) delete state[key];
|
|
48
|
+
state.challenged = refreshed;
|
|
49
|
+
await save(location, state);
|
|
50
|
+
return refreshed;
|
|
51
|
+
}
|