dsh-plugin-coding-delegate 0.0.0-stage → 0.2.2

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 (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +920 -2
  3. package/cordis.patch.example.yml +48 -0
  4. package/cordis.patch.yml +13 -0
  5. package/dist/attestation.d.ts +25 -0
  6. package/dist/attestation.js +44 -0
  7. package/dist/containment.d.ts +33 -0
  8. package/dist/containment.js +63 -0
  9. package/dist/context-quality.d.ts +79 -0
  10. package/dist/context-quality.js +181 -0
  11. package/dist/context.d.ts +39 -0
  12. package/dist/context.js +136 -0
  13. package/dist/contracts.d.ts +174 -0
  14. package/dist/contracts.js +415 -0
  15. package/dist/declaration-egress-hook.d.ts +45 -0
  16. package/dist/declaration-egress-hook.js +125 -0
  17. package/dist/declaration-egress.d.ts +37 -0
  18. package/dist/declaration-egress.js +169 -0
  19. package/dist/delegation.d.ts +339 -0
  20. package/dist/delegation.js +933 -0
  21. package/dist/emission.d.ts +70 -0
  22. package/dist/emission.js +310 -0
  23. package/dist/guard.d.ts +162 -0
  24. package/dist/guard.js +873 -0
  25. package/dist/host-events.d.ts +15 -0
  26. package/dist/host-events.js +27 -0
  27. package/dist/index.d.ts +560 -0
  28. package/dist/index.js +1398 -0
  29. package/dist/local-classifier.d.ts +52 -0
  30. package/dist/local-classifier.js +159 -0
  31. package/dist/logging.d.ts +21 -0
  32. package/dist/logging.js +90 -0
  33. package/dist/paths.d.ts +10 -0
  34. package/dist/paths.js +83 -0
  35. package/dist/profiles.d.ts +35 -0
  36. package/dist/profiles.js +95 -0
  37. package/dist/retry-context.d.ts +52 -0
  38. package/dist/retry-context.js +97 -0
  39. package/dist/roles.d.ts +129 -0
  40. package/dist/roles.js +363 -0
  41. package/dist/routing.d.ts +35 -0
  42. package/dist/routing.js +88 -0
  43. package/dist/savings-tracker.d.ts +128 -0
  44. package/dist/savings-tracker.js +340 -0
  45. package/dist/session-events.d.ts +17 -0
  46. package/dist/session-events.js +31 -0
  47. package/dist/verification.d.ts +104 -0
  48. package/dist/verification.js +560 -0
  49. package/package.json +66 -4
@@ -0,0 +1,174 @@
1
+ /**
2
+ * sha256 of a file, or null when it cannot be read. Callers treat null as a failure rather than as
3
+ * absence: a contract file that vanished is a violation, not an empty string.
4
+ */
5
+ export declare function sha256File(filePath: string): string | null;
6
+ /**
7
+ * Resolve the architect's declared contract paths against the workspace. Names only: the architect
8
+ * never supplies contents, and the resolved list is what the worker is forbidden to write.
9
+ */
10
+ export declare function resolveContractFiles(files: string[] | undefined, baseDir: string): string[];
11
+ /** Keyed by canonical path so two spellings of one file cannot pass as two files. */
12
+ export declare function contractFileHashes(paths: Iterable<string>): Record<string, string | null>;
13
+ /**
14
+ * Anything that changed a declared file during a unit invalidates the verdict, whatever the tests
15
+ * then reported. A missing declaration is reported too, because failing closed is the only safe
16
+ * reading of "the architect declared a contract file that is not there".
17
+ */
18
+ export declare function contractViolations(before: Record<string, string | null>, after: Record<string, string | null>): string[];
19
+ /**
20
+ * The read guard's memory of what a delegated worker wrote, and its bound.
21
+ *
22
+ * It is exported because the guard and the plugin's startup both add to it: a module that owns the
23
+ * registry cannot own the set as well without an accessor for each caller, and the set is the
24
+ * registry's state, not the guard's.
25
+ */
26
+ export declare const delegatedPaths: Set<string>;
27
+ export declare const DELEGATED_PATH_LIMIT = 500;
28
+ /**
29
+ * The record for a delegated file, by canonical path. `undefined` means no verdict covers it, and the
30
+ * guard has to read that as unsettled rather than as a pass.
31
+ */
32
+ export declare function lookupDelegatedRecord(canonicalPath: string): DelegatedRecord | undefined;
33
+ /**
34
+ * What a unit's verification said about the content it wrote.
35
+ *
36
+ * `UNIT_UNVERIFIED` is the honest third answer: a delegated edit whose contract was never checked is
37
+ * neither a pass nor a failure. Only `UNIT_PASSED` has had its content checked, so every other value
38
+ * must be read as unsettled.
39
+ *
40
+ * There are two routes to it, and a record does not say which was taken. The common one is that no
41
+ * verification command was supplied at all. The other is that a command was supplied and the gate
42
+ * stopped it -- typically approval was refused, and nothing ran. The first is an absence of evidence
43
+ * and the second is a decision not to gather any, but both leave the content exactly as unestablished,
44
+ * which is the only thing this field is for. Consumers must therefore word their explanations in terms
45
+ * of the contract that went unchecked rather than of whether a command was written down.
46
+ *
47
+ * `UNIT_FLAKY` is the fourth answer, and it exists because the first three are unsafe for a
48
+ * NON-DETERMINISTIC oracle. A property test or a concurrency harness can pass a broken implementation
49
+ * on a run where the defect simply did not trigger, and `UNIT_PASSED` is what makes a file settled --
50
+ * so the old three-state taxonomy could promote a race condition into the codebase and present it as
51
+ * verified work. That is the most dangerous output this plugin can emit, and it is silent.
52
+ *
53
+ * A flaky verdict is a statement about the ORACLE, not about the code: the contract disagreed with
54
+ * itself across repeated runs, so nothing has been established about the content either way. It is not
55
+ * a pass, it does not settle the file, and the reason names the oracle rather than the implementation.
56
+ *
57
+ * `OPERATOR_ATTESTED` is the fifth answer, and it exists because the other four all describe a DELEGATION.
58
+ * A module an engineer edited by hand has no delegation to judge it, and manufacturing one -- running a
59
+ * synthetic worker purely to stamp a hash -- would replace human-reviewed engineering with simulated
60
+ * machine origin in the audit trail. That is a worse record than no record.
61
+ *
62
+ * The two verdicts are epistemically different and this type keeps them apart on purpose:
63
+ *
64
+ * UNIT_PASSED a local model satisfied a MACHINE-CHECKED contract
65
+ * OPERATOR_ATTESTED a HUMAN verified the artifact, and the evidence of that is recorded verbatim
66
+ *
67
+ * A consumer may treat both as promotable, and must not treat them as the same claim.
68
+ */
69
+ export type DelegatedOutcome = 'UNIT_PASSED' | 'UNIT_FAILED' | 'UNIT_UNVERIFIED' | 'UNIT_FLAKY' | 'OPERATOR_ATTESTED';
70
+ /** Who attested a hand-verified file, and what they said they checked. Auditable, not decorative. */
71
+ export interface OperatorAttestation {
72
+ operator: string;
73
+ evidence: string;
74
+ at: number;
75
+ }
76
+ /**
77
+ * Every parameter `delegate_worker`'s tool schema may declare. The canonical list.
78
+ *
79
+ * WHY THIS EXISTS, and it is not tidiness. The schema is an inferred object literal, so before this there
80
+ * was nothing coupling its parameter names to any type: adding a property compiled cleanly whether or not
81
+ * the implementation had a matching argument, and adding an argument did not force the schema to expose it.
82
+ * Both directions failed silently.
83
+ *
84
+ * The direction that actually bit: `attestTargets` was added to an inline copy of the parameters in
85
+ * index.ts and not to the exported schema, so the host never received it, the delegated call was stripped
86
+ * of both attesting parameters, and the tool reported success while the feature did nothing. That
87
+ * duplication is gone -- index.ts now references the exported schema -- and this type is what stops the one
88
+ * remaining literal from drifting from the implementation.
89
+ *
90
+ * `delegation.ts` asserts the schema's keys are exactly these, so a parameter that is not on this list
91
+ * FAILS THE BUILD, rather than being visible to a model, accepted at call time, and discovered to do
92
+ * nothing weeks later.
93
+ */
94
+ export type DelegateWorkerParameter = 'taskName' | 'instruction' | 'targetFiles' | 'runVerification' | 'verificationRepeats' | 'contractFiles' | 'contextFiles' | 'workspaceDir' | 'attestTargets' | 'attestEvidence' | 'attestOperator';
95
+ /** One delegated file as it is persisted: where it is, and what was written there. */
96
+ export interface DelegatedRecord {
97
+ path: string;
98
+ sha256: string | null;
99
+ at: number;
100
+ /**
101
+ * How the worker touched this file. `created` means it produced the whole thing and the architect has
102
+ * never seen it, so reading it back is the thing rule 3 forbids. `patched` means it changed part of a
103
+ * file the architect already had -- the architect must stay able to read that, or iterating on an
104
+ * existing file becomes impossible the moment a patch to it has been delegated once.
105
+ */
106
+ mode: 'created' | 'patched';
107
+ /**
108
+ * The verdict of the delegation that last wrote this file, and when it was reached. Absent means no
109
+ * verdict covers the content in the record: nothing has verified it yet, or the file was rewritten
110
+ * since and the content the verdict described is gone.
111
+ */
112
+ outcome?: DelegatedOutcome;
113
+ succeeded?: boolean;
114
+ verdictAt?: number;
115
+ /**
116
+ * Present only for `OPERATOR_ATTESTED`: who signed off on this content, what they say they checked, and
117
+ * when. Carried so a reviewer can tell a machine-checked receipt from a human judgement without reading
118
+ * the verdict name, because those are different claims and the difference is the point of the verdict.
119
+ */
120
+ attestation?: OperatorAttestation;
121
+ }
122
+ export declare function resolveDelegatedRegistryPath(): string;
123
+ /**
124
+ * Parse a registry file. Anything unreadable, malformed, or entry-shaped-but-wrong yields no records
125
+ * rather than an exception: a corrupt registry must never be able to stop the plugin loading.
126
+ */
127
+ export declare function parseDelegatedRegistry(text: string): DelegatedRecord[];
128
+ /**
129
+ * Newest wins per path, and the oldest fall off the end once the limit is reached. The hash is not
130
+ * used to relax anything — a delegated file stays protected however it later changes — it makes the
131
+ * record answerable, and gives the prune below something to reason about.
132
+ */
133
+ export declare function mergeDelegatedRecords(existing: DelegatedRecord[], incoming: DelegatedRecord[], limit?: number): DelegatedRecord[];
134
+ /** A path whose file is gone protects nothing, so it is dropped. */
135
+ export declare function pruneDelegatedRecords(records: DelegatedRecord[], exists?: (p: string) => boolean): DelegatedRecord[];
136
+ /** Best effort by design: failing to persist must not fail a delegation that was already paid for. */
137
+ export declare function saveDelegatedRegistry(records: DelegatedRecord[]): boolean;
138
+ /** Load, validate and prune. Called at startup so a restart does not forget what was delegated. */
139
+ export declare function loadDelegatedRegistry(): DelegatedRecord[];
140
+ export declare function rememberDelegated(paths: string[], mode?: 'created' | 'patched'): void;
141
+ /**
142
+ * Stamp a unit's verdict onto the registry records for the files that unit wrote.
143
+ *
144
+ * A delegation writes the registry before its verification has run, so the record it creates cannot
145
+ * carry the verdict. This is the second half of that write, and the only thing that joins a per-file
146
+ * sha256 to a per-unit verdict -- without it "is this file settled?" has no answer, and the read guard
147
+ * has to ask about every delegated file for ever.
148
+ *
149
+ * Best effort, for the same reason the first half is: failing to persist must not fail a delegation that
150
+ * was already paid for, and an absent stamp reads as "no verdict", which is the conservative answer
151
+ * rather than a permissive one. Returns how many records were stamped, so a caller can tell a no-op
152
+ * from a write.
153
+ */
154
+ export declare function recordDelegatedOutcome(paths: string[], outcome: DelegatedOutcome, succeeded: boolean, at?: number): number;
155
+ /**
156
+ * Record that a HUMAN verified a file, with the evidence they gave.
157
+ *
158
+ * WHY THIS IS NOT `recordDelegatedOutcome`. That function stamps records that already exist, because a
159
+ * verdict is always the second half of a delegation that wrote the file first. A hand-edited module has no
160
+ * such record -- `guard.ts` in this repository is 786 lines of hand-written change with no delegation
161
+ * behind it -- so an attestation has to CREATE a record rather than stamp one.
162
+ *
163
+ * It creates it with `mode: 'created'`, which is the honest description: the architect has never been
164
+ * shown this content by a worker, and the read guard should treat reading it back the way it treats any
165
+ * other file the context did not write. Claiming `patched` would assert a history that did not happen.
166
+ *
167
+ * The hash is read from disk HERE, not supplied by the caller, so an attestation always describes the
168
+ * bytes that were present when it was made. A caller-supplied hash would let an attestation be recorded
169
+ * for content that was never on disk, which is the one thing this verdict must not allow.
170
+ *
171
+ * Returns the record it wrote, or null when the file could not be read -- never a bare count, because the
172
+ * caller needs the hash to show the operator what they just attested.
173
+ */
174
+ export declare function recordOperatorAttestation(path: string, operator: string, evidence: string, at?: number): DelegatedRecord | null;
@@ -0,0 +1,415 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.DELEGATED_PATH_LIMIT = exports.delegatedPaths = void 0;
37
+ exports.sha256File = sha256File;
38
+ exports.resolveContractFiles = resolveContractFiles;
39
+ exports.contractFileHashes = contractFileHashes;
40
+ exports.contractViolations = contractViolations;
41
+ exports.lookupDelegatedRecord = lookupDelegatedRecord;
42
+ exports.resolveDelegatedRegistryPath = resolveDelegatedRegistryPath;
43
+ exports.parseDelegatedRegistry = parseDelegatedRegistry;
44
+ exports.mergeDelegatedRecords = mergeDelegatedRecords;
45
+ exports.pruneDelegatedRecords = pruneDelegatedRecords;
46
+ exports.saveDelegatedRegistry = saveDelegatedRegistry;
47
+ exports.loadDelegatedRegistry = loadDelegatedRegistry;
48
+ exports.rememberDelegated = rememberDelegated;
49
+ exports.recordDelegatedOutcome = recordDelegatedOutcome;
50
+ exports.recordOperatorAttestation = recordOperatorAttestation;
51
+ const fs = __importStar(require("fs"));
52
+ const path = __importStar(require("path"));
53
+ const crypto = __importStar(require("crypto"));
54
+ const logging_1 = require("./logging");
55
+ const paths_1 = require("./paths");
56
+ /**
57
+ * sha256 of a file, or null when it cannot be read. Callers treat null as a failure rather than as
58
+ * absence: a contract file that vanished is a violation, not an empty string.
59
+ */
60
+ function sha256File(filePath) {
61
+ try {
62
+ return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex');
63
+ }
64
+ catch (err) {
65
+ return null;
66
+ }
67
+ }
68
+ /**
69
+ * Resolve the architect's declared contract paths against the workspace. Names only: the architect
70
+ * never supplies contents, and the resolved list is what the worker is forbidden to write.
71
+ */
72
+ function resolveContractFiles(files, baseDir) {
73
+ const resolved = [];
74
+ for (const file of files ?? []) {
75
+ const name = String(file || '').trim();
76
+ if (!name)
77
+ continue;
78
+ const full = path.isAbsolute(name) ? name : path.resolve(baseDir, name);
79
+ if (!resolved.some((seen) => (0, paths_1.canonicalisePath)(seen) === (0, paths_1.canonicalisePath)(full))) {
80
+ resolved.push(full);
81
+ }
82
+ }
83
+ return resolved;
84
+ }
85
+ /** Keyed by canonical path so two spellings of one file cannot pass as two files. */
86
+ function contractFileHashes(paths) {
87
+ const hashes = {};
88
+ for (const p of paths)
89
+ hashes[(0, paths_1.canonicalisePath)(p)] = sha256File(p);
90
+ return hashes;
91
+ }
92
+ /**
93
+ * Anything that changed a declared file during a unit invalidates the verdict, whatever the tests
94
+ * then reported. A missing declaration is reported too, because failing closed is the only safe
95
+ * reading of "the architect declared a contract file that is not there".
96
+ */
97
+ function contractViolations(before, after) {
98
+ const violations = [];
99
+ for (const [key, beforeHash] of Object.entries(before)) {
100
+ const afterHash = Object.prototype.hasOwnProperty.call(after, key) ? after[key] : null;
101
+ if (beforeHash === null) {
102
+ violations.push("'" + key + "' was declared as a contract file but does not exist");
103
+ }
104
+ else if (afterHash === null) {
105
+ violations.push("'" + key + "' was deleted while the unit ran");
106
+ }
107
+ else if (afterHash !== beforeHash) {
108
+ violations.push("'" + key + "' was modified while the unit ran");
109
+ }
110
+ }
111
+ return violations;
112
+ }
113
+ /**
114
+ * The read guard's memory of what a delegated worker wrote, and its bound.
115
+ *
116
+ * It is exported because the guard and the plugin's startup both add to it: a module that owns the
117
+ * registry cannot own the set as well without an accessor for each caller, and the set is the
118
+ * registry's state, not the guard's.
119
+ */
120
+ exports.delegatedPaths = new Set();
121
+ exports.DELEGATED_PATH_LIMIT = 500;
122
+ /**
123
+ * The same files as `delegatedPaths`, carrying the record so the read guard can ask what happened to a
124
+ * file rather than only whether it was delegated at all. `indexDelegated` is its only writer: two indexes
125
+ * updated in two places drift, and a drifted index here reads as "no verdict", which is the permissive
126
+ * answer to the question this index exists to answer.
127
+ *
128
+ * It arms itself on first use because the startup loop that fills `delegatedPaths` lives in `index.ts`,
129
+ * which is too large for the worker to rewrite -- so there is no place there to arm a second index, and a
130
+ * lazily armed one is the difference between correct and silently empty.
131
+ */
132
+ const delegatedRecordIndex = new Map();
133
+ let indexArmed = false;
134
+ /**
135
+ * Refresh both indexes from a set of records. Only whole files the worker produced enter the read guard's
136
+ * index -- a patched file is one the architect was already working on and must keep reading -- while the
137
+ * registry itself remembers either kind.
138
+ */
139
+ function indexDelegated(records) {
140
+ indexArmed = true;
141
+ for (const entry of records) {
142
+ if (entry.mode !== 'created')
143
+ continue;
144
+ const key = (0, paths_1.canonicalisePath)(entry.path);
145
+ exports.delegatedPaths.add(key);
146
+ delegatedRecordIndex.set(key, entry);
147
+ }
148
+ while (exports.delegatedPaths.size > exports.DELEGATED_PATH_LIMIT) {
149
+ const oldest = exports.delegatedPaths.values().next().value;
150
+ if (typeof oldest !== 'string')
151
+ break;
152
+ exports.delegatedPaths.delete(oldest);
153
+ delegatedRecordIndex.delete(oldest);
154
+ }
155
+ }
156
+ /**
157
+ * The record for a delegated file, by canonical path. `undefined` means no verdict covers it, and the
158
+ * guard has to read that as unsettled rather than as a pass.
159
+ */
160
+ function lookupDelegatedRecord(canonicalPath) {
161
+ if (!indexArmed)
162
+ indexDelegated(loadDelegatedRegistry());
163
+ return delegatedRecordIndex.get(canonicalPath);
164
+ }
165
+ const DELEGATED_OUTCOMES = new Set([
166
+ 'UNIT_PASSED',
167
+ 'UNIT_FAILED',
168
+ 'UNIT_UNVERIFIED',
169
+ 'UNIT_FLAKY',
170
+ 'OPERATOR_ATTESTED',
171
+ ]);
172
+ function resolveDelegatedRegistryPath() {
173
+ return path.join((0, logging_1.resolveDataDir)(), 'delegated-registry.json');
174
+ }
175
+ /**
176
+ * Parse a registry file. Anything unreadable, malformed, or entry-shaped-but-wrong yields no records
177
+ * rather than an exception: a corrupt registry must never be able to stop the plugin loading.
178
+ */
179
+ function parseDelegatedRegistry(text) {
180
+ if (typeof text !== 'string' || !text.trim())
181
+ return [];
182
+ let parsed;
183
+ try {
184
+ parsed = JSON.parse(text);
185
+ }
186
+ catch (err) {
187
+ return [];
188
+ }
189
+ const raw = parsed && Array.isArray(parsed.records) ? parsed.records : [];
190
+ const records = [];
191
+ for (const entry of raw) {
192
+ if (!entry || typeof entry !== 'object')
193
+ continue;
194
+ const entryPath = String(entry.path ?? '').trim();
195
+ if (!entryPath)
196
+ continue;
197
+ const record = {
198
+ path: entryPath,
199
+ sha256: typeof entry.sha256 === 'string' && entry.sha256 ? entry.sha256 : null,
200
+ at: Number.isFinite(Number(entry.at)) ? Number(entry.at) : 0,
201
+ // Records written before this field existed predate the distinction, and the conservative reading
202
+ // of an old record is the one that protects more: treat it as created.
203
+ mode: entry.mode === 'patched' ? 'patched' : 'created',
204
+ };
205
+ // An unrecognised verdict is dropped rather than carried: the absence of the field means "no verdict",
206
+ // and a value this code cannot read must never be able to relax a guard by passing for a success.
207
+ if (DELEGATED_OUTCOMES.has(String(entry.outcome))) {
208
+ record.outcome = entry.outcome;
209
+ record.succeeded = entry.succeeded === true;
210
+ if (Number.isFinite(Number(entry.verdictAt)))
211
+ record.verdictAt = Number(entry.verdictAt);
212
+ }
213
+ // The attestation survives the round trip, because an attestation without its operator and evidence is
214
+ // not an audit record -- and this parser is where a field can be silently dropped, which is how a
215
+ // written verdict becomes an absence. Both fields are required for it to be carried: a record claiming
216
+ // OPERATOR_ATTESTED with nobody named is exactly the unattributable stamp this verdict exists to avoid.
217
+ const attestation = entry.attestation;
218
+ if (attestation && typeof attestation === 'object') {
219
+ const operator = String(attestation.operator ?? '').trim();
220
+ const evidence = String(attestation.evidence ?? '').trim();
221
+ if (operator && evidence) {
222
+ record.attestation = {
223
+ operator,
224
+ evidence,
225
+ at: Number.isFinite(Number(attestation.at)) ? Number(attestation.at) : 0,
226
+ };
227
+ }
228
+ else {
229
+ // An unattributable attestation is not carried, and the verdict goes with it: keeping the verdict
230
+ // while dropping the attribution would leave a record that reads as human-verified with nobody
231
+ // accountable for it.
232
+ delete record.outcome;
233
+ delete record.succeeded;
234
+ }
235
+ }
236
+ records.push(record);
237
+ }
238
+ return records;
239
+ }
240
+ /**
241
+ * Newest wins per path, and the oldest fall off the end once the limit is reached. The hash is not
242
+ * used to relax anything — a delegated file stays protected however it later changes — it makes the
243
+ * record answerable, and gives the prune below something to reason about.
244
+ */
245
+ function mergeDelegatedRecords(existing, incoming, limit = exports.DELEGATED_PATH_LIMIT) {
246
+ // Dedup on the canonical form, so two spellings of one file cannot become two records, while the
247
+ // record itself keeps the path as it was written.
248
+ const byPath = new Map();
249
+ for (const entry of [...(existing ?? []), ...(incoming ?? [])]) {
250
+ if (!entry || !entry.path)
251
+ continue;
252
+ const key = (0, paths_1.canonicalisePath)(entry.path);
253
+ const prior = byPath.get(key);
254
+ if (!prior || entry.at >= prior.at)
255
+ byPath.set(key, entry);
256
+ }
257
+ return [...byPath.values()].sort((a, b) => b.at - a.at).slice(0, Math.max(1, limit));
258
+ }
259
+ /** A path whose file is gone protects nothing, so it is dropped. */
260
+ function pruneDelegatedRecords(records, exists = (p) => fs.existsSync(p)) {
261
+ return (records ?? []).filter((r) => r && r.path && exists(r.path));
262
+ }
263
+ /** Best effort by design: failing to persist must not fail a delegation that was already paid for. */
264
+ function saveDelegatedRegistry(records) {
265
+ const target = resolveDelegatedRegistryPath();
266
+ try {
267
+ fs.mkdirSync(path.dirname(target), { recursive: true });
268
+ const temp = target + '.tmp';
269
+ fs.writeFileSync(temp, JSON.stringify({ version: 1, records }, null, 2), 'utf8');
270
+ fs.renameSync(temp, target);
271
+ return true;
272
+ }
273
+ catch (err) {
274
+ console.warn('[LOCAL_GUARD] could not persist the delegated registry:', err?.message || err);
275
+ return false;
276
+ }
277
+ }
278
+ /** Load, validate and prune. Called at startup so a restart does not forget what was delegated. */
279
+ function loadDelegatedRegistry() {
280
+ let text = '';
281
+ try {
282
+ text = fs.readFileSync(resolveDelegatedRegistryPath(), 'utf8');
283
+ }
284
+ catch (err) {
285
+ return [];
286
+ }
287
+ return pruneDelegatedRecords(parseDelegatedRegistry(text));
288
+ }
289
+ function rememberDelegated(paths, mode = 'created') {
290
+ const added = [];
291
+ for (const p of paths) {
292
+ if (typeof p !== 'string' || !p)
293
+ continue;
294
+ // The record keeps the path as it was written, so it stays an honest answer to "where did this
295
+ // go?". Canonicalisation resolves symlinks — `os.tmpdir()` on Windows is a junction — so it
296
+ // belongs in the index and the comparisons, not in the record.
297
+ const resolved = path.resolve(p);
298
+ added.push({ path: resolved, sha256: sha256File(resolved), at: Date.now(), mode });
299
+ }
300
+ if (added.length === 0)
301
+ return;
302
+ const merged = mergeDelegatedRecords(loadDelegatedRegistry(), added);
303
+ saveDelegatedRegistry(merged);
304
+ indexDelegated(merged);
305
+ }
306
+ /**
307
+ * Stamp a unit's verdict onto the registry records for the files that unit wrote.
308
+ *
309
+ * A delegation writes the registry before its verification has run, so the record it creates cannot
310
+ * carry the verdict. This is the second half of that write, and the only thing that joins a per-file
311
+ * sha256 to a per-unit verdict -- without it "is this file settled?" has no answer, and the read guard
312
+ * has to ask about every delegated file for ever.
313
+ *
314
+ * Best effort, for the same reason the first half is: failing to persist must not fail a delegation that
315
+ * was already paid for, and an absent stamp reads as "no verdict", which is the conservative answer
316
+ * rather than a permissive one. Returns how many records were stamped, so a caller can tell a no-op
317
+ * from a write.
318
+ */
319
+ function recordDelegatedOutcome(paths, outcome, succeeded, at = Date.now()) {
320
+ if (!Array.isArray(paths) || paths.length === 0)
321
+ return 0;
322
+ if (!DELEGATED_OUTCOMES.has(String(outcome)))
323
+ return 0;
324
+ // Canonical on both sides. The registry holds absolute paths, so unlike the read guard -- which has to
325
+ // infer a workspace before a relative target means anything -- there is no base here to get wrong.
326
+ const wanted = new Set();
327
+ for (const p of paths) {
328
+ if (typeof p === 'string' && p)
329
+ wanted.add((0, paths_1.canonicalisePath)(p));
330
+ }
331
+ if (wanted.size === 0)
332
+ return 0;
333
+ const records = loadDelegatedRegistry();
334
+ let stamped = 0;
335
+ for (const record of records) {
336
+ if (!wanted.has((0, paths_1.canonicalisePath)(record.path)))
337
+ continue;
338
+ record.outcome = outcome;
339
+ record.succeeded = succeeded === true;
340
+ record.verdictAt = at;
341
+ stamped += 1;
342
+ }
343
+ if (stamped > 0) {
344
+ saveDelegatedRegistry(records);
345
+ // The index still holds these records as they were before the stamp, so it has to be refreshed or the
346
+ // guard would keep reading "no verdict" for a file whose verdict has just been recorded.
347
+ indexDelegated(records);
348
+ }
349
+ return stamped;
350
+ }
351
+ /**
352
+ * Record that a HUMAN verified a file, with the evidence they gave.
353
+ *
354
+ * WHY THIS IS NOT `recordDelegatedOutcome`. That function stamps records that already exist, because a
355
+ * verdict is always the second half of a delegation that wrote the file first. A hand-edited module has no
356
+ * such record -- `guard.ts` in this repository is 786 lines of hand-written change with no delegation
357
+ * behind it -- so an attestation has to CREATE a record rather than stamp one.
358
+ *
359
+ * It creates it with `mode: 'created'`, which is the honest description: the architect has never been
360
+ * shown this content by a worker, and the read guard should treat reading it back the way it treats any
361
+ * other file the context did not write. Claiming `patched` would assert a history that did not happen.
362
+ *
363
+ * The hash is read from disk HERE, not supplied by the caller, so an attestation always describes the
364
+ * bytes that were present when it was made. A caller-supplied hash would let an attestation be recorded
365
+ * for content that was never on disk, which is the one thing this verdict must not allow.
366
+ *
367
+ * Returns the record it wrote, or null when the file could not be read -- never a bare count, because the
368
+ * caller needs the hash to show the operator what they just attested.
369
+ */
370
+ function recordOperatorAttestation(path, operator, evidence, at = Date.now()) {
371
+ const target = typeof path === 'string' ? path.trim() : '';
372
+ const who = typeof operator === 'string' ? operator.trim() : '';
373
+ const why = typeof evidence === 'string' ? evidence.trim() : '';
374
+ // Attribution and evidence are both required. An attestation with neither is not a weaker attestation,
375
+ // it is an anonymous stamp, and the registry is better off without one.
376
+ if (!target || !who || !why)
377
+ return null;
378
+ const hash = sha256File(target);
379
+ if (!hash)
380
+ return null;
381
+ const canonical = (0, paths_1.canonicalisePath)(target);
382
+ const records = loadDelegatedRegistry();
383
+ const existing = records.find((record) => (0, paths_1.canonicalisePath)(record.path) === canonical);
384
+ const record = existing ?? { path: canonical, sha256: hash, at, mode: 'created' };
385
+ record.sha256 = hash;
386
+ record.outcome = 'OPERATOR_ATTESTED';
387
+ // Deliberately NOT `succeeded: true`. Success is a claim that a contract was met, and no contract ran
388
+ // here. A consumer that wants to promote an attested file must check the verdict, not a boolean whose
389
+ // meaning would otherwise quietly widen to include "a human looked at it".
390
+ record.succeeded = false;
391
+ record.verdictAt = at;
392
+ record.attestation = { operator: who, evidence: why, at };
393
+ if (!existing)
394
+ records.push(record);
395
+ // A persist that did not happen must not be reported as one. `saveDelegatedRegistry` returns a boolean
396
+ // rather than throwing so its other callers can decide, and the first version of this function ignored
397
+ // that return -- so the very first attestation attempt printed a hash and an operator while the registry
398
+ // was untouched, the write having failed with EPERM inside the persist path. That is the defect this
399
+ // repository has now caught repeatedly: a green result over an operation that did not occur.
400
+ //
401
+ // Throwing is the right response here and not for `recordDelegatedOutcome`, because the two callers face
402
+ // opposite risks. A delegation that fails to record a verdict leaves the file UNSETTLED, which is the
403
+ // fail-closed direction -- an absent verdict reads as "nothing is proven". An attestation that fails to
404
+ // record leaves the operator believing a human-verified claim is on file when nothing is, and the whole
405
+ // point of the verdict is that somebody can be named for it.
406
+ if (!saveDelegatedRegistry(records)) {
407
+ throw new Error('Could not persist the operator attestation for ' +
408
+ canonical +
409
+ '. The registry at ' +
410
+ resolveDelegatedRegistryPath() +
411
+ ' was not written, so nothing was attested.');
412
+ }
413
+ indexDelegated(records);
414
+ return record;
415
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * The wiring for declarations-mode egress control, kept out of `index.ts` on purpose.
3
+ *
4
+ * WHY THIS MODULE EXISTS. The control was first written inline in `index.ts`'s
5
+ * `tools/post-execute` handler, and it only ever inspected READ_TOOLS -- so a search or a shell command
6
+ * returned raw implementation bodies to the architect with nothing looking at them. Extending the
7
+ * inline handler meant a large, multi-region edit in the plugin's largest file. Extracting the decision
8
+ * to here makes the call site two lines and leaves the classification, the argument extraction and the
9
+ * dispatch testable without a host.
10
+ *
11
+ * WHAT IT DELIBERATELY DOES NOT DO: it does not serve declarations for READS. `index.ts` already does
12
+ * that, and its read branch does two things this module must not bypass --
13
+ *
14
+ * 1. it refuses a STALE declaration (`staleDeclarationReason`), so the architect cannot be served
15
+ * yesterday's signature while believing it is current, and
16
+ * 2. it prefixes the served text with the "SERVED AS TYPE DECLARATIONS, NOT SOURCE" banner that
17
+ * `egress-guard.test.cjs` asserts on.
18
+ *
19
+ * So a read returns null here and falls through to the path that is already covered by tests. Only the
20
+ * two routes that can act on nothing except by refusing -- search and shell -- are handled below.
21
+ */
22
+ export interface PostExecuteExec {
23
+ name?: string;
24
+ arguments?: Record<string, unknown>;
25
+ }
26
+ export interface PostExecuteOptions {
27
+ sourceReadEgress?: string;
28
+ declarationRoot?: string;
29
+ }
30
+ export interface EgressHookResult {
31
+ kind: 'accept';
32
+ content: Array<{
33
+ type: 'text';
34
+ text: string;
35
+ }>;
36
+ }
37
+ /**
38
+ * Decide whether this tool call may return its output under declarations mode.
39
+ *
40
+ * Returns a replacement decision when the call must be refused, or null when it may proceed. A null is
41
+ * the caller's signal to keep going, which is why every pass-through path returns null rather than a
42
+ * decision: the handler's contract is (exec, result, next) => decision, and a decision invented here
43
+ * would replace the tool's real output with nothing.
44
+ */
45
+ export declare function handleDeclarationPostExecute(exec: PostExecuteExec, options?: PostExecuteOptions): EgressHookResult | null;