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,70 @@
|
|
|
1
|
+
export interface FileEmissionResult {
|
|
2
|
+
path: string;
|
|
3
|
+
relativeName: string;
|
|
4
|
+
lines: number;
|
|
5
|
+
bytes: number;
|
|
6
|
+
/** How the change arrived: a whole file, or a delta against the file already there. */
|
|
7
|
+
mode?: 'write' | 'patch';
|
|
8
|
+
/** Search/replace blocks applied, when the emission was a patch. */
|
|
9
|
+
hunks?: number;
|
|
10
|
+
}
|
|
11
|
+
/** A delta block: the bytes to find, and what to put there instead. */
|
|
12
|
+
export interface SearchReplaceBlock {
|
|
13
|
+
search: string;
|
|
14
|
+
replace: string;
|
|
15
|
+
}
|
|
16
|
+
/** Called once by the composition root, which holds both halves of the delta machinery. */
|
|
17
|
+
export declare function configurePatchEngine(engine: {
|
|
18
|
+
parse: (body: string) => SearchReplaceBlock[] | null;
|
|
19
|
+
apply: (content: string, blocks: SearchReplaceBlock[]) => {
|
|
20
|
+
ok: boolean;
|
|
21
|
+
content?: string;
|
|
22
|
+
reason?: string;
|
|
23
|
+
};
|
|
24
|
+
}): void;
|
|
25
|
+
/**
|
|
26
|
+
* The containment decision for one delegated write. `baseDir` is the session workspace
|
|
27
|
+
* and `allowedRoots` is the operator's explicit extension list. Both sides are
|
|
28
|
+
* canonicalised, so a symlink inside the workspace cannot be used to escape it.
|
|
29
|
+
*/
|
|
30
|
+
export declare function evaluateEmissionPath(resolvedPath: string, baseDir: string, allowedRoots?: string[]): {
|
|
31
|
+
allowed: boolean;
|
|
32
|
+
reason?: string;
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* The scope decision for one delegated write: is this path one the unit declared?
|
|
36
|
+
*
|
|
37
|
+
* `targetFiles` used to be entirely passive — it became prompt text and it chose a fallback path when a
|
|
38
|
+
* fenced block named no file of its own — so a unit told to change one file could rewrite another, and
|
|
39
|
+
* nothing refused it, reported it, or noticed. That is the precondition for two units disagreeing, and it
|
|
40
|
+
* lands strictly before any project-level check could see it.
|
|
41
|
+
*
|
|
42
|
+
* A declared directory covers its subtree, because `isPathWithin` counts a path as within itself or
|
|
43
|
+
* beneath it. That makes `src`, `src/` and an exact file name all usable declarations, with one rule.
|
|
44
|
+
*
|
|
45
|
+
* An empty or absent declaration constrains nothing. A unit that declared no targets has not exceeded
|
|
46
|
+
* them, and refusing everything for an empty list would break every caller that never set the field.
|
|
47
|
+
*/
|
|
48
|
+
export declare function evaluateUnitScope(resolvedPath: string, declaredTargets: string[] | string | undefined, baseDir: string): {
|
|
49
|
+
allowed: boolean;
|
|
50
|
+
reason?: string;
|
|
51
|
+
};
|
|
52
|
+
/**
|
|
53
|
+
* Parse the model's final content into file emissions and write them to disk.
|
|
54
|
+
*
|
|
55
|
+
* The model may emit either whole files (fenced code blocks with a file path in the
|
|
56
|
+
* header) or search/replace patch blocks. This function handles both, writes the
|
|
57
|
+
* resulting files under the workspace root, and returns a per-file summary plus any
|
|
58
|
+
* errors encountered.
|
|
59
|
+
*
|
|
60
|
+
* `content` is the raw model output. `workspaceRoot` is the directory all emissions
|
|
61
|
+
* are anchored to. `parseSearchReplaceBlocks` and `applySearchReplaceBlocks` are the
|
|
62
|
+
* delta machinery, injected so this module does not import the composition root back.
|
|
63
|
+
*/
|
|
64
|
+
export declare function extractAndEmitFiles(content: string, targetFilesHint?: string[] | string, baseDir?: string, allowedRoots?: string[], protectedPaths?: string[], options?: {
|
|
65
|
+
enforceUnitScope?: boolean;
|
|
66
|
+
}): {
|
|
67
|
+
filesWritten: FileEmissionResult[];
|
|
68
|
+
errors: string[];
|
|
69
|
+
cleanContent: string;
|
|
70
|
+
};
|
package/dist/emission.js
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
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.configurePatchEngine = configurePatchEngine;
|
|
37
|
+
exports.evaluateEmissionPath = evaluateEmissionPath;
|
|
38
|
+
exports.evaluateUnitScope = evaluateUnitScope;
|
|
39
|
+
exports.extractAndEmitFiles = extractAndEmitFiles;
|
|
40
|
+
const fs = __importStar(require("fs"));
|
|
41
|
+
const path = __importStar(require("path"));
|
|
42
|
+
const paths_1 = require("./paths");
|
|
43
|
+
/**
|
|
44
|
+
* The delta machinery lives in `delegation.ts`, which imports this module rather than the other way
|
|
45
|
+
* round, so this module cannot import it. It is configured from the composition root instead.
|
|
46
|
+
*
|
|
47
|
+
* Both seams are closed by default: an unconfigured emission treats every fenced body as a whole file,
|
|
48
|
+
* which is the pre-delta behaviour, and `applyPatch` refuses instead of guessing.
|
|
49
|
+
*/
|
|
50
|
+
let parsePatchBody = () => null;
|
|
51
|
+
let applyPatch = () => ({
|
|
52
|
+
ok: false,
|
|
53
|
+
reason: 'the search/replace engine is not configured, so no patch can be applied',
|
|
54
|
+
});
|
|
55
|
+
/** Called once by the composition root, which holds both halves of the delta machinery. */
|
|
56
|
+
function configurePatchEngine(engine) {
|
|
57
|
+
parsePatchBody = engine.parse;
|
|
58
|
+
applyPatch = engine.apply;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* The containment decision for one delegated write. `baseDir` is the session workspace
|
|
62
|
+
* and `allowedRoots` is the operator's explicit extension list. Both sides are
|
|
63
|
+
* canonicalised, so a symlink inside the workspace cannot be used to escape it.
|
|
64
|
+
*/
|
|
65
|
+
function evaluateEmissionPath(resolvedPath, baseDir, allowedRoots = []) {
|
|
66
|
+
const canonical = (0, paths_1.canonicalisePath)(resolvedPath);
|
|
67
|
+
const roots = [baseDir, ...allowedRoots].filter((root) => typeof root === 'string' && root.trim().length > 0);
|
|
68
|
+
for (const root of roots) {
|
|
69
|
+
if ((0, paths_1.isPathWithin)((0, paths_1.canonicalisePath)(root), canonical))
|
|
70
|
+
return { allowed: true };
|
|
71
|
+
}
|
|
72
|
+
return {
|
|
73
|
+
allowed: false,
|
|
74
|
+
reason: `Refused to write '${resolvedPath}': it resolves to '${canonical}', which is outside the session ` +
|
|
75
|
+
`workspace '${path.resolve(baseDir)}'` +
|
|
76
|
+
(allowedRoots.length > 0 ? ` and every configured emitAllowlist root` : '') +
|
|
77
|
+
`. A delegated worker may only write inside its workspace; add the directory to emitAllowlist to permit it.`,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
/** The declared targets, in the shapes the tool accepts, with blanks dropped. */
|
|
81
|
+
function normaliseTargets(targetFilesHint) {
|
|
82
|
+
if (Array.isArray(targetFilesHint)) {
|
|
83
|
+
return targetFilesHint.map((t) => String(t ?? '')).filter((t) => t.trim().length > 0);
|
|
84
|
+
}
|
|
85
|
+
if (typeof targetFilesHint === 'string' && targetFilesHint.trim())
|
|
86
|
+
return [targetFilesHint.trim()];
|
|
87
|
+
return [];
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* The scope decision for one delegated write: is this path one the unit declared?
|
|
91
|
+
*
|
|
92
|
+
* `targetFiles` used to be entirely passive — it became prompt text and it chose a fallback path when a
|
|
93
|
+
* fenced block named no file of its own — so a unit told to change one file could rewrite another, and
|
|
94
|
+
* nothing refused it, reported it, or noticed. That is the precondition for two units disagreeing, and it
|
|
95
|
+
* lands strictly before any project-level check could see it.
|
|
96
|
+
*
|
|
97
|
+
* A declared directory covers its subtree, because `isPathWithin` counts a path as within itself or
|
|
98
|
+
* beneath it. That makes `src`, `src/` and an exact file name all usable declarations, with one rule.
|
|
99
|
+
*
|
|
100
|
+
* An empty or absent declaration constrains nothing. A unit that declared no targets has not exceeded
|
|
101
|
+
* them, and refusing everything for an empty list would break every caller that never set the field.
|
|
102
|
+
*/
|
|
103
|
+
function evaluateUnitScope(resolvedPath, declaredTargets, baseDir) {
|
|
104
|
+
const declared = normaliseTargets(declaredTargets);
|
|
105
|
+
if (declared.length === 0)
|
|
106
|
+
return { allowed: true };
|
|
107
|
+
const canonical = (0, paths_1.canonicalisePath)(resolvedPath);
|
|
108
|
+
for (const target of declared) {
|
|
109
|
+
const resolvedTarget = path.isAbsolute(target) ? target : path.resolve(baseDir, target);
|
|
110
|
+
if ((0, paths_1.isPathWithin)((0, paths_1.canonicalisePath)(resolvedTarget), canonical))
|
|
111
|
+
return { allowed: true };
|
|
112
|
+
}
|
|
113
|
+
return {
|
|
114
|
+
allowed: false,
|
|
115
|
+
reason: `Refused to write '${resolvedPath}': this unit declared its targets, and that path is not one of ` +
|
|
116
|
+
`them (${declared.join(', ')}). A unit that writes outside what it declared is how two units come ` +
|
|
117
|
+
`to disagree. Re-delegate with the file declared if the change is intended, or set unitScope: ` +
|
|
118
|
+
`'off' to allow writes anywhere in the workspace.`,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Parse the model's final content into file emissions and write them to disk.
|
|
123
|
+
*
|
|
124
|
+
* The model may emit either whole files (fenced code blocks with a file path in the
|
|
125
|
+
* header) or search/replace patch blocks. This function handles both, writes the
|
|
126
|
+
* resulting files under the workspace root, and returns a per-file summary plus any
|
|
127
|
+
* errors encountered.
|
|
128
|
+
*
|
|
129
|
+
* `content` is the raw model output. `workspaceRoot` is the directory all emissions
|
|
130
|
+
* are anchored to. `parseSearchReplaceBlocks` and `applySearchReplaceBlocks` are the
|
|
131
|
+
* delta machinery, injected so this module does not import the composition root back.
|
|
132
|
+
*/
|
|
133
|
+
function extractAndEmitFiles(content, targetFilesHint, baseDir = process.cwd(), allowedRoots = [], protectedPaths = [], options = {}) {
|
|
134
|
+
if (!content)
|
|
135
|
+
return { filesWritten: [], errors: [], cleanContent: '' };
|
|
136
|
+
// Declared targets are a boundary unless a caller says otherwise. `false` restores the behaviour before
|
|
137
|
+
// that boundary existed, which is what `unitScope: 'off'` is for; anything else, including absent, means
|
|
138
|
+
// enforce.
|
|
139
|
+
const enforceUnitScope = options.enforceUnitScope !== false;
|
|
140
|
+
const filesWritten = [];
|
|
141
|
+
const emissionErrors = [];
|
|
142
|
+
const seenPaths = new Set();
|
|
143
|
+
function emitFile(filePath, fileCode) {
|
|
144
|
+
if (!filePath || !fileCode)
|
|
145
|
+
return;
|
|
146
|
+
const cleanPath = filePath.trim().replace(/^["']|["']$/g, '');
|
|
147
|
+
const resolvedPath = path.isAbsolute(cleanPath) ? cleanPath : path.resolve(baseDir, cleanPath);
|
|
148
|
+
// Containment first: the worker's fence header and the caller's targetFiles hints
|
|
149
|
+
// both choose this path, so it is untrusted input. Absolute paths and `..` segments
|
|
150
|
+
// used to escape the workspace silently; they are now refused and reported.
|
|
151
|
+
const containment = evaluateEmissionPath(resolvedPath, baseDir, allowedRoots);
|
|
152
|
+
if (!containment.allowed) {
|
|
153
|
+
emissionErrors.push(String(containment.reason));
|
|
154
|
+
console.warn(`[EMIT_FILE_BLOCKED] ${containment.reason}`);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
// Contract files belong to the architect. This is checked before anything is written, and it
|
|
158
|
+
// covers every emission route -- the fenced header, the `// FILE:` marker, and the fallback --
|
|
159
|
+
// because they all funnel through here.
|
|
160
|
+
const protectedHit = protectedPaths.find((p) => (0, paths_1.canonicalisePath)(String(p)) === (0, paths_1.canonicalisePath)(resolvedPath));
|
|
161
|
+
if (protectedHit) {
|
|
162
|
+
emissionErrors.push(`Refused to write ${resolvedPath}: it is a contract file declared by the architect, and the ` +
|
|
163
|
+
`executor may not modify the test that judges it.`);
|
|
164
|
+
console.warn(`[EMIT_FILE_BLOCKED] contract file: ${resolvedPath}`);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
// Scope third, and after the contract check deliberately: a contract file has to be refused for being
|
|
168
|
+
// a contract rather than for being undeclared, or the reason the operator reads would name the wrong
|
|
169
|
+
// rule.
|
|
170
|
+
if (enforceUnitScope) {
|
|
171
|
+
const scope = evaluateUnitScope(resolvedPath, targetFilesHint, baseDir);
|
|
172
|
+
if (!scope.allowed) {
|
|
173
|
+
emissionErrors.push(String(scope.reason));
|
|
174
|
+
console.warn(`[EMIT_FILE_SCOPE_BLOCKED] ${scope.reason}`);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
if (seenPaths.has(resolvedPath))
|
|
179
|
+
return;
|
|
180
|
+
seenPaths.add(resolvedPath);
|
|
181
|
+
// A search/replace body is a delta against an existing file rather than a replacement for it. The
|
|
182
|
+
// header syntax is shared, so the body decides the mode. An empty list means the body started a
|
|
183
|
+
// patch and never finished it, which is refused rather than written over a real file.
|
|
184
|
+
const patchBlocks = parsePatchBody(fileCode);
|
|
185
|
+
if (!patchBlocks && fs.existsSync(resolvedPath)) {
|
|
186
|
+
// This is an edit boundary, not a heuristic. A whole-file emission creates rather than modifies, and the
|
|
187
|
+
// worker has no repository read, so it cannot have seen this file unless the architect injected it; a file
|
|
188
|
+
// it has not seen can only be replaced blindly. That is how the contract-first experiment's mutation step
|
|
189
|
+
// returned a rewritten 86-line module in place of a 129-line one and the write landed, reported as nothing
|
|
190
|
+
// more than a failing contract. The size heuristic that used to guard this compared byte counts, and a
|
|
191
|
+
// substitution that keeps most of the bytes passes that regardless of how much of the file actually changed.
|
|
192
|
+
// A patch is not a restriction on what can be expressed, since the whole file can still be replaced with
|
|
193
|
+
// its entire content as the search text, only on what can be done silently.
|
|
194
|
+
emissionErrors.push('Refused to overwrite ' + resolvedPath + ': ' +
|
|
195
|
+
'the file already exists and a whole-file emission may create a file but not modify one; ' +
|
|
196
|
+
'the worker cannot see the file unless its content was injected, so the architect should re-delegate ' +
|
|
197
|
+
'with the content as context and have the worker emit a search/replace patch instead');
|
|
198
|
+
console.warn('[EMIT_FILE_BLOCKED] existing file, whole-file emission: ' + resolvedPath);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
if (patchBlocks) {
|
|
202
|
+
if (!fs.existsSync(resolvedPath)) {
|
|
203
|
+
emissionErrors.push(`Refused to patch ${resolvedPath}: it does not exist, and a search/replace block edits a ` +
|
|
204
|
+
`file rather than creating one.`);
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
let original;
|
|
208
|
+
try {
|
|
209
|
+
original = fs.readFileSync(resolvedPath, 'utf8');
|
|
210
|
+
}
|
|
211
|
+
catch (err) {
|
|
212
|
+
emissionErrors.push(`Failed to read ${resolvedPath} for patching: ${err?.message || String(err)}`);
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
const applied = applyPatch(original, patchBlocks);
|
|
216
|
+
if (!applied.ok) {
|
|
217
|
+
emissionErrors.push(`Refused to patch ${resolvedPath}: ${applied.reason}`);
|
|
218
|
+
console.warn(`[EMIT_PATCH_BLOCKED] ${resolvedPath}: ${applied.reason}`);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
fileCode = applied.content;
|
|
222
|
+
}
|
|
223
|
+
try {
|
|
224
|
+
const parentDir = path.dirname(resolvedPath);
|
|
225
|
+
fs.mkdirSync(parentDir, { recursive: true });
|
|
226
|
+
fs.writeFileSync(resolvedPath, fileCode, 'utf8');
|
|
227
|
+
const lines = fileCode.split('\n').length;
|
|
228
|
+
const bytes = Buffer.byteLength(fileCode, 'utf8');
|
|
229
|
+
const relativeName = path.relative(baseDir, resolvedPath) || cleanPath;
|
|
230
|
+
filesWritten.push({
|
|
231
|
+
path: resolvedPath,
|
|
232
|
+
relativeName,
|
|
233
|
+
lines,
|
|
234
|
+
bytes,
|
|
235
|
+
mode: patchBlocks ? 'patch' : 'write',
|
|
236
|
+
...(patchBlocks ? { hunks: patchBlocks.length } : {}),
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
catch (err) {
|
|
240
|
+
const message = 'Failed to write ' + resolvedPath + ': ' + (err?.message || String(err));
|
|
241
|
+
emissionErrors.push(message);
|
|
242
|
+
console.warn('[EMIT_FILE_ERROR] ' + message);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
const FENCE = '\x60\x60\x60';
|
|
246
|
+
// The closing fence is matched only where a fence can legally close: nothing follows it on its line,
|
|
247
|
+
// and nothing but whitespace precedes it. A non-greedy scan to the next bare run of backticks stops
|
|
248
|
+
// at the first one *anywhere*, so a body that itself contains a fence -- a patch quoting one, or a
|
|
249
|
+
// markdown example inside a source file -- had its tail silently dropped and was then refused as a
|
|
250
|
+
// malformed patch. The comment is kept fence-free so this line stays patchable by a confined agent.
|
|
251
|
+
// The fallback at the bottom is for output that named no file of its own. It must not run when a file
|
|
252
|
+
// WAS named and then refused: writing that content at the declared hint instead would put bytes meant
|
|
253
|
+
// for one path at another, and it would walk straight around a containment, contract or scope refusal
|
|
254
|
+
// by simply retrying somewhere the check happens to allow.
|
|
255
|
+
let namedTargetSeen = false;
|
|
256
|
+
const fileAttrRegex = new RegExp(FENCE + '[a-zA-Z0-9_-]*\\s+(?:file|filename)=["\']?([^"\'\\s\\n>]+)["\']?\\s*\\n([\\s\\S]*?)[ \\t]*' + FENCE + '[ \\t]*(?:\\r?\\n|$)', 'gi');
|
|
257
|
+
let match;
|
|
258
|
+
while ((match = fileAttrRegex.exec(content)) !== null) {
|
|
259
|
+
namedTargetSeen = true;
|
|
260
|
+
emitFile(match[1], match[2]);
|
|
261
|
+
}
|
|
262
|
+
const fileMarkerRegex = new RegExp(FENCE + '[a-zA-Z0-9_-]*\\n(?://\\s*FILE:\\s*|#\\s*FILE:\\s*|/\\*\\s*FILE:\\s*|\\[FILE:\\s*)([^\\s\\n\\*\\]]+)(?:\\s*\\*/|\\])?\\n([\\s\\S]*?)' + FENCE, 'gi');
|
|
263
|
+
while ((match = fileMarkerRegex.exec(content)) !== null) {
|
|
264
|
+
namedTargetSeen = true;
|
|
265
|
+
emitFile(match[1], match[2]);
|
|
266
|
+
}
|
|
267
|
+
if (!namedTargetSeen && filesWritten.length === 0 && targetFilesHint) {
|
|
268
|
+
const hints = Array.isArray(targetFilesHint)
|
|
269
|
+
? targetFilesHint
|
|
270
|
+
: typeof targetFilesHint === 'string'
|
|
271
|
+
? [targetFilesHint]
|
|
272
|
+
: [];
|
|
273
|
+
const allCodeBlocks = [];
|
|
274
|
+
const FENCE = '\x60\x60\x60';
|
|
275
|
+
const genericCodeBlockRegex = new RegExp(FENCE + '[a-zA-Z0-9_-]*\\n([\\s\\S]*?)' + FENCE, 'gi');
|
|
276
|
+
let cbMatch;
|
|
277
|
+
while ((cbMatch = genericCodeBlockRegex.exec(content)) !== null) {
|
|
278
|
+
if (cbMatch[1].trim()) {
|
|
279
|
+
allCodeBlocks.push(cbMatch[1]);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
if (allCodeBlocks.length > 0) {
|
|
283
|
+
for (let i = 0; i < hints.length; i++) {
|
|
284
|
+
const hintPath = hints[i];
|
|
285
|
+
const code = allCodeBlocks[i] || allCodeBlocks[0];
|
|
286
|
+
if (hintPath && code) {
|
|
287
|
+
emitFile(hintPath, code);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
else if (content.trim() && hints.length > 0) {
|
|
292
|
+
// Only write unreferenced output when it actually looks like source code.
|
|
293
|
+
// A worker that cannot read the target file may answer with prose or a
|
|
294
|
+
// tool-call transcript; writing that over a real file destroys it.
|
|
295
|
+
const body = content.trim();
|
|
296
|
+
const toolCall = '<' + 'tool_call|<function=|<' + '/tool_call>';
|
|
297
|
+
const proseOpen = "\\s*(?:I'll|I will|I've|Here(?:'s| is)|Sure|Certainly|Let me|First,|To do this)";
|
|
298
|
+
const looksLikeProse = new RegExp(toolCall, 'i').test(body) || new RegExp('^' + proseOpen, 'im').test(body);
|
|
299
|
+
const codeStart = '^(?://|#|<!--|/\\*|import\\s|export\\s|const\\s|let\\s|var\\s|function\\s|class\\s|interface\\s|type\\s|def\\s|package\\s|using\\s|public\\s|private\\s|<!DOCTYPE|<[a-zA-Z])';
|
|
300
|
+
const looksLikeCode = new RegExp(codeStart, 'm').test(body);
|
|
301
|
+
if (looksLikeProse || !looksLikeCode) {
|
|
302
|
+
emissionErrors.push('Refused to write ' + hints[0] + ': worker output has no fenced code block and does not look like source code.');
|
|
303
|
+
}
|
|
304
|
+
else {
|
|
305
|
+
emitFile(hints[0], body);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
return { filesWritten, errors: emissionErrors, cleanContent: content };
|
|
310
|
+
}
|
package/dist/guard.d.ts
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import type { DelegatedRecord } from './contracts';
|
|
2
|
+
/** Shell tools can write files as a side effect; detection is best-effort. */
|
|
3
|
+
export declare const SHELL_TOOLS: Set<string>;
|
|
4
|
+
/**
|
|
5
|
+
* Tools that read a file's contents into the caller's context. Exported because `describeSourceRead`
|
|
6
|
+
* asks the same question — is this tool a read? — and two lists would drift.
|
|
7
|
+
*/
|
|
8
|
+
export declare const READ_TOOLS: Set<string>;
|
|
9
|
+
/**
|
|
10
|
+
* Search tools return file content, so the read gate has to treat them as reads. This is the read
|
|
11
|
+
* gate's copy of the vocabulary `READ_ONLY_INSPECTORS` already carries for the write guard: a tool
|
|
12
|
+
* which reads files in one guard and is invisible in the other is how rule 3 was bypassed — an agent
|
|
13
|
+
* was refused by the read guard and read the same files with search.
|
|
14
|
+
*/
|
|
15
|
+
export declare const SEARCH_TOOLS: Set<string>;
|
|
16
|
+
export interface GuardVerdict {
|
|
17
|
+
kind: 'deny' | 'ask';
|
|
18
|
+
target: string;
|
|
19
|
+
reason: string;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* The file a tool call names, from whichever argument shape that tool uses. Exported because `apply`
|
|
23
|
+
* passes it to `describeSourceRead`, which asks the same question about the same call.
|
|
24
|
+
*/
|
|
25
|
+
export declare function extractWriteTarget(args: any): string | undefined;
|
|
26
|
+
/**
|
|
27
|
+
* Does this command line carry a write signal? A redirection counts only when it is a
|
|
28
|
+
* real one: an `=>` in inline program text and a `2>&1` must not turn a read-only
|
|
29
|
+
* command into an approval prompt.
|
|
30
|
+
*/
|
|
31
|
+
export declare function hasCommandWriteSignal(command: string): boolean;
|
|
32
|
+
/**
|
|
33
|
+
* Delete-capable constructs, checked against BOTH a command line and a script body.
|
|
34
|
+
* Destroying a source file is at least as consequential as overwriting it, and the first
|
|
35
|
+
* version of this guard left deletion entirely ungated. API-level removals are included
|
|
36
|
+
* because inline program text (`python -c "os.remove(...)"`) never names a verb the
|
|
37
|
+
* command line displays. Word-anchored for the same reason as the write list: unanchored,
|
|
38
|
+
* `rm` matches inside unrelated paths and `Move-Item` matches inside `Remove-Item`.
|
|
39
|
+
*/
|
|
40
|
+
export declare const DELETE_PRIMITIVES: RegExp;
|
|
41
|
+
/** Does this command line or script body carry a delete signal? */
|
|
42
|
+
export declare function hasCommandDeleteSignal(text: string): boolean;
|
|
43
|
+
/**
|
|
44
|
+
* Normalise one shell token to a bare program name.
|
|
45
|
+
*
|
|
46
|
+
* The extension strip has to match everywhere a program is recognised, or an alias is half-known: the
|
|
47
|
+
* prefilter compared raw lowercased tokens while `commandReadsContent` and `isReadArgument` both
|
|
48
|
+
* stripped `.exe`/`.cmd`/`.bat`/`.ps1`, so `gc.ps1` was rejected by the first and accepted by the
|
|
49
|
+
* others. One normaliser, one answer.
|
|
50
|
+
*/
|
|
51
|
+
export declare function shellCommandName(token: string): string;
|
|
52
|
+
/**
|
|
53
|
+
* Does this command READ FILE CONTENT, rather than list, test or compare?
|
|
54
|
+
*
|
|
55
|
+
* `CONTENT_READERS` and only `CONTENT_READERS`. An earlier attempt took the union with
|
|
56
|
+
* `READ_ONLY_INSPECTORS` here and `control-bypass.test.cjs` failed on the very next run: that set
|
|
57
|
+
* exists to answer "is this token read as data rather than invoked", which is a *different* question,
|
|
58
|
+
* and it carries `ls`, `get-childitem`, `test-path`, `stat` and friends. Treating those as byte
|
|
59
|
+
* readers is the false positive the two-set split was introduced to remove.
|
|
60
|
+
*
|
|
61
|
+
* Note what this predicate is NOT responsible for. A metadata command passes the prefilter and then
|
|
62
|
+
* correctly reports "does not read content" here, so it is allowed -- which is why the prefilter
|
|
63
|
+
* cannot use this set as its membership test without also un-gating nothing. See the read gate.
|
|
64
|
+
*/
|
|
65
|
+
export declare function commandReadsContent(command: string): boolean;
|
|
66
|
+
/**
|
|
67
|
+
* The longest source-extension reference in a string.
|
|
68
|
+
*
|
|
69
|
+
* The guard previously took the FIRST match, so a command whose prose happened to contain
|
|
70
|
+
* something extension-shaped — an explanation mentioning `(.ts)` — reported that fragment as
|
|
71
|
+
* the target instead of the real path.
|
|
72
|
+
*/
|
|
73
|
+
export declare function longestCodeReference(text: string): string | undefined;
|
|
74
|
+
export type DelegateReadPolicy = 'ask' | 'allow' | 'deny';
|
|
75
|
+
/**
|
|
76
|
+
* What happens when an agent reads a file a delegated worker wrote.
|
|
77
|
+
*
|
|
78
|
+
* The guard cannot yet tell the architect from a lead, so it gates any agent reading delegated code.
|
|
79
|
+
* `ask` is the right default: pulling that code back into the architect's context defeats the point of
|
|
80
|
+
* having delegated it, but reviewing a line is sometimes exactly what an operator wants.
|
|
81
|
+
*
|
|
82
|
+
* `allow` is an escape hatch for a lead tier that has to read the code it writes contracts about. It is
|
|
83
|
+
* an honest weakening of rule 3 rather than a fix, so the reason says which rule it costs — the config
|
|
84
|
+
* entry documents its own price instead of quietly being a bypass.
|
|
85
|
+
*/
|
|
86
|
+
export declare function evaluateDelegatedReadPolicy(policy?: DelegateReadPolicy): {
|
|
87
|
+
kind: 'allow' | 'ask' | 'deny';
|
|
88
|
+
reason: string;
|
|
89
|
+
};
|
|
90
|
+
export interface SettledVerdict {
|
|
91
|
+
allowed: boolean;
|
|
92
|
+
reason: string;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Is this delegated file settled -- written by a unit that passed, with its content unchanged since?
|
|
96
|
+
*
|
|
97
|
+
* `succeeded` alone is not the test, because a verdict describes CONTENT. A file that passed and was then
|
|
98
|
+
* edited is not the version anything verified, so the hash is compared too, and a file that cannot be
|
|
99
|
+
* hashed fails closed rather than open. Pure and exported so the rule is testable without a registry.
|
|
100
|
+
*/
|
|
101
|
+
export declare function evaluateSettledFile(record: DelegatedRecord | undefined, currentHash: string | null): SettledVerdict;
|
|
102
|
+
/**
|
|
103
|
+
* Where the TYPE SKELETON for a source file lives, relative to a declarations root.
|
|
104
|
+
*
|
|
105
|
+
* This is the mapping that lets a read be served as declarations instead of implementation. It is a pure
|
|
106
|
+
* string transform on purpose: the caller decides whether the result exists, because a mapping function
|
|
107
|
+
* that silently invents a path is worse than one that returns the path it computed and lets the caller
|
|
108
|
+
* refuse.
|
|
109
|
+
*
|
|
110
|
+
* Returns null for anything that is not a TypeScript or JavaScript source file, so a caller can treat null
|
|
111
|
+
* as "this read is out of scope" rather than as a failure.
|
|
112
|
+
*
|
|
113
|
+
* The transform mirrors what `tsc --declaration --emitDeclarationOnly` emits for a `rootDir` of the source
|
|
114
|
+
* directory: the tree below the root is preserved and only the extension changes.
|
|
115
|
+
*/
|
|
116
|
+
export declare function declarationPathFor(sourcePath: string, declarationRoot?: string): string | null;
|
|
117
|
+
/**
|
|
118
|
+
* Is a declaration STALE relative to the source it is supposed to describe?
|
|
119
|
+
*
|
|
120
|
+
* The egress control redirects a source read to `<root>/x.d.ts`. Until this existed it verified only that
|
|
121
|
+
* the file EXISTED, never that it still described the source — so editing an interface and skipping the
|
|
122
|
+
* build served the architect yesterday's signature, and nothing anywhere would have said so. That is the
|
|
123
|
+
* same shape as every false green this repository has caught: healthy under test, because a test suite
|
|
124
|
+
* always runs against a freshly built tree, and wrong exactly when a human is working.
|
|
125
|
+
*
|
|
126
|
+
* Returns a reason when stale, or null when the declaration is fresh OR when either timestamp cannot be
|
|
127
|
+
* read. An unreadable stat deliberately returns null rather than a reason: the caller's next step is to read
|
|
128
|
+
* the declaration, which has its own fail-closed path when that file is missing, and inventing a staleness
|
|
129
|
+
* verdict from a failed stat would report a cause it has not established.
|
|
130
|
+
*
|
|
131
|
+
* WHAT THIS IS NOT: a content digest. mtime is evidence that a rebuild happened after an edit, and a
|
|
132
|
+
* checkout, a `touch`, or clock skew can defeat it. It closes the common case -- an operator planning
|
|
133
|
+
* without running the build -- and it does not prove the declaration corresponds to the source. Binding
|
|
134
|
+
* those two cryptographically is a larger change than this gap warrants, and claiming it here would be the
|
|
135
|
+
* overstatement this file exists to avoid.
|
|
136
|
+
*/
|
|
137
|
+
export declare function staleDeclarationReason(sourcePath: string, declarationPath: string): string | null;
|
|
138
|
+
/**
|
|
139
|
+
* Decide whether a tool call would author source code from the cloud context.
|
|
140
|
+
* Pure and exported so it can be unit-tested without a running server.
|
|
141
|
+
* Returns null when the call has nothing to do with code authoring.
|
|
142
|
+
*/
|
|
143
|
+
export declare function evaluateCodeWriteGuard(exec: any, config?: {
|
|
144
|
+
askPaths?: string[];
|
|
145
|
+
/** Injectable reader, so the script scan is testable without touching disk. */
|
|
146
|
+
readScript?: (script: string) => string | undefined;
|
|
147
|
+
/** How deep to follow script-invokes-script (default 2). */
|
|
148
|
+
scriptDepth?: number;
|
|
149
|
+
/** Paths a delegated worker wrote; reads of them are gated. Injectable for tests. */
|
|
150
|
+
delegatedPaths?: Iterable<string>;
|
|
151
|
+
/**
|
|
152
|
+
* How to find the record for a delegated file, by canonical path. Injectable so the settled rule can
|
|
153
|
+
* be exercised against synthetic verdicts; the default reads the index the delegation itself wrote.
|
|
154
|
+
*/
|
|
155
|
+
delegatedRecordFor?: (canonicalPath: string) => DelegatedRecord | undefined;
|
|
156
|
+
/** ask | allow | deny for reading a delegated file back. Defaults to ask. */
|
|
157
|
+
delegateReadPolicy?: DelegateReadPolicy;
|
|
158
|
+
/** Paths the architect may author as the specification. Defaults to tests/. */
|
|
159
|
+
contractPaths?: string[];
|
|
160
|
+
/** allow | ask | deny for a write to a contract path. Defaults to ask. */
|
|
161
|
+
contractWriteMode?: 'allow' | 'ask' | 'deny';
|
|
162
|
+
}): GuardVerdict | null;
|