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.
- package/LICENSE +21 -0
- package/README.md +920 -2
- package/cordis.patch.example.yml +48 -0
- package/cordis.patch.yml +13 -0
- package/dist/attestation.d.ts +25 -0
- package/dist/attestation.js +44 -0
- package/dist/containment.d.ts +33 -0
- package/dist/containment.js +63 -0
- package/dist/context-quality.d.ts +79 -0
- package/dist/context-quality.js +181 -0
- package/dist/context.d.ts +39 -0
- package/dist/context.js +136 -0
- package/dist/contracts.d.ts +174 -0
- package/dist/contracts.js +415 -0
- package/dist/declaration-egress-hook.d.ts +45 -0
- package/dist/declaration-egress-hook.js +125 -0
- package/dist/declaration-egress.d.ts +37 -0
- package/dist/declaration-egress.js +169 -0
- package/dist/delegation.d.ts +339 -0
- package/dist/delegation.js +933 -0
- package/dist/emission.d.ts +70 -0
- package/dist/emission.js +310 -0
- package/dist/guard.d.ts +162 -0
- package/dist/guard.js +873 -0
- package/dist/host-events.d.ts +15 -0
- package/dist/host-events.js +27 -0
- package/dist/index.d.ts +560 -0
- package/dist/index.js +1398 -0
- package/dist/local-classifier.d.ts +52 -0
- package/dist/local-classifier.js +159 -0
- package/dist/logging.d.ts +21 -0
- package/dist/logging.js +90 -0
- package/dist/paths.d.ts +10 -0
- package/dist/paths.js +83 -0
- package/dist/profiles.d.ts +35 -0
- package/dist/profiles.js +95 -0
- package/dist/retry-context.d.ts +52 -0
- package/dist/retry-context.js +97 -0
- package/dist/roles.d.ts +129 -0
- package/dist/roles.js +363 -0
- package/dist/routing.d.ts +35 -0
- package/dist/routing.js +88 -0
- package/dist/savings-tracker.d.ts +128 -0
- package/dist/savings-tracker.js +340 -0
- package/dist/session-events.d.ts +17 -0
- package/dist/session-events.js +31 -0
- package/dist/verification.d.ts +104 -0
- package/dist/verification.js +560 -0
- 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;
|