@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
|
@@ -27,7 +27,7 @@ function sourceLimit(relative, bytes, sources, used, required) {
|
|
|
27
27
|
`Semantic inspection of "${relative}" (${bytes} bytes) exceeds ${reason}; limits are ${disciplineAnalysisLimits.fileBytes} bytes per file, ${disciplineAnalysisLimits.sourceBytes} total bytes and ${disciplineAnalysisLimits.files} files. Preserve the original flow and split its approved inspection before retrying`);
|
|
28
28
|
return reason;
|
|
29
29
|
}
|
|
30
|
-
async function
|
|
30
|
+
export async function repositoryIntegrityFile(root, relative) {
|
|
31
31
|
// This path returns only an integrity digest. It never exposes source bytes or
|
|
32
32
|
// contributes semantic facts outside the signed source-read scope.
|
|
33
33
|
const before = await mutationTarget14(root, relative);
|
|
@@ -39,7 +39,9 @@ async function integrityOnlyFile(root, relative) {
|
|
|
39
39
|
for await (const chunk of handle.createReadStream({ autoClose: false })) digest.update(chunk);
|
|
40
40
|
const last = await handle.stat(), current = await mutationTarget14(root, relative);
|
|
41
41
|
if (!sameFile(first, last) || !sameFile(last, current.stat)) mutationFail14('ENTROROVE_FILE_CHANGED', `${relative} changed during integrity observation`);
|
|
42
|
-
return { sha256: digest.digest('hex'), mode: last.mode & 0o777
|
|
42
|
+
return { sha256: digest.digest('hex'), mode: last.mode & 0o777,
|
|
43
|
+
identity: { dev: last.dev, ino: last.ino, nlink: last.nlink, birthtimeMs: last.birthtimeMs,
|
|
44
|
+
ctimeMs: last.ctimeMs, mtimeMs: last.mtimeMs, size: last.size } };
|
|
43
45
|
} finally { await handle.close(); }
|
|
44
46
|
}
|
|
45
47
|
async function inventory(root, prefix = '') {
|
|
@@ -77,7 +79,7 @@ export async function disciplineRepositoryObservation(input, root, { analyze = f
|
|
|
77
79
|
const allowed = !restricted || withinBudgetScope(relative, restricted);
|
|
78
80
|
const bytes = overrides.has(relative) ? Buffer.byteLength(overrides.get(relative)) : stat.size;
|
|
79
81
|
const limited = semantic && allowed ? sourceLimit(relative, bytes, sources, sourceBytes, required) : null;
|
|
80
|
-
const read = semantic && allowed && !limited ? await mutationRead14(input, root, relative) : await
|
|
82
|
+
const read = semantic && allowed && !limited ? await mutationRead14(input, root, relative) : await repositoryIntegrityFile(root, relative);
|
|
81
83
|
files.push({ path: relative, sha256: read.sha256, mode: read.mode, kind: 'file' });
|
|
82
84
|
if (semantic && allowed && !limited) { sources.push({ path: relative, content: overrides.get(relative) ?? read.content.toString('utf8') }); sourceBytes += bytes; }
|
|
83
85
|
else if (limited) unresolved.push({ path: relative, reason: limited, bytes });
|
|
@@ -124,7 +126,7 @@ export function disciplineRepositoryDifferences(before, after, authorized = new
|
|
|
124
126
|
}
|
|
125
127
|
return changed;
|
|
126
128
|
}
|
|
127
|
-
export async function
|
|
129
|
+
export async function observeDisciplineRepositoryState(input, root, review, ledger) {
|
|
128
130
|
const expected = new Map();
|
|
129
131
|
for (const [relative, write] of Object.entries(ledger.writes ?? {})) {
|
|
130
132
|
if (write.status !== 'written') mutationFail14('ENTROROVE_MUTATION_PASS_CONSUMED', 'Repository comparison found an unresolved physical action');
|
|
@@ -137,6 +139,10 @@ export async function assertDisciplineRepository(input, root, review, ledger) {
|
|
|
137
139
|
}
|
|
138
140
|
const current = await disciplineRepositoryObservation(input, root);
|
|
139
141
|
const changed = disciplineRepositoryDifferences(review.comparison, current.comparison, expected);
|
|
142
|
+
return { comparison: current.comparison, changed };
|
|
143
|
+
}
|
|
144
|
+
export async function assertDisciplineRepository(input, root, review, ledger) {
|
|
145
|
+
const { comparison, changed } = await observeDisciplineRepositoryState(input, root, review, ledger);
|
|
140
146
|
if (changed.length) mutationFail14('ENTROROVE_DISCIPLINE_REPOSITORY_DRIFT', `Whole-repository comparison differs outside completed declared actions: ${changed.join(', ')}`);
|
|
141
|
-
return
|
|
147
|
+
return comparison;
|
|
142
148
|
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Bounded structural inspection only. No property values leave this reader.
|
|
2
|
+
const invalid = () => { throw new Error('invalid-finder-property-list'); };
|
|
3
|
+
function integer(bytes, offset, size, end = bytes.length) {
|
|
4
|
+
if (![1, 2, 4, 8].includes(size) || offset < 0 || offset + size > end) invalid();
|
|
5
|
+
let value = 0n;
|
|
6
|
+
for (let index = offset; index < offset + size; index++) value = value * 256n + BigInt(bytes[index]);
|
|
7
|
+
if (value > BigInt(Number.MAX_SAFE_INTEGER)) invalid();
|
|
8
|
+
return Number(value);
|
|
9
|
+
}
|
|
10
|
+
function length(bytes, start, end, info) {
|
|
11
|
+
if (info !== 15) return { count: info, offset: start };
|
|
12
|
+
const marker = bytes[start];
|
|
13
|
+
if (marker === undefined || marker >> 4 !== 1 || (marker & 15) > 3) invalid();
|
|
14
|
+
const size = 2 ** (marker & 15);
|
|
15
|
+
return { count: integer(bytes, start + 1, size, end), offset: start + 1 + size };
|
|
16
|
+
}
|
|
17
|
+
function string(bytes, start, count, width, end) {
|
|
18
|
+
const size = count * width;
|
|
19
|
+
if (count > 65536 || start + size > end) invalid();
|
|
20
|
+
const content = bytes.subarray(start, start + size);
|
|
21
|
+
if (width === 1 && content.some(value => value > 127)) invalid();
|
|
22
|
+
return new TextDecoder(width === 2 ? 'utf-16be' : 'ascii', { fatal: true }).decode(content);
|
|
23
|
+
}
|
|
24
|
+
function object(state, id, depth) {
|
|
25
|
+
if (!Number.isSafeInteger(id) || id < 0 || id >= state.offsets.length || depth > 64 || state.visiting.has(id)) invalid();
|
|
26
|
+
if (state.read.has(id)) return state.read.get(id);
|
|
27
|
+
state.visiting.add(id);
|
|
28
|
+
const start = state.offsets[id], end = state.ends.get(start), marker = state.bytes[start];
|
|
29
|
+
if (marker === undefined) invalid();
|
|
30
|
+
const type = marker >> 4, info = marker & 15;
|
|
31
|
+
let cursor = start + 1, text = null;
|
|
32
|
+
if (type === 0) { if (![0, 8, 9].includes(info)) invalid(); }
|
|
33
|
+
else if (type === 1) {
|
|
34
|
+
if (info > 4) invalid();
|
|
35
|
+
cursor += 2 ** info;
|
|
36
|
+
} else if (type === 2 || type === 3) {
|
|
37
|
+
const size = type === 3 ? 8 : 2 ** info;
|
|
38
|
+
if (type === 3 && info !== 3 || ![4, 8].includes(size) || cursor + size > end) invalid();
|
|
39
|
+
const value = size === 4 ? state.bytes.readFloatBE(cursor) : state.bytes.readDoubleBE(cursor);
|
|
40
|
+
if (!Number.isFinite(value)) invalid();
|
|
41
|
+
cursor += size;
|
|
42
|
+
} else if ([4, 5, 6, 10, 13].includes(type)) {
|
|
43
|
+
const value = length(state.bytes, cursor, end, info); cursor = value.offset;
|
|
44
|
+
if (value.count > 1_048_576) invalid();
|
|
45
|
+
if (type === 4) cursor += value.count;
|
|
46
|
+
else if (type === 5 || type === 6) {
|
|
47
|
+
text = string(state.bytes, cursor, value.count, type === 6 ? 2 : 1, end);
|
|
48
|
+
cursor += value.count * (type === 6 ? 2 : 1);
|
|
49
|
+
} else {
|
|
50
|
+
if (value.count > 10000) invalid();
|
|
51
|
+
const total = value.count * (type === 13 ? 2 : 1), keys = new Set();
|
|
52
|
+
for (let index = 0; index < total; index++) {
|
|
53
|
+
const reference = integer(state.bytes, cursor, state.referenceSize, end); cursor += state.referenceSize;
|
|
54
|
+
const child = object(state, reference, depth + 1);
|
|
55
|
+
if (type === 13 && index < value.count) {
|
|
56
|
+
if (child.text === null || keys.has(child.text)) invalid();
|
|
57
|
+
keys.add(child.text);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
} else if (type === 8) cursor += info + 1;
|
|
62
|
+
else invalid();
|
|
63
|
+
if (cursor > end) invalid();
|
|
64
|
+
state.visiting.delete(id);
|
|
65
|
+
const result = { text }; state.read.set(id, result); return result;
|
|
66
|
+
}
|
|
67
|
+
export function validateFinderPropertyList(bytes) {
|
|
68
|
+
if (!Buffer.isBuffer(bytes) || bytes.length < 40 || bytes.length > 1_048_576
|
|
69
|
+
|| bytes.subarray(0, 8).toString('latin1') !== 'bplist00') invalid();
|
|
70
|
+
const trailer = bytes.length - 32;
|
|
71
|
+
if (bytes.subarray(trailer, trailer + 6).some(value => value !== 0)) invalid();
|
|
72
|
+
const offsetSize = bytes[trailer + 6], referenceSize = bytes[trailer + 7];
|
|
73
|
+
if (![1, 2, 4, 8].includes(offsetSize) || ![1, 2, 4, 8].includes(referenceSize)) invalid();
|
|
74
|
+
const count = integer(bytes, trailer + 8, 8), top = integer(bytes, trailer + 16, 8), table = integer(bytes, trailer + 24, 8);
|
|
75
|
+
if (!count || count > 10000 || top >= count || table < 8 || table + count * offsetSize !== trailer) invalid();
|
|
76
|
+
const offsets = Array.from({ length: count }, (_, index) => integer(bytes, table + index * offsetSize, offsetSize));
|
|
77
|
+
if (new Set(offsets).size !== count || offsets.some(offset => offset < 8 || offset >= table)) invalid();
|
|
78
|
+
const sorted = [...offsets].sort((a, b) => a - b), ends = new Map(sorted.map((offset, index) => [offset, sorted[index + 1] ?? table]));
|
|
79
|
+
const state = { bytes, offsets, ends, referenceSize, read: new Map(), visiting: new Set() };
|
|
80
|
+
if (bytes[offsets[top]] >> 4 !== 13) invalid();
|
|
81
|
+
object(state, top, 0);
|
|
82
|
+
// Finder can retain unreferenced duplicate scalar objects. Inspect those
|
|
83
|
+
// too; an unused object is not an exemption from structural validation.
|
|
84
|
+
for (let id = 0; id < count; id++) object(state, id, 0);
|
|
85
|
+
if (state.read.size !== count) invalid();
|
|
86
|
+
return { objects: count };
|
|
87
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { validateFinderPropertyList } from './local-finder-plist.mjs';
|
|
2
|
+
|
|
3
|
+
const invalid = () => { throw new Error('invalid-finder-store'); };
|
|
4
|
+
function reader(bytes, start = 0, end = bytes.length) {
|
|
5
|
+
let cursor = start;
|
|
6
|
+
return {
|
|
7
|
+
take(size) {
|
|
8
|
+
if (!Number.isSafeInteger(size) || size < 0 || cursor + size > end) invalid();
|
|
9
|
+
const result = bytes.subarray(cursor, cursor + size); cursor += size; return result;
|
|
10
|
+
},
|
|
11
|
+
number() { return this.take(4).readUInt32BE(); },
|
|
12
|
+
byte() { return this.take(1)[0]; },
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
function allocation(bytes) {
|
|
16
|
+
if (bytes.length < 2048 || bytes.length > 1_048_576 || bytes.subarray(0, 8).toString('hex') !== '0000000142756431') invalid();
|
|
17
|
+
const header = reader(bytes, 8, 36), offset = header.number(), size = header.number();
|
|
18
|
+
if (header.number() !== offset || offset < 32 || size < 1024 || offset + size + 4 > bytes.length) invalid();
|
|
19
|
+
const book = reader(bytes, offset + 4, offset + size + 4), count = book.number();
|
|
20
|
+
if (count < 3 || count > 8192 || book.number() !== 0) invalid();
|
|
21
|
+
const blocks = [], intervals = [[0, 32]];
|
|
22
|
+
for (let index = 0; index < count; index++) {
|
|
23
|
+
const address = book.number();
|
|
24
|
+
if (!address) { blocks.push(null); continue; }
|
|
25
|
+
const power = address & 31, start = (address & 0xffffffe0) >>> 0, length = 2 ** power;
|
|
26
|
+
if (power < 5 || power > 20 || start < 32 || start % length || start + length + 4 > bytes.length) invalid();
|
|
27
|
+
blocks.push({ offset: start, length }); intervals.push([start, start + length]);
|
|
28
|
+
}
|
|
29
|
+
if (book.take((Math.ceil(count / 256) * 256 - count) * 4).some(byte => byte !== 0)) invalid();
|
|
30
|
+
if (blocks[0]?.offset !== offset || blocks[0].length < size || book.number() !== 1 || book.byte() !== 4
|
|
31
|
+
|| book.take(4).toString('latin1') !== 'DSDB') invalid();
|
|
32
|
+
const master = book.number();
|
|
33
|
+
if (!master || !blocks[master]) invalid();
|
|
34
|
+
for (let power = 0; power < 32; power++) {
|
|
35
|
+
const entries = book.number();
|
|
36
|
+
if (entries > 8192 || power < 5 && entries) invalid();
|
|
37
|
+
for (let index = 0; index < entries; index++) {
|
|
38
|
+
const start = book.number(), length = 2 ** power;
|
|
39
|
+
if (start < 32 || start % length || start + length > 2 ** 31) invalid();
|
|
40
|
+
intervals.push([start, start + length]);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
intervals.sort((a, b) => a[0] - b[0]);
|
|
44
|
+
if (intervals.some((range, index) => index && range[0] !== intervals[index - 1][1])
|
|
45
|
+
|| intervals.at(-1)[1] !== 2 ** 31 || Math.max(...blocks.filter(Boolean).map(block => block.offset + block.length)) + 4 !== bytes.length) invalid();
|
|
46
|
+
return { blocks, master };
|
|
47
|
+
}
|
|
48
|
+
const fixedBlobs = new Map([['Iloc', [16]], ['dilc', [32]], ['fwi0', [16]], ['icgo', [8]], ['icsp', [8]],
|
|
49
|
+
['icvo', [18, 26]], ['info', [40, 48]], ['lssp', [8]], ['lsvo', [76]], ['BKGD', [12]]]);
|
|
50
|
+
const plistBlobs = new Set(['bwsp', 'icvp', 'lsvp', 'lsvP']);
|
|
51
|
+
const expectedTypes = new Map([
|
|
52
|
+
...[...fixedBlobs.keys(), ...plistBlobs].map(code => [code, 'blob']),
|
|
53
|
+
...['ICVO', 'LSVO', 'dscl'].map(code => [code, 'bool']),
|
|
54
|
+
...['fwsw', 'vSrn'].map(code => [code, 'long']),
|
|
55
|
+
...['fwvh', 'icvt', 'lsvt'].map(code => [code, 'shor']),
|
|
56
|
+
...['cmmt', 'extn', 'GRP0'].map(code => [code, 'ustr']),
|
|
57
|
+
...['logS', 'lg1S', 'phyS', 'ph1S'].map(code => [code, 'comp']),
|
|
58
|
+
...['modD', 'moDD'].map(code => [code, 'dutc']), ['vstl', 'type'],
|
|
59
|
+
]);
|
|
60
|
+
function unicode(input) {
|
|
61
|
+
const count = input.number();
|
|
62
|
+
if (count > 65536) invalid();
|
|
63
|
+
const value = new TextDecoder('utf-16be', { fatal: true }).decode(input.take(count * 2));
|
|
64
|
+
if (value.includes('\0')) invalid();
|
|
65
|
+
return value;
|
|
66
|
+
}
|
|
67
|
+
function record(input) {
|
|
68
|
+
const name = unicode(input), code = input.take(4).toString('latin1'), type = input.take(4).toString('latin1');
|
|
69
|
+
if (!name || name.length > 1024 || name.includes('/') || expectedTypes.get(code) !== type) invalid();
|
|
70
|
+
if (type === 'blob') {
|
|
71
|
+
const blob = input.take(input.number());
|
|
72
|
+
if (plistBlobs.has(code)) validateFinderPropertyList(blob);
|
|
73
|
+
else if (!fixedBlobs.get(code).includes(blob.length)) invalid();
|
|
74
|
+
if (code === 'BKGD' && !['DefB', 'ClrB'].includes(blob.subarray(0, 4).toString('latin1'))) invalid();
|
|
75
|
+
} else if (type === 'ustr') unicode(input);
|
|
76
|
+
else if (type === 'bool') { if (input.byte() > 1) invalid(); }
|
|
77
|
+
else if (type === 'comp' || type === 'dutc') input.take(8);
|
|
78
|
+
else if (type === 'shor') { if (input.number() > 65535) invalid(); }
|
|
79
|
+
else if (type === 'long') input.number();
|
|
80
|
+
else if (!['icnv', 'clmv', 'Nlsv', 'Flwv'].includes(input.take(4).toString('latin1'))) invalid();
|
|
81
|
+
return name.normalize('NFD').toLowerCase() + '\0' + code;
|
|
82
|
+
}
|
|
83
|
+
function tree(bytes, blocks, master) {
|
|
84
|
+
const readBlock = id => {
|
|
85
|
+
const block = blocks[id];
|
|
86
|
+
if (!block) invalid();
|
|
87
|
+
return reader(bytes, block.offset + 4, block.offset + block.length + 4);
|
|
88
|
+
};
|
|
89
|
+
const info = readBlock(master), root = info.number(), levels = info.number(), records = info.number(), nodes = info.number(), pageSize = info.number();
|
|
90
|
+
if (levels > 32 || records > 10000 || !nodes || nodes > 8190 || pageSize !== 4096) invalid();
|
|
91
|
+
const visited = new Set([0, master]);
|
|
92
|
+
let actualRecords = 0, previous = null;
|
|
93
|
+
const walk = (id, level) => {
|
|
94
|
+
if (visited.has(id) || !blocks[id] || blocks[id].length > pageSize) invalid();
|
|
95
|
+
visited.add(id);
|
|
96
|
+
const input = readBlock(id), right = input.number(), count = input.number();
|
|
97
|
+
if (Boolean(right) !== Boolean(level) || count > 10000 || actualRecords + count > records) invalid();
|
|
98
|
+
for (let index = 0; index < count; index++) {
|
|
99
|
+
if (right) walk(input.number(), level - 1);
|
|
100
|
+
const key = record(input);
|
|
101
|
+
if (previous !== null && previous >= key) invalid();
|
|
102
|
+
previous = key; actualRecords++;
|
|
103
|
+
}
|
|
104
|
+
if (right) walk(right, level - 1);
|
|
105
|
+
};
|
|
106
|
+
walk(root, levels);
|
|
107
|
+
if (actualRecords !== records || visited.size !== nodes + 2
|
|
108
|
+
|| blocks.some((block, id) => block && !visited.has(id))) invalid();
|
|
109
|
+
return { format: 'finder-ds-store/1.0', records, nodes, bytes: bytes.length };
|
|
110
|
+
}
|
|
111
|
+
export function validateFinderStore(bytes) {
|
|
112
|
+
if (!Buffer.isBuffer(bytes)) invalid();
|
|
113
|
+
const { blocks, master } = allocation(bytes);
|
|
114
|
+
return tree(bytes, blocks, master);
|
|
115
|
+
}
|
|
@@ -92,6 +92,16 @@ function contentFor(input, baseline) {
|
|
|
92
92
|
if (input.expectedSha256 !== undefined && input.expectedSha256 !== null && input.expectedSha256 !== baseline.sha256) {
|
|
93
93
|
mutationFail14('ENTROROVE_WRITE_BASELINE', 'Target differs from the expected snapshot hash');
|
|
94
94
|
}
|
|
95
|
+
if (Object.hasOwn(input, 'contentBase64')) {
|
|
96
|
+
if (Object.hasOwn(input, 'content') || typeof input.contentBase64 !== 'string'
|
|
97
|
+
|| input.contentBase64.length > 16_000_000 || input.contentBase64.length % 4
|
|
98
|
+
|| !/^[A-Za-z0-9+/]*={0,2}$/.test(input.contentBase64)) {
|
|
99
|
+
mutationFail14('ENTROROVE_CONTENT_INVALID', 'Use exactly one text/JSON content or canonical bounded contentBase64');
|
|
100
|
+
}
|
|
101
|
+
const bytes = Buffer.from(input.contentBase64, 'base64');
|
|
102
|
+
if (bytes.toString('base64') !== input.contentBase64) mutationFail14('ENTROROVE_CONTENT_INVALID', 'Binary content must use canonical base64');
|
|
103
|
+
return bytes;
|
|
104
|
+
}
|
|
95
105
|
let content; try { content = typeof input.content === 'string' ? input.content : JSON.stringify(input.content); }
|
|
96
106
|
catch { mutationFail14('ENTROROVE_CONTENT_INVALID', 'Guarded-write content must be finite JSON or text'); }
|
|
97
107
|
if (typeof content !== 'string') mutationFail14('ENTROROVE_CONTENT_INVALID', 'Guarded-write content must be JSON or text');
|
|
@@ -103,6 +113,7 @@ async function performWrite(input, state, value) {
|
|
|
103
113
|
return withSwarmMutationLease(input, pass, async () => {
|
|
104
114
|
const baseline = snapshot.manifest.files.find(file => file.path === target), content = contentFor(input, baseline);
|
|
105
115
|
const discipline = await disciplineWritePreflight(input, current, content);
|
|
116
|
+
mutationLifetime14(pass);
|
|
106
117
|
const entry = { status: 'pending', contentDigest: sha256(content), mode: baseline.mode ?? 0o644, at: new Date().toISOString() };
|
|
107
118
|
await saveMutationConsumption14(locations, { ...ledger, writes: { ...ledger.writes, [target]: entry } });
|
|
108
119
|
await auditMutation14(input, 'guarded-write-authorized', { passId: pass.passId, sha256: entry.contentDigest,
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { constants, promises as fs } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { createInterface } from 'node:readline';
|
|
4
|
+
import { canonical, sha256 } from './local-integrity.mjs';
|
|
5
|
+
import { mutationFile14, mutationStateRoot14, readMutationJson14, mutationFail14 } from './local-mutation-files.mjs';
|
|
6
|
+
|
|
7
|
+
const fail = () => mutationFail14('ENTROROVE_DISCIPLINE_REPOSITORY_DRIFT',
|
|
8
|
+
'Post-write recovery requires the original successful write audit and intact execution ledger; no baseline or permission was changed');
|
|
9
|
+
const iso = value => typeof value === 'string' && Number.isFinite(Date.parse(value)) && new Date(value).toISOString() === value;
|
|
10
|
+
|
|
11
|
+
function checkedLedger(value, repositoryDigest) {
|
|
12
|
+
if (value.schemaVersion !== 'entrorove.execution-ledger/1.0' || value.repositoryDigest !== repositoryDigest
|
|
13
|
+
|| !Array.isArray(value.records) || value.records.length > 100_000 || value.sequence !== value.records.length) fail();
|
|
14
|
+
let previous = null;
|
|
15
|
+
for (const [index, record] of value.records.entries()) {
|
|
16
|
+
const { digest, previousDigest, ...unsigned } = record;
|
|
17
|
+
if (record.sequence !== index + 1 || previousDigest !== previous || !iso(record.at)
|
|
18
|
+
|| digest !== sha256(canonical(unsigned) + (previous ?? ''))) fail();
|
|
19
|
+
previous = digest;
|
|
20
|
+
}
|
|
21
|
+
if (value.head !== previous) fail();
|
|
22
|
+
return value.records;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function matchingAudit(file, pass, candidates) {
|
|
26
|
+
const item = await mutationFile14(file);
|
|
27
|
+
if (item.stat.nlink !== 1 || item.stat.size > 256 * 1024 * 1024
|
|
28
|
+
|| process.platform !== 'win32' && (item.stat.mode & 0o077)
|
|
29
|
+
|| process.getuid && item.stat.uid !== process.getuid()) fail();
|
|
30
|
+
const handle = await fs.open(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
31
|
+
const matched = new Map();
|
|
32
|
+
try {
|
|
33
|
+
const before = await handle.stat();
|
|
34
|
+
if (before.dev !== item.stat.dev || before.ino !== item.stat.ino || before.size < item.stat.size) fail();
|
|
35
|
+
if (item.stat.size) {
|
|
36
|
+
const lines = createInterface({ input: handle.createReadStream({ start: 0, end: item.stat.size - 1, autoClose: false }), crlfDelay: Infinity });
|
|
37
|
+
try {
|
|
38
|
+
for await (const line of lines) {
|
|
39
|
+
if (Buffer.byteLength(line) > 1_048_576) fail();
|
|
40
|
+
let record; try { record = JSON.parse(line); } catch { fail(); }
|
|
41
|
+
if (record.event !== 'guarded-write' || record.passId !== pass.passId) continue;
|
|
42
|
+
if (record.schemaVersion !== 'entrorove.local-execution/1.0' || record.flowId !== pass.flowId
|
|
43
|
+
|| record.stepId !== pass.stepId || record.repositoryDigest !== pass.repositoryDigest || !iso(record.at)) fail();
|
|
44
|
+
const candidate = candidates.get(record.targetPathDigest);
|
|
45
|
+
if (!candidate || matched.has(candidate.targetPath) || record.sha256 !== candidate.sha256
|
|
46
|
+
|| Date.parse(record.at) < Date.parse(candidate.writtenAt) || Date.parse(record.at) > Date.parse(candidate.at)) fail();
|
|
47
|
+
matched.set(candidate.targetPath, record);
|
|
48
|
+
}
|
|
49
|
+
} finally { lines.close(); }
|
|
50
|
+
}
|
|
51
|
+
const after = await handle.stat(), current = await mutationFile14(file);
|
|
52
|
+
if (after.dev !== item.stat.dev || after.ino !== item.stat.ino || after.nlink !== 1
|
|
53
|
+
|| after.size < item.stat.size || current.stat.dev !== after.dev || current.stat.ino !== after.ino) fail();
|
|
54
|
+
return matched;
|
|
55
|
+
} finally { await handle.close(); }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function mutationCompletionEvidence(input, state) {
|
|
59
|
+
const { pass, ledger } = state, stateRoot = await mutationStateRoot14(input);
|
|
60
|
+
let records;
|
|
61
|
+
try { records = checkedLedger(await readMutationJson14(path.join(stateRoot, 'ledgers', pass.repositoryDigest, 'state.json')), pass.repositoryDigest); }
|
|
62
|
+
catch { fail(); }
|
|
63
|
+
const candidates = new Map();
|
|
64
|
+
for (const [targetPath, written] of Object.entries(ledger.writes)) {
|
|
65
|
+
if (written.status !== 'written') continue;
|
|
66
|
+
const matches = records.filter(record => record.event === 'guarded-write' && record.passId === pass.passId && record.targetPath === targetPath);
|
|
67
|
+
const record = matches[0];
|
|
68
|
+
if (matches.length !== 1 || record.flowId !== pass.flowId || record.stepId !== pass.stepId
|
|
69
|
+
|| record.sha256 !== written.contentDigest || !iso(written.writtenAt)
|
|
70
|
+
|| Date.parse(record.at) < Date.parse(written.writtenAt) || Date.parse(record.at) > Date.now()) fail();
|
|
71
|
+
candidates.set(sha256(targetPath), { ...record, writtenAt: written.writtenAt });
|
|
72
|
+
}
|
|
73
|
+
if (!candidates.size) fail();
|
|
74
|
+
let audits;
|
|
75
|
+
try { audits = await matchingAudit(path.join(stateRoot, 'invocations.jsonl'), pass, candidates); } catch { fail(); }
|
|
76
|
+
return [...candidates.values()].sort((a, b) => a.targetPath < b.targetPath ? -1 : 1).map(record => {
|
|
77
|
+
const audit = audits.get(record.targetPath);
|
|
78
|
+
if (!audit) fail();
|
|
79
|
+
return { path: record.targetPath, sha256: record.sha256, writtenAt: record.writtenAt, completedAt: record.at,
|
|
80
|
+
ledgerSequence: record.sequence, ledgerRecordDigest: record.digest, auditRecordDigest: sha256(canonical(audit)),
|
|
81
|
+
source: 'original-successful-write-audit-and-ledger' };
|
|
82
|
+
});
|
|
83
|
+
}
|
|
@@ -2,7 +2,7 @@ import path from 'node:path';
|
|
|
2
2
|
import { randomUUID, sign, verify } from 'node:crypto';
|
|
3
3
|
import { canonical, sha256 } from './local-integrity.mjs';
|
|
4
4
|
import { withStateLock } from './local-state-lock.mjs';
|
|
5
|
-
import { mutationFail14, mutationRead14, readMutationJson14, atomicMutationJson14, privateMutationDirectory14 } from './local-mutation-files.mjs';
|
|
5
|
+
import { mutationFail14, mutationRead14, mutationTarget14, readMutationJson14, atomicMutationJson14, privateMutationDirectory14 } from './local-mutation-files.mjs';
|
|
6
6
|
import { readStoredMutationPass14, readMutationConsumption14, mutationPassLocations14 } from './local-mutation-pass-store.mjs';
|
|
7
7
|
import { assertPreservedMutationIssued } from './local-preserved-mutation-state.mjs';
|
|
8
8
|
import { loadPreservedMutationSigner } from './local-preserved-mutation-signer.mjs';
|
|
@@ -10,10 +10,11 @@ import { preservedMutationPublicKey } from './local-preserved-mutation-wire.mjs'
|
|
|
10
10
|
import { assertPreservedMutationProtection } from './local-preserved-mutation-protection.mjs';
|
|
11
11
|
import { snapshotAuthority14, readSnapshotCopy14 } from './local-mutation-snapshot.mjs';
|
|
12
12
|
import { assertDelegatedSourceModification } from './local-delegated-modify-analysis.mjs';
|
|
13
|
-
import {
|
|
13
|
+
import { observeCompletedMutationEnvironment, readMutationEnvironmentObservation } from './local-mutation-environment-observation.mjs';
|
|
14
14
|
import { readDisciplineSnapshot } from './local-discipline-snapshot.mjs';
|
|
15
|
+
import { assertDisciplineRepository } from './local-discipline-repository.mjs';
|
|
15
16
|
|
|
16
|
-
const fail = () => mutationFail14('ENTROROVE_MUTATION_CONSUMPTION_INVALID', 'Mutation consumption requires
|
|
17
|
+
const fail = () => mutationFail14('ENTROROVE_MUTATION_CONSUMPTION_INVALID', 'Mutation consumption requires actual original writes or expired unchanged unwritten targets, intact protection and the separate M signer');
|
|
17
18
|
const iso = value => typeof value === 'string' && Number.isFinite(Date.parse(value)) && new Date(value).toISOString() === value;
|
|
18
19
|
export async function preservedMutationConsumptionState(input) {
|
|
19
20
|
const pass = await readStoredMutationPass14(input, { live: false });
|
|
@@ -24,36 +25,58 @@ export async function preservedMutationConsumptionState(input) {
|
|
|
24
25
|
}
|
|
25
26
|
async function actualWrites(input, state) {
|
|
26
27
|
const { pass, binding, ledger } = state;
|
|
27
|
-
if (!state.complete
|
|
28
|
+
if (!state.complete && Date.now() < Date.parse(pass.expiresAt)) fail();
|
|
28
29
|
const snapshot = await snapshotAuthority14(input, binding.root, pass.snapshotReceipt);
|
|
29
30
|
if (sha256(canonical(snapshot.manifest)) !== pass.snapshotManifestDigest || snapshot.manifest.snapshotDigest !== pass.snapshotDigest) fail();
|
|
30
|
-
const writes = [];
|
|
31
|
+
const writes = [], unwritten = [];
|
|
32
|
+
const create = pass.writeDelegation.purpose === 'planned-file-create';
|
|
31
33
|
for (const target of pass.paths) {
|
|
32
34
|
const completed = ledger.writes[target], before = snapshot.manifest.files.find(file => file.path === target);
|
|
35
|
+
if (completed?.status !== 'written') {
|
|
36
|
+
if (!before || completed && (completed.status !== 'pending' || !iso(completed.at)
|
|
37
|
+
|| Date.parse(completed.at) < Date.parse(pass.expiresAt) || Date.parse(completed.at) > Date.now())) fail();
|
|
38
|
+
const item = await mutationTarget14(binding.root, target, true);
|
|
39
|
+
const current = item.exists ? await mutationRead14(input, binding.root, target)
|
|
40
|
+
: { sha256: sha256('entrorove.missing-file/1.0'), mode: null };
|
|
41
|
+
if (item.exists !== before.exists || current.sha256 !== before.sha256 || current.mode !== before.mode) fail();
|
|
42
|
+
unwritten.push({ path: target, beforeSha256: before.sha256, beforeMode: before.mode,
|
|
43
|
+
observedSha256: current.sha256, observedMode: current.mode, ledgerStatus: completed ? 'expired-before-write' : 'untouched',
|
|
44
|
+
attemptedAt: completed?.at ?? null, attemptedContentDigest: completed?.contentDigest ?? null });
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
33
47
|
if (!iso(completed.writtenAt)) mutationFail14('ENTROROVE_MUTATION_CONSUMPTION_WRITE_TIME', 'Original write has no verifiable completion timestamp; its old receipt cannot authorize another mutation');
|
|
34
|
-
if (!before
|
|
48
|
+
if (!before || (create ? before.exists !== false || before.mode !== null : !before.exists)
|
|
49
|
+
|| completed.kind && completed.kind !== 'modify' || !iso(completed.at)
|
|
35
50
|
|| Date.parse(completed.writtenAt) < Date.parse(pass.issuedAt) || Date.parse(completed.writtenAt) >= Date.parse(pass.expiresAt)
|
|
36
51
|
|| Date.parse(completed.writtenAt) < Date.parse(completed.at) || Date.parse(completed.writtenAt) > Date.now()) fail();
|
|
37
52
|
const current = await mutationRead14(input, binding.root, target), copy = await readSnapshotCopy14(input, snapshot.snapshotRoot, snapshot.manifest, target);
|
|
38
|
-
if (current.sha256 !== completed.contentDigest || current.mode !== completed.mode || current.mode !== before.mode) fail();
|
|
39
|
-
assertDelegatedSourceModification({ path: target, before: copy.content.toString('utf8'), after: current.content.toString('utf8') });
|
|
53
|
+
if (current.sha256 !== completed.contentDigest || current.mode !== completed.mode || current.mode !== (create ? 0o644 : before.mode)) fail();
|
|
54
|
+
if (pass.writeDelegation.purpose === 'preserved-existing-modify') assertDelegatedSourceModification({ path: target, before: copy.content.toString('utf8'), after: current.content.toString('utf8') });
|
|
40
55
|
writes.push({ path: target, beforeSha256: before.sha256, beforeMode: before.mode,
|
|
41
56
|
afterSha256: current.sha256, afterMode: current.mode, writtenAt: completed.writtenAt });
|
|
42
57
|
}
|
|
43
|
-
await assertDisciplineRepository(input, binding.root, await readDisciplineSnapshot(snapshot), ledger);
|
|
44
58
|
await assertPreservedMutationProtection(input, binding.root, pass);
|
|
45
|
-
|
|
59
|
+
const review = await readDisciplineSnapshot(snapshot);
|
|
60
|
+
const writtenLedger = { ...ledger, writes: Object.fromEntries(Object.entries(ledger.writes).filter(([, value]) => value.status === 'written')) };
|
|
61
|
+
if (!writes.length) await assertDisciplineRepository(input, binding.root, review, writtenLedger);
|
|
62
|
+
else await observeCompletedMutationEnvironment(input, state, review, writtenLedger);
|
|
63
|
+
return { writes, ...(unwritten.length ? { unwritten, reconciledAt: new Date(Date.now()).toISOString() } : {}) };
|
|
46
64
|
}
|
|
47
|
-
function receiptCore(pass,
|
|
48
|
-
return { schemaVersion: 'entrorove.preserved-mutation-consumption/1.0', purpose:
|
|
65
|
+
function receiptCore(pass, physical) {
|
|
66
|
+
return { schemaVersion: 'entrorove.preserved-mutation-consumption/1.0', purpose: `${pass.writeDelegation.purpose}-consumption`,
|
|
49
67
|
keyId: pass.writeDelegation.mutationKeyId, ownerId: pass.ownerId, taskId: pass.taskId, flowId: pass.flowId, stepId: pass.stepId,
|
|
50
68
|
leaseId: pass.leaseId, generation: pass.generation, epoch: pass.epoch, passId: pass.passId, passDigest: sha256(canonical(pass)),
|
|
51
69
|
challengeId: pass.challengeId, challengeNonce: pass.challengeNonce, passNonce: pass.nonce,
|
|
52
70
|
snapshotId: pass.snapshotId, snapshotDigest: pass.snapshotDigest,
|
|
53
|
-
protectedFilesDigest: sha256(canonical(pass.writeDelegation.protectedFiles)),
|
|
71
|
+
protectedFilesDigest: sha256(canonical(pass.writeDelegation.protectedFiles)), ...physical };
|
|
54
72
|
}
|
|
55
73
|
function cachedReceipt(value, pass, core) {
|
|
56
74
|
const { signature, issuedAt, expiresAt, nonce, ...actual } = value;
|
|
75
|
+
if (core.reconciledAt) {
|
|
76
|
+
if (!iso(actual.reconciledAt) || Date.parse(actual.reconciledAt) < Date.parse(pass.expiresAt)
|
|
77
|
+
|| Date.parse(actual.reconciledAt) > Date.parse(issuedAt)) fail();
|
|
78
|
+
core = { ...core, reconciledAt: actual.reconciledAt };
|
|
79
|
+
}
|
|
57
80
|
const unsigned = { ...actual, issuedAt, expiresAt, nonce }, bytes = typeof signature === 'string' ? Buffer.from(signature, 'base64url') : Buffer.alloc(0);
|
|
58
81
|
if (canonical(actual) !== canonical(core) || !iso(issuedAt) || !iso(expiresAt) || Date.parse(issuedAt) > Date.now()
|
|
59
82
|
|| Date.parse(expiresAt) <= Date.parse(issuedAt) || Date.parse(expiresAt) - Date.parse(issuedAt) > 300_000
|
|
@@ -62,7 +85,7 @@ function cachedReceipt(value, pass, core) {
|
|
|
62
85
|
return value;
|
|
63
86
|
}
|
|
64
87
|
async function consumptionReceipt(input, state, { renewExpired = false, allowExpiredReplay = false } = {}) {
|
|
65
|
-
const
|
|
88
|
+
const physical = await actualWrites(input, state), core = receiptCore(state.pass, physical);
|
|
66
89
|
const directory = path.join(state.binding.directory, 'consumption-receipts'), file = path.join(directory, `${state.pass.passId}.json`);
|
|
67
90
|
await privateMutationDirectory14(directory, { privateFrom: state.binding.stateRoot });
|
|
68
91
|
let previous;
|
|
@@ -86,7 +109,9 @@ export async function attestMutationConsumption14(input, options = {}) {
|
|
|
86
109
|
const original = await preservedMutationConsumptionState(input);
|
|
87
110
|
return withStateLock(original.locations.consumptionPath, async () => {
|
|
88
111
|
const state = await preservedMutationConsumptionState(input), receipt = await consumptionReceipt(input, state, options);
|
|
112
|
+
const environmentObservation = await readMutationEnvironmentObservation(input, state.pass);
|
|
89
113
|
return { schemaVersion: 'entrorove.local-execution/1.0', status: 'reported', consumptionReceipt: receipt,
|
|
114
|
+
...(environmentObservation ? { environmentObservation, environmentObservationAcknowledgedByServer: false } : {}),
|
|
90
115
|
claimsVerifiedExecution: false, claimsCompletion: false,
|
|
91
116
|
nextAction: { operation: 'mutation-pass-settle', input: { passId: state.pass.passId, consumptionReceipt: receipt } } };
|
|
92
117
|
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { randomUUID, sign, verify } from 'node:crypto';
|
|
3
|
+
import { canonical, sha256 } from './local-integrity.mjs';
|
|
4
|
+
import { mutationFail14, mutationStateRoot14, atomicMutationJson14, readMutationJson14 } from './local-mutation-files.mjs';
|
|
5
|
+
import { observeDisciplineRepositoryState, repositoryIntegrityFile } from './local-discipline-repository.mjs';
|
|
6
|
+
import { mutationCompletionEvidence } from './local-mutation-completion-evidence.mjs';
|
|
7
|
+
import { loadPreservedMutationSigner } from './local-preserved-mutation-signer.mjs';
|
|
8
|
+
import { preservedMutationPublicKey } from './local-preserved-mutation-wire.mjs';
|
|
9
|
+
import { finderMetadataPath, observeFinderMetadataChanges } from './local-mutation-finder-observation.mjs';
|
|
10
|
+
|
|
11
|
+
const schemaVersion = 'entrorove.post-write-environment-observation/1.0';
|
|
12
|
+
const fail = () => mutationFail14('ENTROROVE_DISCIPLINE_REPOSITORY_DRIFT',
|
|
13
|
+
'Environment drift is not a proven new regular file created after the expired pass and its original successful write checks; original source protection remains enforced');
|
|
14
|
+
const iso = value => typeof value === 'string' && Number.isFinite(Date.parse(value)) && new Date(value).toISOString() === value;
|
|
15
|
+
const attribution = changes => changes.some(change => change?.kind === 'validated-finder-metadata')
|
|
16
|
+
? changes.every(change => change?.kind === 'validated-finder-metadata')
|
|
17
|
+
? 'unattributed-post-write-finder-metadata' : 'unattributed-post-write-environment'
|
|
18
|
+
: 'unattributed-post-write-additions';
|
|
19
|
+
function location(stateRoot, pass) {
|
|
20
|
+
return path.join(stateRoot, 'post-write-environment', pass.repositoryDigest, pass.passId + '.json');
|
|
21
|
+
}
|
|
22
|
+
function verified(value, pass) {
|
|
23
|
+
const { signature, ...unsigned } = value ?? {}, key = preservedMutationPublicKey(pass.writeDelegation.mutationPublicKey);
|
|
24
|
+
const expected = { schemaVersion, keyId: pass.writeDelegation.mutationKeyId, ownerId: pass.ownerId, taskId: pass.taskId,
|
|
25
|
+
flowId: pass.flowId, stepId: pass.stepId, passId: pass.passId, passDigest: sha256(canonical(pass)),
|
|
26
|
+
originalReviewDigest: pass.snapshotReceipt.disciplineReviewDigest,
|
|
27
|
+
attribution: attribution(Array.isArray(value?.changes) ? value.changes : []), grantsWriteAuthority: false,
|
|
28
|
+
claimsRepositoryUnchanged: false, claimsAuthorizedEnvironmentChange: false };
|
|
29
|
+
if (Object.entries(expected).some(([field, expected]) => canonical(value?.[field]) !== canonical(expected))
|
|
30
|
+
|| !iso(value.observedAt) || Date.parse(value.observedAt) > Date.now()
|
|
31
|
+
|| !Array.isArray(value.changes) || !value.changes.length || !Array.isArray(value.completionEvidence)
|
|
32
|
+
|| !verify(null, Buffer.from(canonical(unsigned)), key.key, Buffer.from(signature ?? '', 'base64url'))) fail();
|
|
33
|
+
return value;
|
|
34
|
+
}
|
|
35
|
+
export async function readMutationEnvironmentObservation(input, pass) {
|
|
36
|
+
try { return verified(await readMutationJson14(location(await mutationStateRoot14(input), pass)), pass); }
|
|
37
|
+
catch (error) { if (error.code === 'ENOENT') return null; throw error; }
|
|
38
|
+
}
|
|
39
|
+
async function additions(root, review, comparison, changed, state, completionEvidence) {
|
|
40
|
+
const { pass } = state, before = new Set(review.comparison.files.map(file => file.path));
|
|
41
|
+
const protectedPaths = new Set([...pass.paths, ...pass.writeDelegation.protectedFiles.map(file => file.path)]);
|
|
42
|
+
const completedAt = Math.max(Date.parse(pass.expiresAt), ...completionEvidence.map(record => Date.parse(record.completedAt)));
|
|
43
|
+
if (Date.now() < Date.parse(pass.expiresAt) || changed.length > 512) fail();
|
|
44
|
+
const changes = [];
|
|
45
|
+
for (const relative of changed) {
|
|
46
|
+
const observed = comparison.files.find(file => file.path === relative);
|
|
47
|
+
if (before.has(relative) || protectedPaths.has(relative) || observed?.kind !== 'file') fail();
|
|
48
|
+
const current = await repositoryIntegrityFile(root, relative), identity = current.identity;
|
|
49
|
+
if (current.sha256 !== observed.sha256 || current.mode !== observed.mode || current.mode & 0o111
|
|
50
|
+
|| identity.nlink !== 1 || !Number.isFinite(identity.birthtimeMs) || identity.birthtimeMs <= completedAt
|
|
51
|
+
|| identity.birthtimeMs > Date.now() || identity.ctimeMs < identity.birthtimeMs) fail();
|
|
52
|
+
changes.push({ path: relative, before: null, after: { sha256: current.sha256, mode: current.mode },
|
|
53
|
+
physicalIdentity: identity, createdAt: new Date(identity.birthtimeMs).toISOString() });
|
|
54
|
+
}
|
|
55
|
+
return changes;
|
|
56
|
+
}
|
|
57
|
+
export async function observeCompletedMutationEnvironment(input, state, review, writtenLedger) {
|
|
58
|
+
const { comparison, changed } = await observeDisciplineRepositoryState(input, state.binding.root, review, writtenLedger);
|
|
59
|
+
if (!changed.length) return null;
|
|
60
|
+
if (changed.length > 512) fail();
|
|
61
|
+
const completionEvidence = await mutationCompletionEvidence(input, state);
|
|
62
|
+
const finder = changed.filter(finderMetadataPath), other = changed.filter(relative => !finderMetadataPath(relative));
|
|
63
|
+
const changes = [...(finder.length ? await observeFinderMetadataChanges(state.binding.root, review, comparison, finder, state, completionEvidence) : []),
|
|
64
|
+
...await additions(state.binding.root, review, comparison, other, state, completionEvidence)].sort((a, b) => a.path < b.path ? -1 : 1);
|
|
65
|
+
const pass = state.pass;
|
|
66
|
+
const core = { schemaVersion, keyId: pass.writeDelegation.mutationKeyId, ownerId: pass.ownerId, taskId: pass.taskId,
|
|
67
|
+
flowId: pass.flowId, stepId: pass.stepId, passId: pass.passId, passDigest: sha256(canonical(pass)),
|
|
68
|
+
originalReviewDigest: pass.snapshotReceipt.disciplineReviewDigest,
|
|
69
|
+
originalComparisonDigest: sha256(canonical(review.comparison)), observedComparisonDigest: sha256(canonical(comparison)),
|
|
70
|
+
attribution: attribution(changes), grantsWriteAuthority: false,
|
|
71
|
+
claimsRepositoryUnchanged: false, claimsAuthorizedEnvironmentChange: false, completionEvidence, changes };
|
|
72
|
+
const stateRoot = await mutationStateRoot14(input), file = location(stateRoot, pass);
|
|
73
|
+
let previous;
|
|
74
|
+
try { previous = verified(await readMutationJson14(file), pass); } catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
75
|
+
if (previous) {
|
|
76
|
+
const { signature: _, observedAt: __, nonce: ___, ...original } = previous;
|
|
77
|
+
if (canonical(original) !== canonical(core)) fail();
|
|
78
|
+
return previous;
|
|
79
|
+
}
|
|
80
|
+
const signer = await loadPreservedMutationSigner(input, state.binding.signerConfigPath, core.keyId);
|
|
81
|
+
const unsigned = { ...core, observedAt: new Date().toISOString(), nonce: randomUUID() };
|
|
82
|
+
const result = { ...unsigned, signature: sign(null, Buffer.from(canonical(unsigned)), signer.privateKey).toString('base64url') };
|
|
83
|
+
await atomicMutationJson14(file, result, { privateFrom: stateRoot });
|
|
84
|
+
return result;
|
|
85
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { constants, promises as fs } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { sha256 } from './local-integrity.mjs';
|
|
4
|
+
import { mutationTarget14, mutationFail14 } from './local-mutation-files.mjs';
|
|
5
|
+
import { validateFinderStore } from './local-finder-store.mjs';
|
|
6
|
+
|
|
7
|
+
const fail = () => mutationFail14('ENTROROVE_DISCIPLINE_REPOSITORY_DRIFT',
|
|
8
|
+
'Finder metadata recovery requires bounded valid allocator/tree/property-list bytes, original successful write evidence, unchanged protected sources, and single-link non-executable files changed only after completion');
|
|
9
|
+
const same = (left, right) => ['dev', 'ino', 'nlink', 'size', 'mtimeMs', 'ctimeMs', 'mode'].every(key => left[key] === right[key]);
|
|
10
|
+
export const finderMetadataPath = relative => path.posix.basename(relative) === '.DS_Store';
|
|
11
|
+
|
|
12
|
+
async function metadataFile(root, relative) {
|
|
13
|
+
const item = await mutationTarget14(root, relative), handle = await fs.open(item.target, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
14
|
+
try {
|
|
15
|
+
const before = await handle.stat();
|
|
16
|
+
if (!same(before, item.stat) || !before.isFile() || before.nlink !== 1 || before.mode & 0o7111
|
|
17
|
+
|| before.size > 1_048_576) fail();
|
|
18
|
+
const bytes = Buffer.alloc(before.size);
|
|
19
|
+
let offset = 0;
|
|
20
|
+
while (offset < bytes.length) {
|
|
21
|
+
const read = await handle.read(bytes, offset, bytes.length - offset, offset);
|
|
22
|
+
if (!read.bytesRead) fail();
|
|
23
|
+
offset += read.bytesRead;
|
|
24
|
+
}
|
|
25
|
+
let format;
|
|
26
|
+
try { format = validateFinderStore(bytes); } catch { fail(); }
|
|
27
|
+
const after = await handle.stat(), current = await mutationTarget14(root, relative);
|
|
28
|
+
if (!same(before, after) || !same(after, current.stat)) fail();
|
|
29
|
+
return { sha256: sha256(bytes), mode: after.mode & 0o777, format,
|
|
30
|
+
identity: { dev: after.dev, ino: after.ino, nlink: after.nlink, birthtimeMs: after.birthtimeMs,
|
|
31
|
+
ctimeMs: after.ctimeMs, mtimeMs: after.mtimeMs, size: after.size } };
|
|
32
|
+
} finally { await handle.close(); }
|
|
33
|
+
}
|
|
34
|
+
export async function observeFinderMetadataChanges(root, review, comparison, changed, state, completionEvidence) {
|
|
35
|
+
const pass = state.pass, protectedPaths = new Set([...pass.paths, ...pass.writeDelegation.protectedFiles.map(file => file.path)]);
|
|
36
|
+
const completedAt = Math.max(...completionEvidence.map(record => Date.parse(record.completedAt)));
|
|
37
|
+
if (!completionEvidence.length || !Number.isFinite(completedAt) || Date.now() < Date.parse(pass.expiresAt)
|
|
38
|
+
|| !changed.length || changed.length > 512) fail();
|
|
39
|
+
const changes = [];
|
|
40
|
+
let bytes = 0;
|
|
41
|
+
for (const relative of changed) {
|
|
42
|
+
const before = review.comparison.files.find(file => file.path === relative) ?? null;
|
|
43
|
+
const observed = comparison.files.find(file => file.path === relative);
|
|
44
|
+
if (!finderMetadataPath(relative) || protectedPaths.has(relative) || observed?.kind !== 'file'
|
|
45
|
+
|| before && (before.kind !== 'file' || before.mode !== observed.mode || before.mode & 0o111)) fail();
|
|
46
|
+
const current = await metadataFile(root, relative), identity = current.identity;
|
|
47
|
+
if (current.sha256 !== observed.sha256 || current.mode !== observed.mode
|
|
48
|
+
|| ![identity.birthtimeMs, identity.mtimeMs, identity.ctimeMs].every(value => Number.isFinite(value) && value <= Date.now())
|
|
49
|
+
|| identity.mtimeMs <= completedAt || identity.ctimeMs <= completedAt
|
|
50
|
+
|| !before && identity.birthtimeMs <= completedAt || identity.birthtimeMs > identity.ctimeMs) fail();
|
|
51
|
+
bytes += identity.size;
|
|
52
|
+
if (bytes > 8_388_608) fail();
|
|
53
|
+
changes.push({ path: relative, kind: 'validated-finder-metadata', before,
|
|
54
|
+
after: { sha256: current.sha256, mode: current.mode }, physicalIdentity: identity, format: current.format,
|
|
55
|
+
originalContentAvailable: false, claimsOriginalContentFormatValidated: false });
|
|
56
|
+
}
|
|
57
|
+
return changes;
|
|
58
|
+
}
|