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
package/dist/guard.js
ADDED
|
@@ -0,0 +1,873 @@
|
|
|
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.DELETE_PRIMITIVES = exports.SEARCH_TOOLS = exports.READ_TOOLS = exports.SHELL_TOOLS = void 0;
|
|
37
|
+
exports.extractWriteTarget = extractWriteTarget;
|
|
38
|
+
exports.hasCommandWriteSignal = hasCommandWriteSignal;
|
|
39
|
+
exports.hasCommandDeleteSignal = hasCommandDeleteSignal;
|
|
40
|
+
exports.shellCommandName = shellCommandName;
|
|
41
|
+
exports.commandReadsContent = commandReadsContent;
|
|
42
|
+
exports.longestCodeReference = longestCodeReference;
|
|
43
|
+
exports.evaluateDelegatedReadPolicy = evaluateDelegatedReadPolicy;
|
|
44
|
+
exports.evaluateSettledFile = evaluateSettledFile;
|
|
45
|
+
exports.declarationPathFor = declarationPathFor;
|
|
46
|
+
exports.staleDeclarationReason = staleDeclarationReason;
|
|
47
|
+
exports.evaluateCodeWriteGuard = evaluateCodeWriteGuard;
|
|
48
|
+
const fs = __importStar(require("fs"));
|
|
49
|
+
const path = __importStar(require("path"));
|
|
50
|
+
const paths_1 = require("./paths");
|
|
51
|
+
// The registry owns both the path index and the verdict attached to each file. This is a one-way edge:
|
|
52
|
+
// `contracts.ts` imports only `logging.ts` and `paths.ts`, and only `index.ts` and `roles.ts` import this
|
|
53
|
+
// module, so there is no cycle to resolve.
|
|
54
|
+
const contracts_1 = require("./contracts");
|
|
55
|
+
/** Tools that write a file directly. */
|
|
56
|
+
const WRITE_TOOLS = new Set([
|
|
57
|
+
'write', 'edit', 'str_replace_editor', 'apply_patch', 'multi_edit',
|
|
58
|
+
'create_file', 'write_file', 'fs_write', 'notebook_edit',
|
|
59
|
+
]);
|
|
60
|
+
/** Shell tools can write files as a side effect; detection is best-effort. */
|
|
61
|
+
exports.SHELL_TOOLS = new Set([
|
|
62
|
+
'pwsh', 'bash', 'shell', 'terminal', 'run_command', 'pwsh_persistent', 'bash_persistent',
|
|
63
|
+
]);
|
|
64
|
+
/**
|
|
65
|
+
* Tools that read a file's contents into the caller's context. Exported because `describeSourceRead`
|
|
66
|
+
* asks the same question — is this tool a read? — and two lists would drift.
|
|
67
|
+
*/
|
|
68
|
+
exports.READ_TOOLS = new Set(['read', 'read_file', 'fs_read', 'view', 'view_file', 'cat']);
|
|
69
|
+
/**
|
|
70
|
+
* Every delegated file `target` could name, canonical, however the caller spelled it.
|
|
71
|
+
*
|
|
72
|
+
* `canonicalisePath` resolves a relative path against the process cwd, and the process cwd is not the
|
|
73
|
+
* session workspace: comparing the two as strings missed every relative target, which is how the
|
|
74
|
+
* architect names files essentially always. The live gate therefore fired only for absolute paths --
|
|
75
|
+
* reproduced against a file a delegated worker had just created, read back with no decision at all.
|
|
76
|
+
*
|
|
77
|
+
* The guard cannot be told the workspace. A tool call does not carry one, and the value lives in the
|
|
78
|
+
* plugin context, which is exactly the coupling this module avoids so that it stays a pure function. So
|
|
79
|
+
* the base is not assumed, it is searched: `resolvesTo` tests the target against every ancestor directory
|
|
80
|
+
* of every delegated file. A delegated file always lives beneath its workspace, so the workspace is one
|
|
81
|
+
* of those ancestors and the answer is recovered without being told.
|
|
82
|
+
*
|
|
83
|
+
* Which is why this returns a LIST rather than a boolean. The same relative target can name a file in
|
|
84
|
+
* more than one workspace -- two temp workspaces both holding `plugin/src/thing.ts` -- and the guard has
|
|
85
|
+
* no way to prefer one. Returning every candidate lets the caller answer with the worst of them instead
|
|
86
|
+
* of silently taking whichever happened to be first.
|
|
87
|
+
*/
|
|
88
|
+
function matchDelegatedPaths(target, paths) {
|
|
89
|
+
if (typeof target !== 'string' || !target)
|
|
90
|
+
return [];
|
|
91
|
+
const canonical = (0, paths_1.canonicalisePath)(target);
|
|
92
|
+
const absolute = path.isAbsolute(target);
|
|
93
|
+
const matches = [];
|
|
94
|
+
for (const p of paths ?? []) {
|
|
95
|
+
const delegated = (0, paths_1.canonicalisePath)(String(p));
|
|
96
|
+
if (delegated !== canonical && (absolute || !resolvesTo(target, delegated)))
|
|
97
|
+
continue;
|
|
98
|
+
if (!matches.includes(delegated))
|
|
99
|
+
matches.push(delegated);
|
|
100
|
+
}
|
|
101
|
+
return matches;
|
|
102
|
+
}
|
|
103
|
+
/** Does `target`, read relative to some ancestor of `delegated`, name `delegated` itself? */
|
|
104
|
+
function resolvesTo(target, delegated) {
|
|
105
|
+
let dir = path.dirname(delegated);
|
|
106
|
+
for (;;) {
|
|
107
|
+
if (samePath(path.resolve(dir, target), delegated))
|
|
108
|
+
return true;
|
|
109
|
+
const parent = path.dirname(dir);
|
|
110
|
+
if (parent === dir)
|
|
111
|
+
return false;
|
|
112
|
+
dir = parent;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
/** Windows paths are case-insensitive; the rest are not. */
|
|
116
|
+
function samePath(a, b) {
|
|
117
|
+
return process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Search tools return file content, so the read gate has to treat them as reads. This is the read
|
|
121
|
+
* gate's copy of the vocabulary `READ_ONLY_INSPECTORS` already carries for the write guard: a tool
|
|
122
|
+
* which reads files in one guard and is invisible in the other is how rule 3 was bypassed — an agent
|
|
123
|
+
* was refused by the read guard and read the same files with search.
|
|
124
|
+
*/
|
|
125
|
+
exports.SEARCH_TOOLS = new Set(['grep', 'rg', 'ripgrep', 'search', 'find_in_files', 'select-string']);
|
|
126
|
+
/**
|
|
127
|
+
* A read names a path but a search names a pattern and a scope. `matchDelegatedPaths` matches file
|
|
128
|
+
* identity, so a directory scope matched nothing and every workspace-wide search walked past the
|
|
129
|
+
* rule. A relative scope can only be resolved against an ancestor directory of a delegated file
|
|
130
|
+
* because the guard is never told the workspace, which is the same inference `resolvesTo` uses. An
|
|
131
|
+
* empty scope returns every delegated path because a search with no path argument covers the
|
|
132
|
+
* workspace.
|
|
133
|
+
*/
|
|
134
|
+
function delegatedUnder(scope, paths) {
|
|
135
|
+
const all = [...(paths ?? [])];
|
|
136
|
+
if (typeof scope !== 'string' || !scope.trim())
|
|
137
|
+
return all;
|
|
138
|
+
const absolute = path.isAbsolute(scope);
|
|
139
|
+
const matches = [];
|
|
140
|
+
for (const p of all) {
|
|
141
|
+
const delegated = String(p);
|
|
142
|
+
let dir = path.dirname(delegated);
|
|
143
|
+
for (;;) {
|
|
144
|
+
const base = absolute ? path.resolve(scope) : path.resolve(dir, scope);
|
|
145
|
+
const rel = path.relative(base, delegated);
|
|
146
|
+
if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) {
|
|
147
|
+
if (!matches.includes(delegated))
|
|
148
|
+
matches.push(delegated);
|
|
149
|
+
break;
|
|
150
|
+
}
|
|
151
|
+
if (absolute)
|
|
152
|
+
break;
|
|
153
|
+
const parent = path.dirname(dir);
|
|
154
|
+
if (parent === dir)
|
|
155
|
+
break;
|
|
156
|
+
dir = parent;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return matches;
|
|
160
|
+
}
|
|
161
|
+
const DEFAULT_GUARD_ASK_PATHS = ['tests/', 'tools/'];
|
|
162
|
+
/**
|
|
163
|
+
* The file a tool call names, from whichever argument shape that tool uses. Exported because `apply`
|
|
164
|
+
* passes it to `describeSourceRead`, which asks the same question about the same call.
|
|
165
|
+
*/
|
|
166
|
+
function extractWriteTarget(args) {
|
|
167
|
+
if (!args || typeof args !== 'object')
|
|
168
|
+
return undefined;
|
|
169
|
+
for (const key of ['file_path', 'filePath', 'path', 'filename', 'file', 'target_file', 'targetPath']) {
|
|
170
|
+
const value = args[key];
|
|
171
|
+
if (typeof value === 'string' && value.trim())
|
|
172
|
+
return value.trim();
|
|
173
|
+
}
|
|
174
|
+
return undefined;
|
|
175
|
+
}
|
|
176
|
+
function shellWriteTarget(command) {
|
|
177
|
+
if (typeof command !== 'string' || !command)
|
|
178
|
+
return undefined;
|
|
179
|
+
const match = /(?:Set-Content|Add-Content|Out-File|New-Item|tee|>>?)\s+(?:-Path\s+)?["']?([^\s"'|;>)]+\.[A-Za-z0-9]{1,6})["']?/.exec(command);
|
|
180
|
+
if (!match)
|
|
181
|
+
return undefined;
|
|
182
|
+
return paths_1.CODE_EXTENSIONS.has(path.extname(match[1]).toLowerCase()) ? match[1] : undefined;
|
|
183
|
+
}
|
|
184
|
+
/** Script kinds a shell command can invoke. */
|
|
185
|
+
const SCRIPT_EXTENSIONS = new Set([
|
|
186
|
+
'.ps1', '.psm1', '.sh', '.bash', '.zsh', '.cmd', '.bat',
|
|
187
|
+
'.mjs', '.cjs', '.js', '.py', '.rb', '.pl',
|
|
188
|
+
]);
|
|
189
|
+
/**
|
|
190
|
+
* Write primitives, deliberately explicit rather than including a bare `>`.
|
|
191
|
+
* A redirection pattern would match `=>` in every arrow function and turn any JS
|
|
192
|
+
* file into a false positive.
|
|
193
|
+
*/
|
|
194
|
+
const WRITE_PRIMITIVES = /(?:Set-Content|Add-Content|Out-File|New-Item|WriteAllText|WriteAllBytes|WriteAllLines|writeFileSync|writeFile|createWriteStream|copyFileSync|renameSync|fs\.appendFile)/i;
|
|
195
|
+
/** A source-extension reference inside a script body. */
|
|
196
|
+
const CODE_REFERENCE = /[A-Za-z0-9_\-\\/.]*\.(?:ts|tsx|js|jsx|mjs|cjs|py|rb|go|rs|java|kt|cs|c|cc|cpp|h|hpp|swift|php|scala|lua|dart|sh|bash|ps1|psm1|sql|html|htm|css|scss|vue|svelte)\b/i;
|
|
197
|
+
/**
|
|
198
|
+
* Write-capable constructs visible on a COMMAND LINE, not merely inside a script file
|
|
199
|
+
* the guard can name. Previously only a script body was inspected, so inline program
|
|
200
|
+
* text (`python -c`, `node -e`) and ordinary file verbs were invisible to the guard and
|
|
201
|
+
* fell through to an allow.
|
|
202
|
+
*/
|
|
203
|
+
const COMMAND_WRITE_PRIMITIVES = /(?:\bSet-Content\b|\bAdd-Content\b|\bClear-Content\b|\bOut-File\b|\bNew-Item\b|\bCopy-Item\b|\bMove-Item\b|\bRename-Item\b|\bWriteAllText\b|\bWriteAllBytes\b|\bWriteAllLines\b|\bwriteFileSync\b|\bwriteFile\b|\bcreateWriteStream\b|\bappendFile\b|\bcopyFileSync\b|\brenameSync\b|\bshutil\.copy\b|\.write\s*\(|\bcp\b|\bmv\b|\bcopy\b|\bmove\b|\bren\b|\bdd\b|\bsed\s+-i\b|\bperl\s+-i\b|\bgit\s+(?:apply|checkout|restore|stash|clean)\b|\brobocopy\b|\bxcopy\b|\btruncate\b|\btee\b)/i;
|
|
204
|
+
// Every alternative is word-anchored deliberately. Unanchored verbs match as SUBSTRINGS of
|
|
205
|
+
// unrelated words: `Move-Item` is a substring of `Remove-Item`, so a plain delete was being
|
|
206
|
+
// classified as a write signal. Anchoring also keeps `writeFile` from being read out of
|
|
207
|
+
// `writeFileSync` and vice versa.
|
|
208
|
+
/** Inline program text can write a file the command line never names. */
|
|
209
|
+
const INLINE_EVAL_FLAG = /(?:^|\s)(?:-e|-c|--eval|-Command|-EncodedCommand)(?=\s|$)/i;
|
|
210
|
+
/**
|
|
211
|
+
* Does this command line carry a write signal? A redirection counts only when it is a
|
|
212
|
+
* real one: an `=>` in inline program text and a `2>&1` must not turn a read-only
|
|
213
|
+
* command into an approval prompt.
|
|
214
|
+
*/
|
|
215
|
+
function hasCommandWriteSignal(command) {
|
|
216
|
+
if (typeof command !== 'string' || !command)
|
|
217
|
+
return false;
|
|
218
|
+
if (COMMAND_WRITE_PRIMITIVES.test(command))
|
|
219
|
+
return true;
|
|
220
|
+
if (INLINE_EVAL_FLAG.test(command))
|
|
221
|
+
return true;
|
|
222
|
+
return /(?:^|[^=\-])>>?(?![=&])/.test(command);
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Delete-capable constructs, checked against BOTH a command line and a script body.
|
|
226
|
+
* Destroying a source file is at least as consequential as overwriting it, and the first
|
|
227
|
+
* version of this guard left deletion entirely ungated. API-level removals are included
|
|
228
|
+
* because inline program text (`python -c "os.remove(...)"`) never names a verb the
|
|
229
|
+
* command line displays. Word-anchored for the same reason as the write list: unanchored,
|
|
230
|
+
* `rm` matches inside unrelated paths and `Move-Item` matches inside `Remove-Item`.
|
|
231
|
+
*/
|
|
232
|
+
exports.DELETE_PRIMITIVES = /(?:\bRemove-Item\b|\brm\b|\bdel\b|\berase\b|\brmdir\b|\brd\b|\bunlink\b|\bshred\b|\bgit\s+rm\b|\bos\.remove\b|\bshutil\.rmtree\b|\bunlinkSync\b|\brmSync\b|\brmdirSync\b|\bfs\.unlink\b)/i;
|
|
233
|
+
/** Does this command line or script body carry a delete signal? */
|
|
234
|
+
function hasCommandDeleteSignal(text) {
|
|
235
|
+
return typeof text === 'string' && text.length > 0 && exports.DELETE_PRIMITIVES.test(text);
|
|
236
|
+
}
|
|
237
|
+
/** Tokens in a command line that name a script file. */
|
|
238
|
+
function extractScriptPaths(command) {
|
|
239
|
+
if (typeof command !== 'string' || !command)
|
|
240
|
+
return [];
|
|
241
|
+
return command
|
|
242
|
+
.split(/[\s'"`|;&()]+/)
|
|
243
|
+
.filter((token) => token && SCRIPT_EXTENSIONS.has(path.extname(token).toLowerCase()));
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Commands that READ a file named on the command line rather than executing it.
|
|
247
|
+
*
|
|
248
|
+
* Without this list, `Select-String -Path some.js` was treated as an invocation of
|
|
249
|
+
* `some.js`: the guard read the whole file and, since any sizeable program contains a write
|
|
250
|
+
* primitive, asked for approval to *read* it. Reading a file is not running it.
|
|
251
|
+
*/
|
|
252
|
+
const READ_ONLY_INSPECTORS = new Set([
|
|
253
|
+
'select-string', 'get-content', 'cat', 'type', 'head', 'tail', 'less', 'more',
|
|
254
|
+
'grep', 'rg', 'findstr', 'test-path', 'get-item', 'get-childitem', 'ls', 'dir',
|
|
255
|
+
'stat', 'wc', 'diff', 'cmp', 'sort', 'uniq', 'strings', 'file', 'od', 'xxd',
|
|
256
|
+
'out-string', 'measure-object',
|
|
257
|
+
]);
|
|
258
|
+
/**
|
|
259
|
+
* The subset of inspectors that return file CONTENT, as opposed to names, sizes or existence.
|
|
260
|
+
*
|
|
261
|
+
* `READ_ONLY_INSPECTORS` answers "is this token read as data rather than invoked", which is a different
|
|
262
|
+
* question from "does this command read bytes", and conflating the two made the read guard fire on
|
|
263
|
+
* `Get-ChildItem` and `Test-Path`. That is fail-closed and therefore safe, but it costs the operator
|
|
264
|
+
* every ordinary command in a workspace where anything has been delegated -- and a guard that cannot be
|
|
265
|
+
* lived with is a guard that gets switched off, which loses the whole rule rather than the false positive.
|
|
266
|
+
*
|
|
267
|
+
* The line this draws is the one the containment inference actually needs: `delegatedUnder` exists
|
|
268
|
+
* because a command scoped to a DIRECTORY can read every delegated file beneath it, and that is only
|
|
269
|
+
* true of a command that returns contents. `Get-ChildItem src` lists names; `Get-Content src/*.ts`
|
|
270
|
+
* returns bytes. Only the second may infer containment.
|
|
271
|
+
*
|
|
272
|
+
* What is deliberately NOT here: `stat`, `wc`, `file`, `diff`, `test-path`, `get-childitem`, `ls`,
|
|
273
|
+
* `dir`. Those report metadata or compare handles, and treating them as readers would put the false
|
|
274
|
+
* positive back.
|
|
275
|
+
*/
|
|
276
|
+
const CONTENT_READERS = new Set([
|
|
277
|
+
'select-string', 'get-content', 'gc', 'cat', 'type', 'head', 'tail', 'less', 'more',
|
|
278
|
+
'grep', 'rg', 'findstr', 'strings', 'od', 'xxd',
|
|
279
|
+
]);
|
|
280
|
+
/**
|
|
281
|
+
* Normalise one shell token to a bare program name.
|
|
282
|
+
*
|
|
283
|
+
* The extension strip has to match everywhere a program is recognised, or an alias is half-known: the
|
|
284
|
+
* prefilter compared raw lowercased tokens while `commandReadsContent` and `isReadArgument` both
|
|
285
|
+
* stripped `.exe`/`.cmd`/`.bat`/`.ps1`, so `gc.ps1` was rejected by the first and accepted by the
|
|
286
|
+
* others. One normaliser, one answer.
|
|
287
|
+
*/
|
|
288
|
+
function shellCommandName(token) {
|
|
289
|
+
return path
|
|
290
|
+
.basename(String(token || '').trim())
|
|
291
|
+
.toLowerCase()
|
|
292
|
+
.replace(/\.(?:exe|cmd|bat|ps1)$/, '');
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Does this command READ FILE CONTENT, rather than list, test or compare?
|
|
296
|
+
*
|
|
297
|
+
* `CONTENT_READERS` and only `CONTENT_READERS`. An earlier attempt took the union with
|
|
298
|
+
* `READ_ONLY_INSPECTORS` here and `control-bypass.test.cjs` failed on the very next run: that set
|
|
299
|
+
* exists to answer "is this token read as data rather than invoked", which is a *different* question,
|
|
300
|
+
* and it carries `ls`, `get-childitem`, `test-path`, `stat` and friends. Treating those as byte
|
|
301
|
+
* readers is the false positive the two-set split was introduced to remove.
|
|
302
|
+
*
|
|
303
|
+
* Note what this predicate is NOT responsible for. A metadata command passes the prefilter and then
|
|
304
|
+
* correctly reports "does not read content" here, so it is allowed -- which is why the prefilter
|
|
305
|
+
* cannot use this set as its membership test without also un-gating nothing. See the read gate.
|
|
306
|
+
*/
|
|
307
|
+
function commandReadsContent(command) {
|
|
308
|
+
return String(command || '')
|
|
309
|
+
.split(/[\s'"|;&()]+/)
|
|
310
|
+
.filter(Boolean)
|
|
311
|
+
.some((token) => CONTENT_READERS.has(shellCommandName(token)));
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Delegated files that sit under the directory an ABSOLUTE token names.
|
|
315
|
+
*
|
|
316
|
+
* `delegatedUnder` covers a token that names the delegated file's own parent, and `matchDelegatedPaths`
|
|
317
|
+
* covers a token that names the file. Neither covers a token that names a GLOB inside a delegated
|
|
318
|
+
* directory, which is the shape a reader most often uses: `Get-Content 'C:\ws\src\*.ts'` is neither the
|
|
319
|
+
* directory nor any single file, so with containment restricted to content readers it slipped past both
|
|
320
|
+
* and read delegated bytes without a grant. Found by this suite, and the reason the containment has an
|
|
321
|
+
* explicit absolute form rather than relying on the two inferences above to happen to meet.
|
|
322
|
+
*/
|
|
323
|
+
function delegatedUnderDirectory(token, paths) {
|
|
324
|
+
if (!path.isAbsolute(token))
|
|
325
|
+
return [];
|
|
326
|
+
const root = path.dirname(token);
|
|
327
|
+
const matches = [];
|
|
328
|
+
for (const p of paths ?? []) {
|
|
329
|
+
const delegated = String(p);
|
|
330
|
+
if ((0, paths_1.isPathWithin)(root, delegated) && !matches.includes(delegated))
|
|
331
|
+
matches.push(delegated);
|
|
332
|
+
}
|
|
333
|
+
return matches;
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* Is this token read as data by a read-only inspector, rather than invoked?
|
|
337
|
+
*
|
|
338
|
+
* Scans positionally instead of by token: the tokenizer splits on `;`, `|` and `&`, which
|
|
339
|
+
* are exactly the statement boundaries this needs to respect. Everything from the previous
|
|
340
|
+
* separator up to the token is the statement that names it; if a read-only inspector appears
|
|
341
|
+
* there, the token is an argument to it. A token at a statement start — including `&` or
|
|
342
|
+
* `.` invocation, where the separator is immediately behind it — is an invocation, which is
|
|
343
|
+
* the case the body scan exists for.
|
|
344
|
+
*/
|
|
345
|
+
function isReadArgument(command, script) {
|
|
346
|
+
const at = command.indexOf(script);
|
|
347
|
+
if (at < 0)
|
|
348
|
+
return false;
|
|
349
|
+
const before = command.slice(0, at);
|
|
350
|
+
const boundary = Math.max(before.lastIndexOf(';'), before.lastIndexOf('|'), before.lastIndexOf('&'), before.lastIndexOf('\n'));
|
|
351
|
+
const statement = before.slice(boundary + 1);
|
|
352
|
+
return statement
|
|
353
|
+
.split(/[\s'"`()]+/)
|
|
354
|
+
.filter(Boolean)
|
|
355
|
+
.some((word) => {
|
|
356
|
+
const bare = path.basename(word).toLowerCase().replace(/\.(?:exe|cmd|bat|ps1)$/, '');
|
|
357
|
+
return READ_ONLY_INSPECTORS.has(bare);
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* A delegated file named by a read-only inspector in a shell command is the same read by another
|
|
362
|
+
* route. Only delegated paths are tested, so this cannot reintroduce the false positive that made
|
|
363
|
+
* `Select-String some.js` look like a script invocation.
|
|
364
|
+
*/
|
|
365
|
+
function findDelegatedRead(command, paths) {
|
|
366
|
+
if (typeof command !== 'string' || !command || !paths)
|
|
367
|
+
return undefined;
|
|
368
|
+
let form;
|
|
369
|
+
const matches = [];
|
|
370
|
+
for (const p of paths) {
|
|
371
|
+
const canonical = (0, paths_1.canonicalisePath)(String(p));
|
|
372
|
+
const forms = [canonical, canonical.replace(/\\/g, '/')];
|
|
373
|
+
// The same search as matchDelegatedPaths, in the form a command string needs: each way of spelling
|
|
374
|
+
// the file relative to one of its ancestors. A single-segment name is skipped, because a bare
|
|
375
|
+
// filename appears in commands that have nothing to do with the file.
|
|
376
|
+
let dir = path.dirname(canonical);
|
|
377
|
+
for (;;) {
|
|
378
|
+
const relative = path.relative(dir, canonical);
|
|
379
|
+
if (relative.includes(path.sep))
|
|
380
|
+
forms.push(relative, relative.replace(/\\/g, '/'));
|
|
381
|
+
const parent = path.dirname(dir);
|
|
382
|
+
if (parent === dir)
|
|
383
|
+
break;
|
|
384
|
+
dir = parent;
|
|
385
|
+
}
|
|
386
|
+
for (const candidate of forms) {
|
|
387
|
+
if (command.includes(candidate) && isReadArgument(command, candidate)) {
|
|
388
|
+
if (form === undefined)
|
|
389
|
+
form = candidate;
|
|
390
|
+
if (!matches.includes(canonical))
|
|
391
|
+
matches.push(canonical);
|
|
392
|
+
break;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
return form === undefined ? undefined : { form, matches };
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* The longest source-extension reference in a string.
|
|
400
|
+
*
|
|
401
|
+
* The guard previously took the FIRST match, so a command whose prose happened to contain
|
|
402
|
+
* something extension-shaped — an explanation mentioning `(.ts)` — reported that fragment as
|
|
403
|
+
* the target instead of the real path.
|
|
404
|
+
*/
|
|
405
|
+
function longestCodeReference(text) {
|
|
406
|
+
const scanner = new RegExp(CODE_REFERENCE.source, 'gi');
|
|
407
|
+
let longest;
|
|
408
|
+
for (const match of text.matchAll(scanner)) {
|
|
409
|
+
if (longest === undefined || match[0].length > longest.length)
|
|
410
|
+
longest = match[0];
|
|
411
|
+
}
|
|
412
|
+
return longest;
|
|
413
|
+
}
|
|
414
|
+
function resolveScriptPath(script) {
|
|
415
|
+
return path.isAbsolute(script) ? script : path.resolve(process.cwd(), script);
|
|
416
|
+
}
|
|
417
|
+
/** Size-capped read; a missing or unreadable script simply yields no finding. */
|
|
418
|
+
function defaultReadScript(script) {
|
|
419
|
+
try {
|
|
420
|
+
const resolved = resolveScriptPath(script);
|
|
421
|
+
const stat = fs.statSync(resolved);
|
|
422
|
+
if (!stat.isFile() || stat.size > 512 * 1024)
|
|
423
|
+
return undefined;
|
|
424
|
+
return fs.readFileSync(resolved, 'utf8');
|
|
425
|
+
}
|
|
426
|
+
catch {
|
|
427
|
+
return undefined;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
/**
|
|
431
|
+
* Follow script invocations looking for a script that writes source files.
|
|
432
|
+
* Bounded depth and a visited set, so a script that invokes itself terminates.
|
|
433
|
+
*/
|
|
434
|
+
function findWriteViaScript(command, readScript, depth, visited = new Set()) {
|
|
435
|
+
if (depth <= 0)
|
|
436
|
+
return null;
|
|
437
|
+
for (const script of extractScriptPaths(command)) {
|
|
438
|
+
// A file named in order to be READ is not a script being invoked.
|
|
439
|
+
if (isReadArgument(command, script))
|
|
440
|
+
continue;
|
|
441
|
+
const resolved = resolveScriptPath(script);
|
|
442
|
+
if (visited.has(resolved))
|
|
443
|
+
continue;
|
|
444
|
+
visited.add(resolved);
|
|
445
|
+
const body = readScript(script);
|
|
446
|
+
if (!body)
|
|
447
|
+
continue;
|
|
448
|
+
// Both signals are required: a mutation primitive (write or delete) AND a source reference.
|
|
449
|
+
if (WRITE_PRIMITIVES.test(body) || exports.DELETE_PRIMITIVES.test(body)) {
|
|
450
|
+
const target = longestCodeReference(body);
|
|
451
|
+
if (target)
|
|
452
|
+
return { script, target };
|
|
453
|
+
}
|
|
454
|
+
const nested = findWriteViaScript(body, readScript, depth - 1, visited);
|
|
455
|
+
if (nested)
|
|
456
|
+
return nested;
|
|
457
|
+
}
|
|
458
|
+
return null;
|
|
459
|
+
}
|
|
460
|
+
/**
|
|
461
|
+
* What happens when an agent reads a file a delegated worker wrote.
|
|
462
|
+
*
|
|
463
|
+
* The guard cannot yet tell the architect from a lead, so it gates any agent reading delegated code.
|
|
464
|
+
* `ask` is the right default: pulling that code back into the architect's context defeats the point of
|
|
465
|
+
* having delegated it, but reviewing a line is sometimes exactly what an operator wants.
|
|
466
|
+
*
|
|
467
|
+
* `allow` is an escape hatch for a lead tier that has to read the code it writes contracts about. It is
|
|
468
|
+
* an honest weakening of rule 3 rather than a fix, so the reason says which rule it costs — the config
|
|
469
|
+
* entry documents its own price instead of quietly being a bypass.
|
|
470
|
+
*/
|
|
471
|
+
function evaluateDelegatedReadPolicy(policy = 'ask') {
|
|
472
|
+
if (policy === 'allow') {
|
|
473
|
+
return {
|
|
474
|
+
kind: 'allow',
|
|
475
|
+
reason: 'delegateReadPolicy is allow, which weakens rule 3: reading delegated source back into a ' +
|
|
476
|
+
'context is permitted for every agent, because the host does not yet say which agent is which.',
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
if (policy === 'deny') {
|
|
480
|
+
return {
|
|
481
|
+
kind: 'deny',
|
|
482
|
+
reason: 'delegateReadPolicy is deny, so reading delegated source back is refused (rule 3).',
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
return {
|
|
486
|
+
kind: 'ask',
|
|
487
|
+
reason: 'delegateReadPolicy is ask (the default), so this read needs an operator decision.',
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
/**
|
|
491
|
+
* Is this delegated file settled -- written by a unit that passed, with its content unchanged since?
|
|
492
|
+
*
|
|
493
|
+
* `succeeded` alone is not the test, because a verdict describes CONTENT. A file that passed and was then
|
|
494
|
+
* edited is not the version anything verified, so the hash is compared too, and a file that cannot be
|
|
495
|
+
* hashed fails closed rather than open. Pure and exported so the rule is testable without a registry.
|
|
496
|
+
*/
|
|
497
|
+
function evaluateSettledFile(record, currentHash) {
|
|
498
|
+
if (!record)
|
|
499
|
+
return { allowed: false, reason: 'the registry holds no verdict for it.' };
|
|
500
|
+
if (record.outcome === 'UNIT_FLAKY') {
|
|
501
|
+
// Named against the ORACLE rather than the code, because that is what disagreed. A contract that
|
|
502
|
+
// passed on one run and failed on another has not established anything about the content, and the
|
|
503
|
+
// run it passed is exactly the run that could have promoted a race condition.
|
|
504
|
+
return {
|
|
505
|
+
allowed: false,
|
|
506
|
+
reason: 'the contract that judged it disagreed with itself across repeated runs, so nothing has been established about its content.',
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
if (record.outcome === 'UNIT_FAILED') {
|
|
510
|
+
return { allowed: false, reason: 'the unit that wrote it failed verification.' };
|
|
511
|
+
}
|
|
512
|
+
if (record.outcome === 'UNIT_UNVERIFIED') {
|
|
513
|
+
// Deliberately about the CONTRACT rather than about whether a command was written down, because a
|
|
514
|
+
// registry record does not carry that distinction and guessing it produced a false statement here.
|
|
515
|
+
// This message used to read "no verification command was given for the unit that wrote it", which was
|
|
516
|
+
// wrong whenever the gate was what stopped the check -- a command had been given, and approval was
|
|
517
|
+
// refused. Both are cases of a contract that was never checked, which is the fact the reader needs.
|
|
518
|
+
return {
|
|
519
|
+
allowed: false,
|
|
520
|
+
reason: 'the unit that wrote it was never verified, so nothing has been established about its content.',
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
// A hand-verified file reads back like a passing one, and the reason says which claim is behind it. The
|
|
524
|
+
// promotion socket treats both as promotable because both are a human-or-machine judgement made against
|
|
525
|
+
// recorded content; the read guard only needs to say that SOMEBODY with standing looked at it, and to
|
|
526
|
+
// name which kind of somebody so the distinction is never lost in a log.
|
|
527
|
+
if (record.outcome === 'OPERATOR_ATTESTED') {
|
|
528
|
+
if (currentHash === null) {
|
|
529
|
+
return { allowed: false, reason: 'its content could not be read, so nothing can be said about it.' };
|
|
530
|
+
}
|
|
531
|
+
if (record.sha256 !== currentHash) {
|
|
532
|
+
return {
|
|
533
|
+
allowed: false,
|
|
534
|
+
reason: 'its content changed after the operator attested it, so the attestation covers bytes that are no longer there.',
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
const by = record.attestation?.operator;
|
|
538
|
+
return {
|
|
539
|
+
allowed: true,
|
|
540
|
+
reason: by
|
|
541
|
+
? 'an operator (' + by + ') attested this content and it is unchanged since.'
|
|
542
|
+
: 'the registry holds an operator attestation for it.',
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
if (record.outcome !== 'UNIT_PASSED' || record.succeeded !== true) {
|
|
546
|
+
return { allowed: false, reason: 'the registry holds no verdict for it.' };
|
|
547
|
+
}
|
|
548
|
+
if (currentHash === null) {
|
|
549
|
+
return { allowed: false, reason: 'its content could not be read, so nothing can be said about it.' };
|
|
550
|
+
}
|
|
551
|
+
if (record.sha256 !== currentHash) {
|
|
552
|
+
return {
|
|
553
|
+
allowed: false,
|
|
554
|
+
reason: 'its content changed after the verdict was reached, so it is not the version anything verified.',
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
return { allowed: true, reason: 'a unit passed it and its content is unchanged since that verdict.' };
|
|
558
|
+
}
|
|
559
|
+
/**
|
|
560
|
+
* Where the TYPE SKELETON for a source file lives, relative to a declarations root.
|
|
561
|
+
*
|
|
562
|
+
* This is the mapping that lets a read be served as declarations instead of implementation. It is a pure
|
|
563
|
+
* string transform on purpose: the caller decides whether the result exists, because a mapping function
|
|
564
|
+
* that silently invents a path is worse than one that returns the path it computed and lets the caller
|
|
565
|
+
* refuse.
|
|
566
|
+
*
|
|
567
|
+
* Returns null for anything that is not a TypeScript or JavaScript source file, so a caller can treat null
|
|
568
|
+
* as "this read is out of scope" rather than as a failure.
|
|
569
|
+
*
|
|
570
|
+
* The transform mirrors what `tsc --declaration --emitDeclarationOnly` emits for a `rootDir` of the source
|
|
571
|
+
* directory: the tree below the root is preserved and only the extension changes.
|
|
572
|
+
*/
|
|
573
|
+
function declarationPathFor(sourcePath, declarationRoot = '') {
|
|
574
|
+
if (typeof sourcePath !== 'string' || !sourcePath.trim())
|
|
575
|
+
return null;
|
|
576
|
+
const extension = path.extname(sourcePath).toLowerCase();
|
|
577
|
+
if (extension !== '.ts' && extension !== '.tsx' && extension !== '.js' && extension !== '.jsx') {
|
|
578
|
+
return null;
|
|
579
|
+
}
|
|
580
|
+
// A declaration file is already a skeleton; mapping it again would look for `x.d.d.ts`.
|
|
581
|
+
if (sourcePath.toLowerCase().endsWith('.d.ts'))
|
|
582
|
+
return null;
|
|
583
|
+
const withoutExtension = sourcePath.slice(0, sourcePath.length - extension.length);
|
|
584
|
+
// The segment after the source root is what the declaration tree preserves. `src` is the root this
|
|
585
|
+
// repository builds with, and a path that does not contain it maps from its own basename so that a
|
|
586
|
+
// caller passing a bare filename still gets a sensible answer.
|
|
587
|
+
const marker = 'src' + path.sep;
|
|
588
|
+
const normalised = withoutExtension.split('/').join(path.sep);
|
|
589
|
+
const at = normalised.toLowerCase().lastIndexOf(marker.toLowerCase());
|
|
590
|
+
const relative = at >= 0 ? normalised.slice(at + marker.length) : path.basename(normalised);
|
|
591
|
+
return path.join(declarationRoot, relative + '.d.ts');
|
|
592
|
+
}
|
|
593
|
+
/**
|
|
594
|
+
* Is a declaration STALE relative to the source it is supposed to describe?
|
|
595
|
+
*
|
|
596
|
+
* The egress control redirects a source read to `<root>/x.d.ts`. Until this existed it verified only that
|
|
597
|
+
* the file EXISTED, never that it still described the source — so editing an interface and skipping the
|
|
598
|
+
* build served the architect yesterday's signature, and nothing anywhere would have said so. That is the
|
|
599
|
+
* same shape as every false green this repository has caught: healthy under test, because a test suite
|
|
600
|
+
* always runs against a freshly built tree, and wrong exactly when a human is working.
|
|
601
|
+
*
|
|
602
|
+
* Returns a reason when stale, or null when the declaration is fresh OR when either timestamp cannot be
|
|
603
|
+
* read. An unreadable stat deliberately returns null rather than a reason: the caller's next step is to read
|
|
604
|
+
* the declaration, which has its own fail-closed path when that file is missing, and inventing a staleness
|
|
605
|
+
* verdict from a failed stat would report a cause it has not established.
|
|
606
|
+
*
|
|
607
|
+
* WHAT THIS IS NOT: a content digest. mtime is evidence that a rebuild happened after an edit, and a
|
|
608
|
+
* checkout, a `touch`, or clock skew can defeat it. It closes the common case -- an operator planning
|
|
609
|
+
* without running the build -- and it does not prove the declaration corresponds to the source. Binding
|
|
610
|
+
* those two cryptographically is a larger change than this gap warrants, and claiming it here would be the
|
|
611
|
+
* overstatement this file exists to avoid.
|
|
612
|
+
*/
|
|
613
|
+
function staleDeclarationReason(sourcePath, declarationPath) {
|
|
614
|
+
try {
|
|
615
|
+
const sourceStat = fs.statSync(sourcePath);
|
|
616
|
+
const declarationStat = fs.statSync(declarationPath);
|
|
617
|
+
if (declarationStat.mtimeMs < sourceStat.mtimeMs) {
|
|
618
|
+
return ("its type skeleton at '" +
|
|
619
|
+
declarationPath +
|
|
620
|
+
"' is OLDER than the source, so it may describe a signature that has since changed");
|
|
621
|
+
}
|
|
622
|
+
return null;
|
|
623
|
+
}
|
|
624
|
+
catch {
|
|
625
|
+
return null;
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
/**
|
|
629
|
+
* The verdict for every delegated file a target could name: settled only when all of them are. Shared by
|
|
630
|
+
* the read tool and the shell route so the two cannot drift into disagreeing about the same file, and
|
|
631
|
+
* conservative because a relative target can genuinely name more than one -- the guard infers the
|
|
632
|
+
* workspace rather than being told it, and taking the permissive match would wave a failed file through.
|
|
633
|
+
*/
|
|
634
|
+
function evaluateMatchedReads(matched, config) {
|
|
635
|
+
const recordFor = config.delegatedRecordFor ?? contracts_1.lookupDelegatedRecord;
|
|
636
|
+
const unsettled = [];
|
|
637
|
+
for (const p of matched) {
|
|
638
|
+
const verdict = evaluateSettledFile(recordFor(p), (0, contracts_1.sha256File)(p));
|
|
639
|
+
if (!verdict.allowed)
|
|
640
|
+
unsettled.push(verdict);
|
|
641
|
+
}
|
|
642
|
+
if (unsettled.length === 0) {
|
|
643
|
+
return { allowed: true, reason: 'every matching delegated file is settled.' };
|
|
644
|
+
}
|
|
645
|
+
const extra = unsettled.length > 1 ? ` (${unsettled.length} delegated files could match this path.)` : '';
|
|
646
|
+
return { allowed: false, reason: `${unsettled[0].reason}${extra}` };
|
|
647
|
+
}
|
|
648
|
+
/**
|
|
649
|
+
* Decide whether a tool call would author source code from the cloud context.
|
|
650
|
+
* Pure and exported so it can be unit-tested without a running server.
|
|
651
|
+
* Returns null when the call has nothing to do with code authoring.
|
|
652
|
+
*/
|
|
653
|
+
function evaluateCodeWriteGuard(exec, config = {}) {
|
|
654
|
+
const name = String(exec?.name || '');
|
|
655
|
+
const args = exec?.arguments;
|
|
656
|
+
const askPaths = config.askPaths && config.askPaths.length > 0 ? config.askPaths : DEFAULT_GUARD_ASK_PATHS;
|
|
657
|
+
const reason = (target) => `Writing source file '${target}' from the cloud context is blocked by the local-only code guard. ` +
|
|
658
|
+
`Delegate new files to the local worker with delegate_worker, passing targetFiles and workspaceDir. ` +
|
|
659
|
+
`The worker has no repository read, so it cannot modify an existing file; plan that as a delta.`;
|
|
660
|
+
// Reading a file the architect delegated pulls that code back into its context, which is the noise
|
|
661
|
+
// delegation exists to keep out. But not every delegated file is noise: one a unit passed and nothing
|
|
662
|
+
// has touched since is finished work, and reading source in order to engineer is what the architect is
|
|
663
|
+
// for. The rule is settled versus in-flight, and it says which one it applied.
|
|
664
|
+
// A read names a path, a search names a pattern and a scope, and a shell command names whatever its arguments name — so the candidates are gathered per case and then judged by the one rule the branch already applied.
|
|
665
|
+
const readCall = (() => {
|
|
666
|
+
if (exports.READ_TOOLS.has(name)) {
|
|
667
|
+
const target = extractWriteTarget(args);
|
|
668
|
+
if (!target)
|
|
669
|
+
return null;
|
|
670
|
+
return { target, matched: matchDelegatedPaths(target, config.delegatedPaths) };
|
|
671
|
+
}
|
|
672
|
+
if (exports.SEARCH_TOOLS.has(name)) {
|
|
673
|
+
const a = (args ?? {});
|
|
674
|
+
const scope = String(a.path ?? a.file_path ?? a.target ?? '');
|
|
675
|
+
const matched = [
|
|
676
|
+
...matchDelegatedPaths(scope, config.delegatedPaths),
|
|
677
|
+
...delegatedUnder(scope, config.delegatedPaths),
|
|
678
|
+
].filter((v, i, all) => all.indexOf(v) === i);
|
|
679
|
+
return { target: scope || '(workspace)', matched };
|
|
680
|
+
}
|
|
681
|
+
if (exports.SHELL_TOOLS.has(name)) {
|
|
682
|
+
const command = String((args ?? {}).command ?? '');
|
|
683
|
+
if (!command)
|
|
684
|
+
return null;
|
|
685
|
+
// A shell command has to actually read something to count as a read. Without this, any command
|
|
686
|
+
// that merely names a directory containing delegated files would prompt — `npm --prefix plugin run build`,
|
|
687
|
+
// even `cd plugin` — and a gate that fires on ordinary commands is one an operator turns off.
|
|
688
|
+
//
|
|
689
|
+
// The prefilter and `commandReadsContent` answer DIFFERENT questions, and this one must be the
|
|
690
|
+
// WIDER of the two. A metadata command has to pass here so that `commandReadsContent` can then
|
|
691
|
+
// correctly report that it reads no bytes -- if this set were narrowed to content readers, the
|
|
692
|
+
// listing commands would exit right here instead, and the distinction would be lost rather than
|
|
693
|
+
// enforced. It must nonetheless include every content reader, and it did not: `gc` was in
|
|
694
|
+
// CONTENT_READERS but not here, so `gc file.ts` returned null before containment was considered.
|
|
695
|
+
// The extension strip lives in `shellCommandName` so both lists agree on `gc.ps1` too.
|
|
696
|
+
const hasReadSignal = command
|
|
697
|
+
.split(/[\s'"|;&]+/)
|
|
698
|
+
.filter(Boolean)
|
|
699
|
+
.some((t) => {
|
|
700
|
+
const bare = shellCommandName(t);
|
|
701
|
+
return READ_ONLY_INSPECTORS.has(bare) || CONTENT_READERS.has(bare);
|
|
702
|
+
});
|
|
703
|
+
if (!hasReadSignal)
|
|
704
|
+
return null;
|
|
705
|
+
const matched = [];
|
|
706
|
+
// Containment is only inferred for a command that returns CONTENT. A command that merely lists or
|
|
707
|
+
// tests a directory reads no delegated bytes, so matching it would gate ordinary housekeeping --
|
|
708
|
+
// and the fail-closed argument does not apply, because there is nothing to fail closed about.
|
|
709
|
+
const infersContainment = commandReadsContent(command);
|
|
710
|
+
for (const token of command.split(/[\s'"]+/)) {
|
|
711
|
+
if (!token || token.length < 2 || token.startsWith('-'))
|
|
712
|
+
continue;
|
|
713
|
+
for (const hit of matchDelegatedPaths(token, config.delegatedPaths)) {
|
|
714
|
+
if (!matched.includes(hit))
|
|
715
|
+
matched.push(hit);
|
|
716
|
+
}
|
|
717
|
+
if (!infersContainment)
|
|
718
|
+
continue;
|
|
719
|
+
for (const hit of delegatedUnder(token, config.delegatedPaths)) {
|
|
720
|
+
if (!matched.includes(hit))
|
|
721
|
+
matched.push(hit);
|
|
722
|
+
}
|
|
723
|
+
for (const hit of delegatedUnderDirectory(token, config.delegatedPaths)) {
|
|
724
|
+
if (!matched.includes(hit))
|
|
725
|
+
matched.push(hit);
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
return matched.length ? { target: command.slice(0, 80), matched } : null;
|
|
729
|
+
}
|
|
730
|
+
return null;
|
|
731
|
+
})();
|
|
732
|
+
if (readCall && readCall.matched.length > 0) {
|
|
733
|
+
const target = readCall.target;
|
|
734
|
+
const matched = readCall.matched;
|
|
735
|
+
// The setting is the ceiling and is checked first, so both hatches keep working exactly as they did:
|
|
736
|
+
// 'allow' is the documented rule-3 weakening, and 'deny' refuses even settled work.
|
|
737
|
+
const delegatedRead = evaluateDelegatedReadPolicy(config.delegateReadPolicy);
|
|
738
|
+
if (delegatedRead.kind === 'allow')
|
|
739
|
+
return null;
|
|
740
|
+
if (delegatedRead.kind === 'deny') {
|
|
741
|
+
return {
|
|
742
|
+
kind: 'deny',
|
|
743
|
+
target,
|
|
744
|
+
reason: `'${target}' was written by a delegated worker. ${delegatedRead.reason}`,
|
|
745
|
+
};
|
|
746
|
+
}
|
|
747
|
+
const settled = evaluateMatchedReads(matched, config);
|
|
748
|
+
if (settled.allowed)
|
|
749
|
+
return null;
|
|
750
|
+
return {
|
|
751
|
+
kind: 'ask',
|
|
752
|
+
target,
|
|
753
|
+
reason: `'${target}' was written by a delegated worker and is not settled: ${settled.reason} Reading it ` +
|
|
754
|
+
`pulls that code into the cloud architect's context, and the worker has no repository read, so it ` +
|
|
755
|
+
`cannot summarise the file back instead. Approve only if you need the contents here, or re-plan ` +
|
|
756
|
+
`the unit so that it does not.`,
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
if (WRITE_TOOLS.has(name)) {
|
|
760
|
+
const target = extractWriteTarget(args);
|
|
761
|
+
if (!target)
|
|
762
|
+
return null;
|
|
763
|
+
if (!paths_1.CODE_EXTENSIONS.has(path.extname(target).toLowerCase()))
|
|
764
|
+
return null;
|
|
765
|
+
const normalized = target.replace(/\\/g, '/').toLowerCase();
|
|
766
|
+
// A contract test is the specification the worker is held to, not the implementation. Rule 2
|
|
767
|
+
// forbids the architect authoring source and rule 7 needs the architect to own those tests, so
|
|
768
|
+
// this is the one declared exception, and its scope is the operator's to widen or refuse.
|
|
769
|
+
const contractPaths = config.contractPaths ?? ['tests/'];
|
|
770
|
+
const isContract = contractPaths.some((fragment) => normalized.includes(String(fragment).replace(/\\/g, '/').toLowerCase()));
|
|
771
|
+
if (isContract) {
|
|
772
|
+
// Defaults to 'ask', not 'allow'. guardAskPaths already makes tests/ approval-eligible, and
|
|
773
|
+
// silently withdrawing that prompt would be a weakening nobody asked for; opting in is explicit.
|
|
774
|
+
const mode = config.contractWriteMode ?? 'ask';
|
|
775
|
+
if (mode === 'allow')
|
|
776
|
+
return null;
|
|
777
|
+
if (mode === 'deny') {
|
|
778
|
+
return {
|
|
779
|
+
kind: 'deny',
|
|
780
|
+
target,
|
|
781
|
+
reason: `'${target}' is a contract file, and contractWriteMode is 'deny'. A contract test is the ` +
|
|
782
|
+
`specification the worker is held to, so this refusal is deliberate: set ` +
|
|
783
|
+
`contractWriteMode: 'allow' to author contract files, or narrow contractPaths.`,
|
|
784
|
+
};
|
|
785
|
+
}
|
|
786
|
+
// 'ask' falls through to the ordinary rule deliberately. Where guardAskPaths already makes this
|
|
787
|
+
// path ask-eligible the write still asks, with the message it always had. Short-circuiting here
|
|
788
|
+
// would have replaced the reason for every test write in the repository and broken assertions
|
|
789
|
+
// that predate this carve-out by a long way -- which is exactly what it did on the first attempt.
|
|
790
|
+
}
|
|
791
|
+
const downgrade = askPaths.some((fragment) => normalized.includes(String(fragment).replace(/\\/g, '/').toLowerCase()));
|
|
792
|
+
return {
|
|
793
|
+
kind: downgrade ? 'ask' : 'deny',
|
|
794
|
+
target,
|
|
795
|
+
reason: reason(target) +
|
|
796
|
+
(downgrade ? ' This path is approval-eligible (architect-owned test/tooling).' : ''),
|
|
797
|
+
};
|
|
798
|
+
}
|
|
799
|
+
if (exports.SHELL_TOOLS.has(name)) {
|
|
800
|
+
const command = args?.command ?? args?.script ?? '';
|
|
801
|
+
const target = shellWriteTarget(command);
|
|
802
|
+
if (target) {
|
|
803
|
+
return {
|
|
804
|
+
kind: 'ask',
|
|
805
|
+
target,
|
|
806
|
+
reason: `Shell command appears to write source file '${target}'. Shell-based writes cannot be ` +
|
|
807
|
+
`attributed reliably, so this requires explicit approval; prefer delegate_worker for code work.`,
|
|
808
|
+
};
|
|
809
|
+
}
|
|
810
|
+
// A script can write anything the command line never mentions, which is how the
|
|
811
|
+
// plugin's own source was edited during development. Inspect what it invokes.
|
|
812
|
+
const viaScript = findWriteViaScript(command, config.readScript ?? defaultReadScript, config.scriptDepth ?? 2);
|
|
813
|
+
if (viaScript) {
|
|
814
|
+
return {
|
|
815
|
+
kind: 'ask',
|
|
816
|
+
target: viaScript.target,
|
|
817
|
+
reason: `Shell command invokes '${viaScript.script}', which appears to write source file ` +
|
|
818
|
+
`'${viaScript.target}'. Script-mediated writes are invisible to a command-line scan, ` +
|
|
819
|
+
`so this requires explicit approval; prefer delegate_worker for code work.`,
|
|
820
|
+
};
|
|
821
|
+
}
|
|
822
|
+
// A command line that names a source file AND carries a write signal cannot be
|
|
823
|
+
// cleared by matching the write target itself: `cp`, `git checkout`, a real
|
|
824
|
+
// redirection, or inline program text all write a file the pattern never sees.
|
|
825
|
+
const referenced = longestCodeReference(command);
|
|
826
|
+
if (referenced) {
|
|
827
|
+
const writes = hasCommandWriteSignal(command);
|
|
828
|
+
const deletes = hasCommandDeleteSignal(command);
|
|
829
|
+
if (writes || deletes) {
|
|
830
|
+
return {
|
|
831
|
+
kind: 'ask',
|
|
832
|
+
target: referenced,
|
|
833
|
+
reason: deletes && !writes
|
|
834
|
+
? `Shell command would delete source file '${referenced}'. Destroying source from the ` +
|
|
835
|
+
`cloud context is gated the same way as writing it, so this requires explicit approval; ` +
|
|
836
|
+
`prefer delegate_worker for code work.`
|
|
837
|
+
: `Shell command names source file '${referenced}' and carries a write signal, so it may ` +
|
|
838
|
+
`author source from the cloud context. Command-line inspection cannot prove otherwise, so ` +
|
|
839
|
+
`this requires explicit approval; prefer delegate_worker for code work.`,
|
|
840
|
+
};
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
// Reading a delegated file through the shell is the same read by another route, so it answers to the
|
|
844
|
+
// same rule: settled work passes, in-flight work asks.
|
|
845
|
+
const delegatedRead = findDelegatedRead(command, config.delegatedPaths);
|
|
846
|
+
if (delegatedRead) {
|
|
847
|
+
const readPolicy = evaluateDelegatedReadPolicy(config.delegateReadPolicy);
|
|
848
|
+
if (readPolicy.kind === 'allow')
|
|
849
|
+
return null;
|
|
850
|
+
if (readPolicy.kind === 'deny') {
|
|
851
|
+
return {
|
|
852
|
+
kind: 'deny',
|
|
853
|
+
target: delegatedRead.form,
|
|
854
|
+
reason: `Shell command reads '${delegatedRead.form}', which a delegated worker wrote. ` +
|
|
855
|
+
`${readPolicy.reason}`,
|
|
856
|
+
};
|
|
857
|
+
}
|
|
858
|
+
const settled = evaluateMatchedReads(delegatedRead.matches, config);
|
|
859
|
+
if (settled.allowed)
|
|
860
|
+
return null;
|
|
861
|
+
return {
|
|
862
|
+
kind: 'ask',
|
|
863
|
+
target: delegatedRead.form,
|
|
864
|
+
reason: `Shell command reads '${delegatedRead.form}', which a delegated worker wrote, and it is not ` +
|
|
865
|
+
`settled: ${settled.reason} Reading it pulls that code into the cloud architect's context, and ` +
|
|
866
|
+
`the worker has no repository read to summarise it back instead. Approve only if you need the ` +
|
|
867
|
+
`contents here.`,
|
|
868
|
+
};
|
|
869
|
+
}
|
|
870
|
+
return null;
|
|
871
|
+
}
|
|
872
|
+
return null;
|
|
873
|
+
}
|