@causyne/agent 1.10.21 → 1.11.0
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/native-skills/media/professional-document-publishing/SKILL.md +44 -0
- package/native-skills/media/screenplay-and-shotlist-forge/SKILL.md +42 -0
- package/package.json +1 -1
- package/src/acp/client.js +235 -0
- package/src/acp/client.ts +282 -0
- package/src/acp/peerProfiles.js +122 -0
- package/src/acp/peerProfiles.ts +128 -0
- package/src/acp/protocol.js +54 -0
- package/src/acp/protocol.ts +434 -0
- package/src/acp/server.js +609 -0
- package/src/acp/server.ts +676 -0
- package/src/agentLoop.d.ts +43 -0
- package/src/agentLoop.js +142 -8
- package/src/commands.js +203 -0
- package/src/compiledSkills.js +10 -0
- package/src/documentEngine/antiSlopAuditor.js +156 -0
- package/src/documentEngine/antiSlopAuditor.ts +221 -0
- package/src/documentEngine/documentForge.js +443 -0
- package/src/documentEngine/documentForge.ts +519 -0
- package/src/documentEngine/screenplayFormatter.js +336 -0
- package/src/documentEngine/screenplayFormatter.ts +407 -0
- package/src/entrypoint.js +13 -0
- package/src/execSafe.js +10 -13
- package/src/execSafe.ts +25 -0
- package/src/modelRouter.js +56 -123
- package/src/modelRouter.ts +134 -0
- package/src/observer/advisory.js +164 -0
- package/src/observer/advisory.ts +171 -0
- package/src/observer/coldObserver.js +462 -0
- package/src/observer/coldObserver.ts +540 -0
- package/src/observer/evidence.js +435 -0
- package/src/observer/evidence.ts +499 -0
- package/src/perception.js +303 -118
- package/src/perception.ts +387 -0
- package/src/procSignal.js +15 -16
- package/src/procSignal.ts +31 -0
- package/src/promptMentions.js +170 -0
- package/src/promptMentions.ts +212 -0
- package/src/sandbox.js +1 -1
- package/src/textDiff.js +117 -109
- package/src/textDiff.ts +169 -0
- package/src/toolPacks.js +5 -2
- package/src/tools/documentForge.js +215 -0
- package/src/tools/documentForge.ts +265 -0
- package/src/tools/peerAgent.js +102 -0
- package/src/tools/peerAgent.ts +116 -0
- package/src/tools/registry.js +3 -1
- package/src/tui/completion.js +97 -41
- package/src/tui/prompt.js +17 -17
- package/src/uncertainty.js +93 -205
- package/src/uncertainty.ts +255 -0
- package/src/utils/paths.js +15 -18
- package/src/utils/paths.ts +36 -0
- package/src/vfeTracker.js +122 -181
- package/src/vfeTracker.ts +242 -0
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { resolveUserPath } from '../utils/paths.js';
|
|
5
|
+
|
|
6
|
+
export type RiskSeverity = 'low' | 'medium' | 'high' | 'critical';
|
|
7
|
+
|
|
8
|
+
export interface RiskEvidence {
|
|
9
|
+
code: string;
|
|
10
|
+
severity: RiskSeverity;
|
|
11
|
+
weight: number;
|
|
12
|
+
confidence: number;
|
|
13
|
+
detail: string;
|
|
14
|
+
source: 'rules' | 'advisory';
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface MutationAssessment {
|
|
18
|
+
allowed: boolean;
|
|
19
|
+
veto: boolean;
|
|
20
|
+
requiresConfirmation: boolean;
|
|
21
|
+
riskScore: number;
|
|
22
|
+
confidence: number;
|
|
23
|
+
reason: string;
|
|
24
|
+
findings: string[];
|
|
25
|
+
evidence: RiskEvidence[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface MutationContext {
|
|
29
|
+
workspaceRoot: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface ToolOutcome {
|
|
33
|
+
tool: string;
|
|
34
|
+
args: Record<string, unknown>;
|
|
35
|
+
success: boolean;
|
|
36
|
+
error: string;
|
|
37
|
+
exitCode: number | null;
|
|
38
|
+
command: string;
|
|
39
|
+
path: string;
|
|
40
|
+
at: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const SEVERITY_WEIGHT: Record<RiskSeverity, number> = {
|
|
44
|
+
low: 0.3,
|
|
45
|
+
medium: 0.55,
|
|
46
|
+
high: 0.78,
|
|
47
|
+
critical: 1
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const CRITICAL_PROJECT_FILES = new Set([
|
|
51
|
+
'package.json',
|
|
52
|
+
'package-lock.json',
|
|
53
|
+
'pnpm-lock.yaml',
|
|
54
|
+
'yarn.lock',
|
|
55
|
+
'bun.lockb',
|
|
56
|
+
'tsconfig.json',
|
|
57
|
+
'tsconfig.runtime.json',
|
|
58
|
+
'tsconfig.strict.json',
|
|
59
|
+
'.env',
|
|
60
|
+
'.env.production',
|
|
61
|
+
'.env.local'
|
|
62
|
+
]);
|
|
63
|
+
|
|
64
|
+
const CODE_EXTENSIONS = new Set([
|
|
65
|
+
'.js', '.mjs', '.cjs', '.jsx', '.ts', '.tsx', '.py', '.rb', '.php',
|
|
66
|
+
'.go', '.rs', '.java', '.kt', '.kts', '.swift', '.c', '.cc', '.cpp',
|
|
67
|
+
'.h', '.hpp', '.cs', '.fs', '.sh', '.bash', '.zsh', '.fish', '.sql',
|
|
68
|
+
'.html', '.css', '.scss', '.sass', '.vue', '.svelte'
|
|
69
|
+
]);
|
|
70
|
+
|
|
71
|
+
const VERIFICATION_COMMAND = /\b(?:(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?(?:test|check|build|lint|typecheck|types)|node\s+--test|pytest|py_compile|compileall|cargo\s+(?:test|check)|go\s+(?:test|vet)|mvn\s+test|gradle\s+test|dotnet\s+test|swift\s+test|tsc(?:\s|$)|eslint|biome\s+(?:check|ci)|ruby\s+-c|php\s+-l)\b/i;
|
|
72
|
+
|
|
73
|
+
const HIGH_RISK_COMMANDS: Array<{ pattern: RegExp; code: string; detail: string; severity?: RiskSeverity }> = [
|
|
74
|
+
{ pattern: /\bgit\s+reset\s+--hard\b/i, code: 'git-reset-hard', detail: 'git reset --hard discards uncommitted work' },
|
|
75
|
+
{ pattern: /\bgit\s+clean\s+-[a-z]*[fdx][a-z]*/i, code: 'git-clean', detail: 'git clean permanently removes untracked files' },
|
|
76
|
+
{ pattern: /\bgit\s+checkout\s+--\s+\./i, code: 'git-checkout-all', detail: 'checkout -- . discards working-tree changes' },
|
|
77
|
+
{ pattern: /\bgit\s+push\b[^\n]*(?:--force|-f)\b/i, code: 'git-force-push', detail: 'force push rewrites shared history' },
|
|
78
|
+
{ pattern: /\b(?:curl|wget)\b[^\n|]*\|\s*(?:sudo\s+)?(?:ba|z|da)?sh\b/i, code: 'remote-shell', detail: 'piping downloaded content into a shell executes unreviewed code' },
|
|
79
|
+
{ pattern: /\bchmod\s+(?:-[A-Za-z]*R[A-Za-z]*\s+)?0?777\b/i, code: 'chmod-777', detail: 'world-writable permissions weaken the project', severity: 'medium' },
|
|
80
|
+
{ pattern: /\bchown\s+-[A-Za-z]*R[A-Za-z]*\b/i, code: 'chown-recursive', detail: 'recursive ownership changes can make files unusable', severity: 'high' },
|
|
81
|
+
{ pattern: /\b(?:DROP|TRUNCATE)\s+(?:DATABASE|SCHEMA|TABLE)\b/i, code: 'sql-destructive', detail: 'destructive SQL can remove stored data' },
|
|
82
|
+
{ pattern: /\b(?:npm|pnpm|yarn)\s+publish\b/i, code: 'package-publish', detail: 'publishing changes a public package' },
|
|
83
|
+
{ pattern: /\b(?:docker|podman)\s+system\s+prune\b/i, code: 'container-prune', detail: 'pruning containers and images removes local state' },
|
|
84
|
+
{ pattern: /\bfind\s+(?:\/|~|\$HOME)\b[^\n]*-delete\b/i, code: 'find-root-delete', detail: 'deleting files from a system or home root is destructive' },
|
|
85
|
+
{ pattern: /\btruncate\s+-s\s*0\b/i, code: 'file-truncate', detail: 'truncating a file destroys its current contents' },
|
|
86
|
+
{ pattern: /\b(?:shutdown|reboot|halt|poweroff)\b/i, code: 'system-power', detail: 'power state changes interrupt every running process' },
|
|
87
|
+
{ pattern: /\bsudo\b/i, code: 'privilege-escalation', detail: 'elevated privileges expand the blast radius', severity: 'medium' },
|
|
88
|
+
{ pattern: /\bkill\s+-9\s+(?:-1|1)\b/i, code: 'kill-all', detail: 'killing every process can take down the session' }
|
|
89
|
+
];
|
|
90
|
+
|
|
91
|
+
const CRITICAL_COMMANDS: Array<{ pattern: RegExp; code: string; detail: string }> = [
|
|
92
|
+
{ pattern: /\bmkfs(?:\.\w+)?\b/i, code: 'filesystem-format', detail: 'formatting a filesystem destroys its contents' },
|
|
93
|
+
{ pattern: /\bdd\b[^\n]*\bof=\/dev\//i, code: 'raw-device-write', detail: 'writing directly to a device destroys its filesystem' },
|
|
94
|
+
{ pattern: />\s*\/dev\/(?:sd|disk|nvme|hd)/i, code: 'raw-device-redirect', detail: 'redirecting output to a device destroys its contents' },
|
|
95
|
+
{ pattern: /:\s*\(\s*\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:/, code: 'fork-bomb', detail: 'a fork bomb exhausts process resources' },
|
|
96
|
+
{ pattern: /\b(?:rm|shred)\b[^\n]*--no-preserve-root\b/i, code: 'no-preserve-root', detail: 'removing the root filesystem protection is catastrophic' },
|
|
97
|
+
{ pattern: /\bchmod\s+-[A-Za-z]*R[A-Za-z]*\s+0?777\s+\/(?:\s|$)/i, code: 'root-permissions', detail: 'making the filesystem root world-writable is catastrophic' }
|
|
98
|
+
];
|
|
99
|
+
|
|
100
|
+
function add(
|
|
101
|
+
evidence: RiskEvidence[],
|
|
102
|
+
code: string,
|
|
103
|
+
severity: RiskSeverity,
|
|
104
|
+
detail: string,
|
|
105
|
+
confidence: number,
|
|
106
|
+
source: RiskEvidence['source'] = 'rules'
|
|
107
|
+
): void {
|
|
108
|
+
if (evidence.some((item) => item.code === code)) return;
|
|
109
|
+
evidence.push({
|
|
110
|
+
code,
|
|
111
|
+
severity,
|
|
112
|
+
weight: SEVERITY_WEIGHT[severity],
|
|
113
|
+
confidence,
|
|
114
|
+
detail,
|
|
115
|
+
source
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function realpathLoose(target: string): string {
|
|
120
|
+
let current = target;
|
|
121
|
+
const suffix: string[] = [];
|
|
122
|
+
while (true) {
|
|
123
|
+
try {
|
|
124
|
+
return path.join(fs.realpathSync(current), ...suffix.reverse());
|
|
125
|
+
} catch {
|
|
126
|
+
const parent = path.dirname(current);
|
|
127
|
+
if (parent === current) return target;
|
|
128
|
+
suffix.push(path.basename(current));
|
|
129
|
+
current = parent;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function safeRealpath(target: string): string {
|
|
135
|
+
try {
|
|
136
|
+
return fs.realpathSync(target);
|
|
137
|
+
} catch {
|
|
138
|
+
return path.resolve(target);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function physicalRoot(workspaceRoot: string): string {
|
|
143
|
+
return realpathLoose(path.resolve(workspaceRoot));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function resolveTarget(workspaceRoot: string, raw: string): { lexical: string; physical: string; inside: boolean } {
|
|
147
|
+
const lexical = resolveUserPath(raw);
|
|
148
|
+
const physical = realpathLoose(lexical);
|
|
149
|
+
const root = physicalRoot(workspaceRoot);
|
|
150
|
+
const relative = path.relative(root, physical);
|
|
151
|
+
const inside = relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative));
|
|
152
|
+
return { lexical, physical, inside };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function pathArgument(args: Record<string, unknown>): string {
|
|
156
|
+
for (const key of ['path', 'file_path', 'targetFile', 'file', 'source', 'destination', 'target', 'cwd']) {
|
|
157
|
+
const value = args[key];
|
|
158
|
+
if (typeof value === 'string' && value.trim()) return value.trim();
|
|
159
|
+
}
|
|
160
|
+
return '';
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function toolPath(args: Record<string, unknown>): string {
|
|
164
|
+
return pathArgument(args);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function isCodePath(target: string): boolean {
|
|
168
|
+
return CODE_EXTENSIONS.has(path.extname(target).toLowerCase());
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function isVerificationCommand(command: string): boolean {
|
|
172
|
+
return VERIFICATION_COMMAND.test(command);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function readSize(target: string): number | null {
|
|
176
|
+
try {
|
|
177
|
+
const stat = fs.statSync(target);
|
|
178
|
+
return stat.isFile() ? stat.size : null;
|
|
179
|
+
} catch {
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function tokenizeTargets(segment: string): { recursive: boolean; targets: string[] } {
|
|
185
|
+
const tokens: string[] = [];
|
|
186
|
+
let current = '';
|
|
187
|
+
let quote = '';
|
|
188
|
+
for (let index = 0; index < segment.length; index += 1) {
|
|
189
|
+
const character = segment[index];
|
|
190
|
+
if (character === undefined) continue;
|
|
191
|
+
if (quote) {
|
|
192
|
+
if (character === quote) quote = '';
|
|
193
|
+
else current += character;
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
if (character === '"' || character === "'") {
|
|
197
|
+
quote = character;
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
if (/\s/.test(character)) {
|
|
201
|
+
if (current) {
|
|
202
|
+
tokens.push(current);
|
|
203
|
+
current = '';
|
|
204
|
+
}
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
current += character;
|
|
208
|
+
}
|
|
209
|
+
if (current) tokens.push(current);
|
|
210
|
+
|
|
211
|
+
let recursive = false;
|
|
212
|
+
const targets: string[] = [];
|
|
213
|
+
let flagsDone = false;
|
|
214
|
+
for (const token of tokens) {
|
|
215
|
+
if (!flagsDone && token === '--') {
|
|
216
|
+
flagsDone = true;
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
if (!flagsDone && /^--/.test(token)) {
|
|
220
|
+
if (token === '--recursive') recursive = true;
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
if (!flagsDone && /^-[A-Za-z]+$/.test(token)) {
|
|
224
|
+
if (/[rR]/.test(token)) recursive = true;
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
if (!flagsDone && /^-/.test(token)) continue;
|
|
228
|
+
targets.push(token);
|
|
229
|
+
}
|
|
230
|
+
return { recursive, targets };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function scanRemoveCommands(command: string, workspaceRoot: string, evidence: RiskEvidence[]): void {
|
|
234
|
+
const pattern = /\brm\s+([^;&|\n]+)/gi;
|
|
235
|
+
for (const match of command.matchAll(pattern)) {
|
|
236
|
+
const segment = match[1];
|
|
237
|
+
if (!segment) continue;
|
|
238
|
+
const { recursive, targets } = tokenizeTargets(segment);
|
|
239
|
+
for (const rawTarget of targets) {
|
|
240
|
+
const expanded = rawTarget
|
|
241
|
+
.replace(/^\$\{HOME\}/, os.homedir())
|
|
242
|
+
.replace(/^\$HOME\b/, os.homedir())
|
|
243
|
+
.replace(/^~(?=\/|$)/, os.homedir());
|
|
244
|
+
const wildcard = expanded === '*' || expanded === './*' || expanded === '.*';
|
|
245
|
+
const resolved = resolveTarget(workspaceRoot, wildcard ? workspaceRoot : expanded);
|
|
246
|
+
const target = resolved.physical;
|
|
247
|
+
const root = physicalRoot(workspaceRoot);
|
|
248
|
+
const home = safeRealpath(os.homedir());
|
|
249
|
+
const filesystemRoot = path.parse(target).root;
|
|
250
|
+
|
|
251
|
+
if (wildcard || target === root || target === home || target === filesystemRoot) {
|
|
252
|
+
if (recursive) {
|
|
253
|
+
add(evidence, 'rm-project-root', 'critical', `recursive removal targets the workspace, home, or filesystem root: ${expanded}`, 0.95);
|
|
254
|
+
} else {
|
|
255
|
+
add(evidence, 'rm-root-file', 'high', `removal targets a protected root path: ${expanded}`, 0.85);
|
|
256
|
+
}
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (!resolved.inside) {
|
|
261
|
+
add(
|
|
262
|
+
evidence,
|
|
263
|
+
'rm-outside-workspace',
|
|
264
|
+
recursive ? 'high' : 'medium',
|
|
265
|
+
`recursive removal targets a path outside the workspace: ${expanded}`,
|
|
266
|
+
0.8
|
|
267
|
+
);
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const base = path.basename(target);
|
|
272
|
+
if (recursive && ['.git', 'src', 'app', 'lib', 'packages'].includes(base)) {
|
|
273
|
+
add(evidence, 'rm-critical-directory', 'high', `recursive removal targets a core project directory: ${expanded}`, 0.85);
|
|
274
|
+
} else if (recursive) {
|
|
275
|
+
add(evidence, 'rm-recursive', 'medium', `recursive removal inside the workspace: ${expanded}`, 0.75);
|
|
276
|
+
} else {
|
|
277
|
+
add(evidence, 'rm-file', 'low', `file removal: ${expanded}`, 0.65);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function scanShell(command: string, workspaceRoot: string, evidence: RiskEvidence[]): void {
|
|
284
|
+
if (!command.trim()) return;
|
|
285
|
+
scanRemoveCommands(command, workspaceRoot, evidence);
|
|
286
|
+
for (const rule of CRITICAL_COMMANDS) {
|
|
287
|
+
if (rule.pattern.test(command)) add(evidence, rule.code, 'critical', rule.detail, 0.92);
|
|
288
|
+
}
|
|
289
|
+
for (const rule of HIGH_RISK_COMMANDS) {
|
|
290
|
+
if (rule.pattern.test(command)) add(evidence, rule.code, rule.severity ?? 'high', rule.detail, 0.82);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function scanWrite(toolName: string, args: Record<string, unknown>, workspaceRoot: string, evidence: RiskEvidence[]): void {
|
|
295
|
+
const rawTarget = pathArgument(args);
|
|
296
|
+
if (!rawTarget) {
|
|
297
|
+
add(evidence, 'write-without-path', 'medium', `${toolName} did not declare a target path`, 0.6);
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const resolved = resolveTarget(workspaceRoot, rawTarget);
|
|
302
|
+
const base = path.basename(resolved.physical);
|
|
303
|
+
|
|
304
|
+
if (!resolved.inside) {
|
|
305
|
+
const temporary = resolved.physical.startsWith(safeRealpath(os.tmpdir()));
|
|
306
|
+
add(
|
|
307
|
+
evidence,
|
|
308
|
+
temporary ? 'write-temporary' : 'write-outside-workspace',
|
|
309
|
+
temporary ? 'low' : 'high',
|
|
310
|
+
`${toolName} writes outside the workspace: ${rawTarget}`,
|
|
311
|
+
0.82
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (resolved.physical.includes(`${path.sep}.git${path.sep}`) || base === '.git') {
|
|
316
|
+
add(evidence, 'write-git-metadata', 'high', `write touches repository metadata: ${rawTarget}`, 0.85);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if (CRITICAL_PROJECT_FILES.has(base)) {
|
|
320
|
+
add(evidence, 'write-critical-config', 'high', `write changes a critical project file: ${base}`, 0.8);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (base === 'AGENTS.md') {
|
|
324
|
+
add(evidence, 'write-agent-instructions', 'medium', 'write changes repository agent instructions', 0.7);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const originalSize = readSize(resolved.physical);
|
|
328
|
+
let replacementSize: number | null = null;
|
|
329
|
+
if (toolName === 'fs_write') {
|
|
330
|
+
const content = typeof args.content === 'string' ? args.content : typeof args.text === 'string' ? args.text : '';
|
|
331
|
+
replacementSize = Buffer.byteLength(content, 'utf8');
|
|
332
|
+
if (replacementSize === 0 && originalSize !== null && originalSize > 0) {
|
|
333
|
+
add(evidence, 'write-empty-file', 'high', `write replaces ${base} with an empty file`, 0.85);
|
|
334
|
+
}
|
|
335
|
+
if (base === 'package.json' && replacementSize > 0) {
|
|
336
|
+
try {
|
|
337
|
+
const parsed: unknown = JSON.parse(content);
|
|
338
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
339
|
+
add(evidence, 'invalid-package-json', 'high', 'package.json would not contain a JSON object', 0.9);
|
|
340
|
+
}
|
|
341
|
+
} catch {
|
|
342
|
+
add(evidence, 'invalid-package-json', 'high', 'package.json would contain invalid JSON', 0.9);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
} else {
|
|
346
|
+
const edits = Array.isArray(args.edits) ? args.edits : [];
|
|
347
|
+
let delta = 0;
|
|
348
|
+
let sawEdit = false;
|
|
349
|
+
for (const edit of edits) {
|
|
350
|
+
if (edit === null || typeof edit !== 'object' || Array.isArray(edit)) continue;
|
|
351
|
+
const record = edit as Record<string, unknown>;
|
|
352
|
+
const before = typeof record.old_string === 'string'
|
|
353
|
+
? record.old_string
|
|
354
|
+
: typeof record.searchContent === 'string' ? record.searchContent : null;
|
|
355
|
+
const after = typeof record.new_string === 'string'
|
|
356
|
+
? record.new_string
|
|
357
|
+
: typeof record.replaceContent === 'string' ? record.replaceContent : null;
|
|
358
|
+
if (before === null || after === null) continue;
|
|
359
|
+
delta += Buffer.byteLength(after, 'utf8') - Buffer.byteLength(before, 'utf8');
|
|
360
|
+
sawEdit = true;
|
|
361
|
+
}
|
|
362
|
+
if (sawEdit && originalSize !== null) replacementSize = originalSize + delta;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
if (originalSize !== null && replacementSize !== null && originalSize > 1024 && replacementSize < originalSize * 0.2) {
|
|
366
|
+
const lost = Math.round((1 - replacementSize / originalSize) * 100);
|
|
367
|
+
add(evidence, 'write-large-shrink', 'high', `edit removes about ${lost}% of ${base}`, 0.78);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function scanDelete(args: Record<string, unknown>, workspaceRoot: string, evidence: RiskEvidence[]): void {
|
|
372
|
+
const rawTarget = pathArgument(args);
|
|
373
|
+
if (!rawTarget) {
|
|
374
|
+
add(evidence, 'delete-without-path', 'medium', 'delete did not declare a target', 0.6);
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
const resolved = resolveTarget(workspaceRoot, rawTarget);
|
|
378
|
+
const base = path.basename(resolved.physical);
|
|
379
|
+
|
|
380
|
+
if (!resolved.inside) {
|
|
381
|
+
add(evidence, 'delete-outside-workspace', 'high', `delete targets a path outside the workspace: ${rawTarget}`, 0.88);
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
if (base === '.git' || resolved.physical.includes(`${path.sep}.git${path.sep}`)) {
|
|
385
|
+
add(evidence, 'delete-git-metadata', 'high', `delete targets repository metadata: ${rawTarget}`, 0.9);
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
if (resolved.physical === physicalRoot(workspaceRoot)) {
|
|
389
|
+
add(evidence, 'delete-workspace-root', 'critical', `delete targets the workspace root: ${rawTarget}`, 0.95);
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
if (CRITICAL_PROJECT_FILES.has(base)) {
|
|
393
|
+
add(evidence, 'delete-critical-config', 'high', `delete targets a critical project file: ${base}`, 0.9);
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
if (args.recursive === true) {
|
|
397
|
+
add(evidence, 'delete-recursive', 'high', `recursive delete inside the workspace: ${rawTarget}`, 0.82);
|
|
398
|
+
} else {
|
|
399
|
+
add(evidence, 'delete-file', 'medium', `delete targets a project file: ${rawTarget}`, 0.7);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function scanMove(args: Record<string, unknown>, workspaceRoot: string, evidence: RiskEvidence[]): void {
|
|
404
|
+
const source = typeof args.source === 'string' ? args.source : '';
|
|
405
|
+
const destination = typeof args.destination === 'string' ? args.destination : '';
|
|
406
|
+
if (source) {
|
|
407
|
+
const resolved = resolveTarget(workspaceRoot, source);
|
|
408
|
+
if (!resolved.inside) add(evidence, 'move-source-outside', 'medium', `move reads outside the workspace: ${source}`, 0.75);
|
|
409
|
+
const base = path.basename(resolved.physical);
|
|
410
|
+
if (base === '.git' || CRITICAL_PROJECT_FILES.has(base)) {
|
|
411
|
+
add(evidence, 'move-critical-path', 'high', `move targets a critical path: ${source}`, 0.85);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
if (destination) {
|
|
415
|
+
const resolved = resolveTarget(workspaceRoot, destination);
|
|
416
|
+
if (!resolved.inside) add(evidence, 'move-destination-outside', 'high', `move writes outside the workspace: ${destination}`, 0.82);
|
|
417
|
+
}
|
|
418
|
+
if (args.overwrite === true) {
|
|
419
|
+
add(evidence, 'move-overwrite', 'medium', 'move is allowed to overwrite an existing destination', 0.65);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
export function scanMutation(toolName: string, args: Record<string, unknown>, context: MutationContext): {
|
|
424
|
+
evidence: RiskEvidence[];
|
|
425
|
+
riskScore: number;
|
|
426
|
+
confidence: number;
|
|
427
|
+
veto: boolean;
|
|
428
|
+
} {
|
|
429
|
+
const evidence: RiskEvidence[] = [];
|
|
430
|
+
const workspaceRoot = context.workspaceRoot || process.cwd();
|
|
431
|
+
|
|
432
|
+
if (['sysRun', 'sysRunBg', 'terminal_persistent_shell'].includes(toolName)) {
|
|
433
|
+
const command = typeof args.command === 'string'
|
|
434
|
+
? args.command
|
|
435
|
+
: typeof args.cmd === 'string' ? args.cmd : '';
|
|
436
|
+
scanShell(command, workspaceRoot, evidence);
|
|
437
|
+
} else if (toolName === 'gitOp') {
|
|
438
|
+
const command = typeof args.command === 'string' ? `git ${args.command}` : '';
|
|
439
|
+
scanShell(command, workspaceRoot, evidence);
|
|
440
|
+
} else if (toolName === 'fs_write' || toolName === 'fs_edit' || toolName === 'MultiEdit') {
|
|
441
|
+
scanWrite(toolName, args, workspaceRoot, evidence);
|
|
442
|
+
} else if (toolName === 'fs_delete') {
|
|
443
|
+
scanDelete(args, workspaceRoot, evidence);
|
|
444
|
+
} else if (toolName === 'fs_move') {
|
|
445
|
+
scanMove(args, workspaceRoot, evidence);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const critical = evidence.some((item) => item.severity === 'critical');
|
|
449
|
+
let riskScore = 0;
|
|
450
|
+
let confidence = 0;
|
|
451
|
+
for (const item of evidence) {
|
|
452
|
+
riskScore = Math.max(riskScore, item.weight);
|
|
453
|
+
confidence = Math.max(confidence, item.confidence);
|
|
454
|
+
}
|
|
455
|
+
if (!critical && evidence.length > 1) {
|
|
456
|
+
riskScore = Math.min(0.97, riskScore + 0.04 * (evidence.length - 1));
|
|
457
|
+
}
|
|
458
|
+
if (critical) {
|
|
459
|
+
riskScore = 1;
|
|
460
|
+
confidence = Math.max(confidence, 0.9);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
return { evidence, riskScore, confidence, veto: critical };
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
export function boundedArgs(args: Record<string, unknown>): Record<string, unknown> {
|
|
467
|
+
const result: Record<string, unknown> = {};
|
|
468
|
+
for (const key of ['command', 'cmd', 'path', 'file_path', 'source', 'destination', 'action', 'url', 'query', 'pattern']) {
|
|
469
|
+
const value = args[key];
|
|
470
|
+
if (typeof value === 'string') result[key] = value.slice(0, 400);
|
|
471
|
+
}
|
|
472
|
+
if (typeof args.recursive === 'boolean') result.recursive = args.recursive;
|
|
473
|
+
if (typeof args.overwrite === 'boolean') result.overwrite = args.overwrite;
|
|
474
|
+
if (typeof args.content === 'string') result.contentBytes = Buffer.byteLength(args.content, 'utf8');
|
|
475
|
+
if (Array.isArray(args.edits)) result.editCount = args.edits.length;
|
|
476
|
+
return result;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
export function outcomeFromResult(tool: string, args: Record<string, unknown>, result: unknown, at: number): ToolOutcome {
|
|
480
|
+
const record = result !== null && typeof result === 'object' && !Array.isArray(result)
|
|
481
|
+
? result as Record<string, unknown>
|
|
482
|
+
: {};
|
|
483
|
+
const success = record.success !== false && typeof record.error !== 'string';
|
|
484
|
+
const rawError = typeof record.error === 'string' ? record.error : '';
|
|
485
|
+
const exitCode = typeof record.exitCode === 'number' && Number.isFinite(record.exitCode) ? record.exitCode : null;
|
|
486
|
+
const command = typeof args.command === 'string'
|
|
487
|
+
? args.command
|
|
488
|
+
: typeof args.cmd === 'string' ? args.cmd : '';
|
|
489
|
+
return {
|
|
490
|
+
tool,
|
|
491
|
+
args: boundedArgs(args),
|
|
492
|
+
success,
|
|
493
|
+
error: rawError.slice(0, 600),
|
|
494
|
+
exitCode,
|
|
495
|
+
command: command.slice(0, 600),
|
|
496
|
+
path: pathArgument(args).slice(0, 400),
|
|
497
|
+
at
|
|
498
|
+
};
|
|
499
|
+
}
|