diffsplain 0.8.1 → 0.8.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/README.md +2 -4
- package/package.json +1 -1
- package/scripts/build-diff-data.mjs +46 -12
- package/scripts/cli-args.mjs +2 -12
- package/scripts/coding-agents.mjs +35 -68
- package/scripts/generate-summaries.mjs +5 -325
- package/scripts/present.mjs +0 -1
package/README.md
CHANGED
|
@@ -15,9 +15,8 @@ The command opens a local page and compares the checkout with its default
|
|
|
15
15
|
branch. It starts at port `2299` and uses the next free port when needed. You
|
|
16
16
|
need Node.js 22.13 or newer and a signed-in Codex, Claude, Copilot, Cursor, or
|
|
17
17
|
OpenCode CLI. Diffsplain tries them in that order. Cursor Agent must be version
|
|
18
|
-
2026.08.11 or newer
|
|
19
|
-
|
|
20
|
-
review tools cannot access the host. Pull requests also need a signed-in GitHub
|
|
18
|
+
2026.08.11 or newer. It uses the signed-in Cursor CLI in the user's home and
|
|
19
|
+
still contacts the Cursor service. Pull requests also need a signed-in GitHub
|
|
21
20
|
CLI. Once Diffsplain chooses an agent, a failed check or run ends the command;
|
|
22
21
|
it does not switch agents.
|
|
23
22
|
|
|
@@ -52,7 +51,6 @@ Arguments:
|
|
|
52
51
|
| `--batch-size COUNT` | Set the most files per agent pass. The default is `12`; large patches use smaller batches. |
|
|
53
52
|
| `--jobs COUNT` | Set agent passes to run at once. The default is `3`. |
|
|
54
53
|
| `--force` | Regenerate all agent notes instead of using cached notes. |
|
|
55
|
-
| `--skip-safety-checks` | Use an explicitly selected Cursor without its compatibility gate or boundary canary. |
|
|
56
54
|
| `--support-record` | Print a safe JSON record if the review fails. |
|
|
57
55
|
| `--support-record-file FILE` | Write one safe JSON record if the review fails. |
|
|
58
56
|
| `--remote NAME\|URL` | Choose the Git remote. The default is `origin`. |
|
package/package.json
CHANGED
|
@@ -143,11 +143,42 @@ const excludedPaths = new Set(
|
|
|
143
143
|
function command(commandName, commandArgs, options = {}) {
|
|
144
144
|
return execFileSync(commandName, commandArgs, {
|
|
145
145
|
cwd: options.cwd,
|
|
146
|
+
env: options.env,
|
|
146
147
|
encoding: 'utf8',
|
|
147
148
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
148
149
|
});
|
|
149
150
|
}
|
|
150
151
|
|
|
152
|
+
function githubHttpsRemote(url) {
|
|
153
|
+
if (typeof url !== 'string' || !url) return false;
|
|
154
|
+
try {
|
|
155
|
+
const parsed = new URL(url);
|
|
156
|
+
return (
|
|
157
|
+
parsed.protocol === 'https:' &&
|
|
158
|
+
(parsed.hostname === 'github.com' || parsed.hostname === 'www.github.com')
|
|
159
|
+
);
|
|
160
|
+
} catch {
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function runGit(gitArgs, { gitDir, remoteUrl } = {}) {
|
|
166
|
+
const githubHttps = githubHttpsRemote(remoteUrl);
|
|
167
|
+
return command(
|
|
168
|
+
'git',
|
|
169
|
+
[
|
|
170
|
+
...(gitDir ? ['--git-dir', gitDir] : ['-C', repo]),
|
|
171
|
+
...(githubHttps
|
|
172
|
+
? ['-c', 'credential.helper=!gh auth git-credential']
|
|
173
|
+
: []),
|
|
174
|
+
...gitArgs,
|
|
175
|
+
],
|
|
176
|
+
githubHttps
|
|
177
|
+
? { env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } }
|
|
178
|
+
: {},
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
151
182
|
const runRepo = (gitArgs) => command('git', ['-C', repo, ...gitArgs]);
|
|
152
183
|
const tryRepo = (gitArgs) => {
|
|
153
184
|
try {
|
|
@@ -383,16 +414,19 @@ function bareCache(remoteUrl) {
|
|
|
383
414
|
|
|
384
415
|
function fetchInto(cache, remoteUrl, refspecs) {
|
|
385
416
|
try {
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
417
|
+
runGit(
|
|
418
|
+
[
|
|
419
|
+
'fetch',
|
|
420
|
+
'--quiet',
|
|
421
|
+
'--no-tags',
|
|
422
|
+
'--no-write-fetch-head',
|
|
423
|
+
'--no-auto-maintenance',
|
|
424
|
+
'--force',
|
|
425
|
+
remoteUrl,
|
|
426
|
+
...refspecs,
|
|
427
|
+
],
|
|
428
|
+
{ gitDir: cache.path, remoteUrl },
|
|
429
|
+
);
|
|
396
430
|
} catch (error) {
|
|
397
431
|
const detail = error?.stderr?.toString().trim();
|
|
398
432
|
throw new Error(
|
|
@@ -422,7 +456,7 @@ function uniqueMergeBase(runGit, base, head) {
|
|
|
422
456
|
function remoteDefaultBranchInfo(remoteUrl) {
|
|
423
457
|
let raw;
|
|
424
458
|
try {
|
|
425
|
-
raw =
|
|
459
|
+
raw = runGit(['ls-remote', '--symref', remoteUrl, 'HEAD'], { remoteUrl });
|
|
426
460
|
} catch {
|
|
427
461
|
throw new Error('Could not read the remote default branch');
|
|
428
462
|
}
|
|
@@ -441,7 +475,7 @@ function remoteDefaultBranch(remoteUrl) {
|
|
|
441
475
|
function remoteContainsCommits(remoteUrl, commits) {
|
|
442
476
|
let raw;
|
|
443
477
|
try {
|
|
444
|
-
raw =
|
|
478
|
+
raw = runGit(['ls-remote', remoteUrl], { remoteUrl });
|
|
445
479
|
} catch {
|
|
446
480
|
return false;
|
|
447
481
|
}
|
package/scripts/cli-args.mjs
CHANGED
|
@@ -40,7 +40,6 @@ export const cliOptions = defineCliOptions({
|
|
|
40
40
|
'--agent': { kind: 'agent' },
|
|
41
41
|
'--no-agent': { kind: 'no-agent' },
|
|
42
42
|
'--force': { kind: 'flag' },
|
|
43
|
-
'--skip-safety-checks': { kind: 'flag' },
|
|
44
43
|
'--worktree': { kind: 'flag' },
|
|
45
44
|
'--no-browser': { kind: 'flag' },
|
|
46
45
|
'--support-record': { kind: 'flag' },
|
|
@@ -94,8 +93,6 @@ Options:
|
|
|
94
93
|
--batch-size COUNT Maximum files per agent pass (default: ${batchSizeOption.default})
|
|
95
94
|
--jobs COUNT Agent passes to run at once (default: ${jobsOption.default})
|
|
96
95
|
--force Regenerate all agent notes
|
|
97
|
-
--skip-safety-checks
|
|
98
|
-
Use Cursor without compatibility or boundary checks
|
|
99
96
|
--support-record Print a safe record if agent notes fail
|
|
100
97
|
--support-record-file FILE
|
|
101
98
|
Write a safe record if agent notes fail
|
|
@@ -114,8 +111,8 @@ Automatic agent selection:
|
|
|
114
111
|
codex, claude, copilot, cursor, opencode
|
|
115
112
|
|
|
116
113
|
Cursor:
|
|
117
|
-
Requires Cursor Agent 2026.08.11 or newer
|
|
118
|
-
Cursor contacts its service
|
|
114
|
+
Requires Cursor Agent 2026.08.11 or newer. Uses the signed-in CLI in the
|
|
115
|
+
user's home. Cursor still contacts its service.
|
|
119
116
|
|
|
120
117
|
Examples:
|
|
121
118
|
diffsplain
|
|
@@ -274,9 +271,6 @@ export function parseCliArgs(
|
|
|
274
271
|
if (noAgent && options.has('--summaries')) {
|
|
275
272
|
fail('--no-agent cannot be used with --summaries');
|
|
276
273
|
}
|
|
277
|
-
if (options.has('--skip-safety-checks') && agent !== 'cursor') {
|
|
278
|
-
fail('--skip-safety-checks requires --agent cursor');
|
|
279
|
-
}
|
|
280
274
|
if (
|
|
281
275
|
noAgent &&
|
|
282
276
|
(options.has('--support-record') ||
|
|
@@ -374,9 +368,6 @@ export function parseCliArgs(
|
|
|
374
368
|
}
|
|
375
369
|
const agentArgs = [...commonArgs];
|
|
376
370
|
if (options.has('--force')) agentArgs.push('--force');
|
|
377
|
-
if (options.has('--skip-safety-checks')) {
|
|
378
|
-
agentArgs.push('--skip-safety-checks');
|
|
379
|
-
}
|
|
380
371
|
for (const name of [
|
|
381
372
|
'--codex-bin',
|
|
382
373
|
'--model',
|
|
@@ -481,6 +472,5 @@ export function parseCliArgs(
|
|
|
481
472
|
host,
|
|
482
473
|
browserEnabled: !options.has('--no-browser'),
|
|
483
474
|
forceSummaryRegeneration: options.has('--force'),
|
|
484
|
-
skipSafetyChecks: options.has('--skip-safety-checks'),
|
|
485
475
|
};
|
|
486
476
|
}
|
|
@@ -7,7 +7,6 @@ import {
|
|
|
7
7
|
dirname,
|
|
8
8
|
isAbsolute,
|
|
9
9
|
join,
|
|
10
|
-
resolve,
|
|
11
10
|
} from 'node:path';
|
|
12
11
|
|
|
13
12
|
export const codingAgentCapabilities = {
|
|
@@ -63,41 +62,9 @@ export function summaryAgentEnvironment(env = process.env) {
|
|
|
63
62
|
);
|
|
64
63
|
}
|
|
65
64
|
|
|
66
|
-
export function cursorAuthPaths(
|
|
67
|
-
home,
|
|
68
|
-
{
|
|
69
|
-
env = process.env,
|
|
70
|
-
platform = process.platform,
|
|
71
|
-
} = {},
|
|
72
|
-
) {
|
|
73
|
-
if (platform === 'linux') {
|
|
74
|
-
const configHome = env.XDG_CONFIG_HOME ||
|
|
75
|
-
(env.HOME ? resolve(env.HOME, '.config') : undefined);
|
|
76
|
-
return configHome
|
|
77
|
-
? {
|
|
78
|
-
source: resolve(configHome, 'cursor', 'auth.json'),
|
|
79
|
-
destination: resolve(home, '.config', 'cursor', 'auth.json'),
|
|
80
|
-
}
|
|
81
|
-
: undefined;
|
|
82
|
-
}
|
|
83
|
-
if (platform === 'win32') {
|
|
84
|
-
const roaming = env.APPDATA ||
|
|
85
|
-
(env.USERPROFILE
|
|
86
|
-
? resolve(env.USERPROFILE, 'AppData', 'Roaming')
|
|
87
|
-
: undefined);
|
|
88
|
-
return roaming
|
|
89
|
-
? {
|
|
90
|
-
source: resolve(roaming, 'Cursor', 'auth.json'),
|
|
91
|
-
destination: resolve(home, 'AppData', 'Roaming', 'Cursor', 'auth.json'),
|
|
92
|
-
}
|
|
93
|
-
: undefined;
|
|
94
|
-
}
|
|
95
|
-
return undefined;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
65
|
const minimumCursorVersion = [2026, 8, 11];
|
|
99
|
-
const
|
|
100
|
-
'Cursor needs
|
|
66
|
+
const cursorRequirementSummary =
|
|
67
|
+
'Cursor needs version 2026.08.11 or newer with Ask mode, --sandbox, --trust, and --workspace.';
|
|
101
68
|
|
|
102
69
|
export const enabledCodingAgents = codingAgents;
|
|
103
70
|
|
|
@@ -121,8 +88,8 @@ function versionAtLeast(current, minimum) {
|
|
|
121
88
|
return true;
|
|
122
89
|
}
|
|
123
90
|
|
|
124
|
-
function
|
|
125
|
-
return `Cursor
|
|
91
|
+
function cursorCompatibilityError(detail) {
|
|
92
|
+
return `Cursor Agent is incompatible: ${detail} ${cursorRequirementSummary} Upgrade Cursor Agent.`;
|
|
126
93
|
}
|
|
127
94
|
|
|
128
95
|
export function inspectCursorCompatibility(
|
|
@@ -146,7 +113,7 @@ export function inspectCursorCompatibility(
|
|
|
146
113
|
return {
|
|
147
114
|
compatible: false,
|
|
148
115
|
version,
|
|
149
|
-
reason:
|
|
116
|
+
reason: cursorCompatibilityError('The version check failed.'),
|
|
150
117
|
};
|
|
151
118
|
}
|
|
152
119
|
const parts = cursorVersionParts(version);
|
|
@@ -154,7 +121,7 @@ export function inspectCursorCompatibility(
|
|
|
154
121
|
return {
|
|
155
122
|
compatible: false,
|
|
156
123
|
version,
|
|
157
|
-
reason:
|
|
124
|
+
reason: cursorCompatibilityError(
|
|
158
125
|
`Found ${version}; version 2026.08.11 or newer is required.`,
|
|
159
126
|
),
|
|
160
127
|
};
|
|
@@ -165,7 +132,7 @@ export function inspectCursorCompatibility(
|
|
|
165
132
|
return {
|
|
166
133
|
compatible: false,
|
|
167
134
|
version,
|
|
168
|
-
reason:
|
|
135
|
+
reason: cursorCompatibilityError('The CLI help check failed.'),
|
|
169
136
|
};
|
|
170
137
|
}
|
|
171
138
|
const requiredHelp = [
|
|
@@ -173,9 +140,10 @@ export function inspectCursorCompatibility(
|
|
|
173
140
|
['"ask"', 'Ask mode'],
|
|
174
141
|
['--sandbox <mode>', 'sandbox control'],
|
|
175
142
|
['"enabled"', 'sandbox control'],
|
|
176
|
-
['--workspace <path-or-name>', 'workspace
|
|
143
|
+
['--workspace <path-or-name>', 'workspace selection'],
|
|
177
144
|
['--output-format <format>', 'structured output'],
|
|
178
145
|
['--model <model>', 'model selection'],
|
|
146
|
+
['--trust', 'workspace trust control'],
|
|
179
147
|
];
|
|
180
148
|
const missing = requiredHelp
|
|
181
149
|
.filter(([text]) => !help.includes(text))
|
|
@@ -184,7 +152,7 @@ export function inspectCursorCompatibility(
|
|
|
184
152
|
return {
|
|
185
153
|
compatible: false,
|
|
186
154
|
version,
|
|
187
|
-
reason:
|
|
155
|
+
reason: cursorCompatibilityError(
|
|
188
156
|
`The CLI lacks ${[...new Set(missing)].join(', ')}.`,
|
|
189
157
|
),
|
|
190
158
|
};
|
|
@@ -249,7 +217,6 @@ export async function codingAgentAvailability(
|
|
|
249
217
|
binary = codingAgentBinary(agent),
|
|
250
218
|
env = process.env,
|
|
251
219
|
platform = process.platform,
|
|
252
|
-
skipSafetyChecks = false,
|
|
253
220
|
} = {},
|
|
254
221
|
) {
|
|
255
222
|
const path = await findCommand(binary, { env, platform });
|
|
@@ -257,9 +224,6 @@ export async function codingAgentAvailability(
|
|
|
257
224
|
if (agent !== 'cursor') {
|
|
258
225
|
return { available: true, installed: true, path };
|
|
259
226
|
}
|
|
260
|
-
if (skipSafetyChecks) {
|
|
261
|
-
return { available: true, installed: true, path };
|
|
262
|
-
}
|
|
263
227
|
const inspection = inspectCursorCompatibility(path, { env });
|
|
264
228
|
return {
|
|
265
229
|
available: inspection.compatible,
|
|
@@ -329,12 +293,29 @@ export function codingAgentBinary(
|
|
|
329
293
|
);
|
|
330
294
|
}
|
|
331
295
|
|
|
296
|
+
function extractJsonValue(text) {
|
|
297
|
+
const start = text.search(/[{[]/);
|
|
298
|
+
if (start === -1) return undefined;
|
|
299
|
+
const opening = text[start];
|
|
300
|
+
const closing = opening === '{' ? '}' : ']';
|
|
301
|
+
const end = text.lastIndexOf(closing);
|
|
302
|
+
if (end <= start) return undefined;
|
|
303
|
+
try {
|
|
304
|
+
return JSON.parse(text.slice(start, end + 1));
|
|
305
|
+
} catch {
|
|
306
|
+
return undefined;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
332
310
|
function parseJsonText(text, agent) {
|
|
333
311
|
const trimmed = text.trim();
|
|
334
312
|
const fenced = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i);
|
|
313
|
+
const candidate = fenced ? fenced[1] : trimmed;
|
|
335
314
|
try {
|
|
336
|
-
return JSON.parse(
|
|
315
|
+
return JSON.parse(candidate);
|
|
337
316
|
} catch {
|
|
317
|
+
const extracted = extractJsonValue(candidate);
|
|
318
|
+
if (extracted !== undefined) return extracted;
|
|
338
319
|
throw new Error(`${agent} did not return valid summary JSON`);
|
|
339
320
|
}
|
|
340
321
|
}
|
|
@@ -393,12 +374,7 @@ function parseCursorResponse(stdout) {
|
|
|
393
374
|
const trimmed = stdout.trim();
|
|
394
375
|
const lines = trimmed.split('\n').filter(Boolean);
|
|
395
376
|
if (lines.length > 1) {
|
|
396
|
-
|
|
397
|
-
const toolCall = parsed.events.find((event) => event.type === 'tool_call');
|
|
398
|
-
if (toolCall) {
|
|
399
|
-
throw new Error('Cursor emitted an unexpected tool call');
|
|
400
|
-
}
|
|
401
|
-
return parsed.response;
|
|
377
|
+
return parseCursorStreamResponse(trimmed).response;
|
|
402
378
|
}
|
|
403
379
|
const envelope = parseJsonText(trimmed, 'Cursor');
|
|
404
380
|
if (typeof envelope?.result === 'string') {
|
|
@@ -590,17 +566,14 @@ function cursorCommand({
|
|
|
590
566
|
'ask',
|
|
591
567
|
'--sandbox',
|
|
592
568
|
'enabled',
|
|
569
|
+
'--trust',
|
|
593
570
|
'--workspace',
|
|
594
571
|
summaryDirectory,
|
|
595
572
|
];
|
|
596
573
|
if (model) args.push('--model', model);
|
|
597
574
|
args.push(
|
|
598
|
-
`${prompt}\n\
|
|
575
|
+
`${prompt}\n\nRead the snapshot JSON from ${basename(inputPath)} in this workspace. Return JSON that matches this schema:\n${JSON.stringify(schema)}`,
|
|
599
576
|
);
|
|
600
|
-
const controlDirectory = join(dirname(summaryDirectory), 'cursor-control');
|
|
601
|
-
const home = join(controlDirectory, 'home');
|
|
602
|
-
const temporary = join(controlDirectory, 'tmp');
|
|
603
|
-
const invocationName = basename(inputPath).replace(/[^A-Za-z0-9.-]/g, '-');
|
|
604
577
|
return {
|
|
605
578
|
command: binary,
|
|
606
579
|
args,
|
|
@@ -608,16 +581,10 @@ function cursorCommand({
|
|
|
608
581
|
cwd: summaryDirectory,
|
|
609
582
|
env: {
|
|
610
583
|
...summaryEnv,
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
XDG_CONFIG_HOME: join(home, '.config'),
|
|
616
|
-
CURSOR_CONFIG_DIR: join(controlDirectory, 'config'),
|
|
617
|
-
CURSOR_DATA_DIR: join(controlDirectory, `data-${invocationName}`),
|
|
618
|
-
TEMP: temporary,
|
|
619
|
-
TMP: temporary,
|
|
620
|
-
TMPDIR: temporary,
|
|
584
|
+
...(sourceEnv.XDG_CONFIG_HOME
|
|
585
|
+
? { XDG_CONFIG_HOME: sourceEnv.XDG_CONFIG_HOME }
|
|
586
|
+
: {}),
|
|
587
|
+
...(sourceEnv.APPDATA ? { APPDATA: sourceEnv.APPDATA } : {}),
|
|
621
588
|
...(sourceEnv.CURSOR_API_KEY
|
|
622
589
|
? { CURSOR_API_KEY: sourceEnv.CURSOR_API_KEY }
|
|
623
590
|
: {}),
|
|
@@ -3,10 +3,7 @@
|
|
|
3
3
|
import { spawn, spawnSync } from 'node:child_process';
|
|
4
4
|
import { createHash } from 'node:crypto';
|
|
5
5
|
import {
|
|
6
|
-
chmodSync,
|
|
7
|
-
copyFileSync,
|
|
8
6
|
existsSync,
|
|
9
|
-
mkdirSync,
|
|
10
7
|
mkdtempSync,
|
|
11
8
|
readFileSync,
|
|
12
9
|
rmSync,
|
|
@@ -20,9 +17,7 @@ import {
|
|
|
20
17
|
assertReasoningSupported,
|
|
21
18
|
codingAgentAvailability,
|
|
22
19
|
codingAgentBinary,
|
|
23
|
-
cursorAuthPaths,
|
|
24
20
|
parseAgentResponse,
|
|
25
|
-
parseCursorStreamResponse,
|
|
26
21
|
selectCodingAgent,
|
|
27
22
|
} from './coding-agents.mjs';
|
|
28
23
|
import { summaryPath } from './summary-path.mjs';
|
|
@@ -66,7 +61,6 @@ const booleanFlags = new Set([
|
|
|
66
61
|
'--checkout',
|
|
67
62
|
'--force',
|
|
68
63
|
'--support-record',
|
|
69
|
-
'--skip-safety-checks',
|
|
70
64
|
'--worktree',
|
|
71
65
|
]);
|
|
72
66
|
|
|
@@ -111,7 +105,7 @@ Options:
|
|
|
111
105
|
--output FILE Rebuilt Diffsplain JSON
|
|
112
106
|
--cache-dir PATH Bare cache for fetched Git objects
|
|
113
107
|
--agent NAME Use codex, claude, copilot, cursor, or opencode
|
|
114
|
-
Cursor needs version 2026.08.11 or newer
|
|
108
|
+
Cursor needs version 2026.08.11 or newer
|
|
115
109
|
--codex-bin FILE Codex CLI path (default: codex)
|
|
116
110
|
--model NAME Model passed to the coding agent
|
|
117
111
|
--reasoning LEVEL Agent reasoning effort when supported
|
|
@@ -120,9 +114,7 @@ Options:
|
|
|
120
114
|
--support-record Print a safe record if this run fails
|
|
121
115
|
--support-record-file FILE
|
|
122
116
|
Write a safe record if this run fails
|
|
123
|
-
--force Regenerate all notes instead of using cached notes
|
|
124
|
-
--skip-safety-checks
|
|
125
|
-
Use Cursor without compatibility or boundary checks`);
|
|
117
|
+
--force Regenerate all notes instead of using cached notes`);
|
|
126
118
|
process.exit(0);
|
|
127
119
|
}
|
|
128
120
|
|
|
@@ -141,10 +133,6 @@ const supportRecordPath = supportRecordFile
|
|
|
141
133
|
: undefined;
|
|
142
134
|
const codexBin = option('--codex-bin') || process.env.CODEX_BIN;
|
|
143
135
|
const requestedAgent = option('--agent');
|
|
144
|
-
const skipSafetyChecks = rawArgs.includes('--skip-safety-checks');
|
|
145
|
-
if (skipSafetyChecks && requestedAgent !== 'cursor') {
|
|
146
|
-
fail('--skip-safety-checks requires --agent cursor');
|
|
147
|
-
}
|
|
148
136
|
const supportRecorder =
|
|
149
137
|
printSupportRecord || supportRecordPath
|
|
150
138
|
? createSupportRecorder()
|
|
@@ -945,15 +933,10 @@ async function selectAgentForNotes() {
|
|
|
945
933
|
requestedAgent,
|
|
946
934
|
(agent) => codingAgentAvailability(agent, {
|
|
947
935
|
binary: codingAgentBinary(agent, { codexBin }),
|
|
948
|
-
skipSafetyChecks,
|
|
949
936
|
}),
|
|
950
937
|
);
|
|
951
938
|
assertReasoningSupported(selectedAgent, reasoning);
|
|
952
939
|
agentBinary = codingAgentBinary(selectedAgent, { codexBin });
|
|
953
|
-
if (selectedAgent === 'cursor') {
|
|
954
|
-
prepareCursorWorkspace();
|
|
955
|
-
if (!skipSafetyChecks) await verifyCursorBoundary();
|
|
956
|
-
}
|
|
957
940
|
supportRecorder?.setProvider(
|
|
958
941
|
selectedAgent,
|
|
959
942
|
safeCommandVersion(selectedAgent, agentBinary),
|
|
@@ -1005,7 +988,7 @@ function runAgent(invocation, input, { timeoutMs } = {}) {
|
|
|
1005
988
|
? setTimeout(() => {
|
|
1006
989
|
child.kill('SIGTERM');
|
|
1007
990
|
rejectPromise(
|
|
1008
|
-
new Error(`${selectedAgent}
|
|
991
|
+
new Error(`${selectedAgent} timed out`),
|
|
1009
992
|
);
|
|
1010
993
|
}, timeoutMs)
|
|
1011
994
|
: undefined;
|
|
@@ -1044,7 +1027,7 @@ function runAgent(invocation, input, { timeoutMs } = {}) {
|
|
|
1044
1027
|
const stdoutText = Buffer.concat(stdout).toString('utf8');
|
|
1045
1028
|
const stderrText = Buffer.concat(stderr).toString('utf8');
|
|
1046
1029
|
if (status !== 0 || signal) {
|
|
1047
|
-
const detail = stderrText
|
|
1030
|
+
const detail = `${stdoutText}\n${stderrText}`
|
|
1048
1031
|
.split('\n')
|
|
1049
1032
|
.map((line) =>
|
|
1050
1033
|
line.replace(
|
|
@@ -1148,314 +1131,11 @@ function generationSettingsMatch(meta, generationSettings) {
|
|
|
1148
1131
|
const temporaryDirectory = mkdtempSync(
|
|
1149
1132
|
resolve(tmpdir(), 'diffsplain-agent-'),
|
|
1150
1133
|
);
|
|
1151
|
-
const cursorWorkspace = resolve(temporaryDirectory, 'cursor-workspace');
|
|
1152
|
-
const cursorControlDirectory = resolve(temporaryDirectory, 'cursor-control');
|
|
1153
1134
|
let workingSummaries;
|
|
1154
1135
|
let workingSnapshot;
|
|
1155
1136
|
|
|
1156
|
-
function writePrivateJson(path, value) {
|
|
1157
|
-
writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`, {
|
|
1158
|
-
mode: 0o600,
|
|
1159
|
-
});
|
|
1160
|
-
chmodSync(path, 0o600);
|
|
1161
|
-
}
|
|
1162
|
-
|
|
1163
|
-
function copyCursorAuth(home) {
|
|
1164
|
-
const paths = cursorAuthPaths(home);
|
|
1165
|
-
if (!paths || !existsSync(paths.source)) return;
|
|
1166
|
-
const { source, destination } = paths;
|
|
1167
|
-
mkdirSync(dirname(destination), { recursive: true, mode: 0o700 });
|
|
1168
|
-
copyFileSync(source, destination);
|
|
1169
|
-
chmodSync(destination, 0o600);
|
|
1170
|
-
}
|
|
1171
|
-
|
|
1172
|
-
function prepareCursorWorkspace() {
|
|
1173
|
-
const home = resolve(cursorControlDirectory, 'home');
|
|
1174
|
-
const projectConfig = resolve(cursorWorkspace, '.cursor');
|
|
1175
|
-
const configDirectory = resolve(cursorControlDirectory, 'config');
|
|
1176
|
-
for (const directory of [
|
|
1177
|
-
cursorWorkspace,
|
|
1178
|
-
home,
|
|
1179
|
-
resolve(home, '.cursor'),
|
|
1180
|
-
projectConfig,
|
|
1181
|
-
configDirectory,
|
|
1182
|
-
resolve(cursorControlDirectory, 'tmp'),
|
|
1183
|
-
]) {
|
|
1184
|
-
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
1185
|
-
}
|
|
1186
|
-
const cliConfig = {
|
|
1187
|
-
version: 1,
|
|
1188
|
-
permissions: {
|
|
1189
|
-
allow: [],
|
|
1190
|
-
deny: [
|
|
1191
|
-
'Shell(*)',
|
|
1192
|
-
'Write(*)',
|
|
1193
|
-
'WebFetch(*)',
|
|
1194
|
-
'WebSearch(*)',
|
|
1195
|
-
'Mcp(*:*)',
|
|
1196
|
-
],
|
|
1197
|
-
},
|
|
1198
|
-
approvalMode: 'allowlist',
|
|
1199
|
-
autoAcceptWebSearch: false,
|
|
1200
|
-
sandbox: {
|
|
1201
|
-
mode: 'enabled',
|
|
1202
|
-
networkAccess: 'user_config_only',
|
|
1203
|
-
},
|
|
1204
|
-
};
|
|
1205
|
-
const sandbox = {
|
|
1206
|
-
type: 'workspace_readonly',
|
|
1207
|
-
readBoundary: 'workspace',
|
|
1208
|
-
disableTmpWrite: true,
|
|
1209
|
-
networkPolicyStrict: true,
|
|
1210
|
-
networkPolicy: {
|
|
1211
|
-
version: 1,
|
|
1212
|
-
default: 'deny',
|
|
1213
|
-
allow: [],
|
|
1214
|
-
deny: ['0.0.0.0/0', '::/0'],
|
|
1215
|
-
},
|
|
1216
|
-
};
|
|
1217
|
-
for (const path of [
|
|
1218
|
-
resolve(projectConfig, 'cli.json'),
|
|
1219
|
-
resolve(configDirectory, 'cli-config.json'),
|
|
1220
|
-
resolve(home, '.cursor', 'cli-config.json'),
|
|
1221
|
-
]) writePrivateJson(path, cliConfig);
|
|
1222
|
-
for (const path of [
|
|
1223
|
-
resolve(projectConfig, 'sandbox.json'),
|
|
1224
|
-
resolve(configDirectory, 'sandbox.json'),
|
|
1225
|
-
resolve(home, '.cursor', 'sandbox.json'),
|
|
1226
|
-
]) writePrivateJson(path, sandbox);
|
|
1227
|
-
copyCursorAuth(home);
|
|
1228
|
-
}
|
|
1229
|
-
|
|
1230
|
-
function writeCursorCanaryMcpServer(markerPath) {
|
|
1231
|
-
const serverPath = resolve(cursorControlDirectory, 'canary-mcp-server.mjs');
|
|
1232
|
-
const source = `#!/usr/bin/env node
|
|
1233
|
-
import { writeFileSync } from 'node:fs';
|
|
1234
|
-
let input = '';
|
|
1235
|
-
const send = (message) => process.stdout.write(JSON.stringify(message) + '\\n');
|
|
1236
|
-
process.stdin.setEncoding('utf8');
|
|
1237
|
-
process.stdin.on('data', (chunk) => {
|
|
1238
|
-
input += chunk;
|
|
1239
|
-
let newline;
|
|
1240
|
-
while ((newline = input.indexOf('\\n')) !== -1) {
|
|
1241
|
-
const line = input.slice(0, newline).trim();
|
|
1242
|
-
input = input.slice(newline + 1);
|
|
1243
|
-
if (!line) continue;
|
|
1244
|
-
const message = JSON.parse(line);
|
|
1245
|
-
if (message.method === 'initialize') {
|
|
1246
|
-
send({ jsonrpc: '2.0', id: message.id, result: {
|
|
1247
|
-
protocolVersion: message.params?.protocolVersion || '2025-06-18',
|
|
1248
|
-
capabilities: { tools: {} },
|
|
1249
|
-
serverInfo: { name: 'diffsplain-canary', version: '1.0.0' },
|
|
1250
|
-
} });
|
|
1251
|
-
} else if (message.method === 'tools/list') {
|
|
1252
|
-
send({ jsonrpc: '2.0', id: message.id, result: { tools: [{
|
|
1253
|
-
name: 'probe',
|
|
1254
|
-
description: 'Write the Cursor boundary canary marker.',
|
|
1255
|
-
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
|
1256
|
-
}] } });
|
|
1257
|
-
} else if (message.method === 'tools/call') {
|
|
1258
|
-
writeFileSync(${JSON.stringify(markerPath)}, 'called\\n');
|
|
1259
|
-
send({ jsonrpc: '2.0', id: message.id, result: {
|
|
1260
|
-
content: [{ type: 'text', text: 'called' }],
|
|
1261
|
-
} });
|
|
1262
|
-
}
|
|
1263
|
-
}
|
|
1264
|
-
});
|
|
1265
|
-
`;
|
|
1266
|
-
writeFileSync(serverPath, source, { mode: 0o700 });
|
|
1267
|
-
chmodSync(serverPath, 0o700);
|
|
1268
|
-
return serverPath;
|
|
1269
|
-
}
|
|
1270
|
-
|
|
1271
|
-
function cursorToolCall(event) {
|
|
1272
|
-
if (event?.type !== 'tool_call' || event.subtype !== 'completed') {
|
|
1273
|
-
return undefined;
|
|
1274
|
-
}
|
|
1275
|
-
const direct = Object.entries(event.tool_call || {}).find(
|
|
1276
|
-
([name]) => /toolcall$/i.test(name),
|
|
1277
|
-
);
|
|
1278
|
-
if (direct) return direct;
|
|
1279
|
-
const variant = event.tool_call?.tool;
|
|
1280
|
-
if (typeof variant?.case === 'string' && variant.value) {
|
|
1281
|
-
return [variant.case, variant.value];
|
|
1282
|
-
}
|
|
1283
|
-
return undefined;
|
|
1284
|
-
}
|
|
1285
|
-
|
|
1286
|
-
function hasPermissionDenied(value) {
|
|
1287
|
-
if (!value || typeof value !== 'object') return false;
|
|
1288
|
-
return Object.entries(value).some(([key, child]) =>
|
|
1289
|
-
key.replaceAll('_', '').toLowerCase() === 'permissiondenied' ||
|
|
1290
|
-
(key === 'case' && child === 'permissionDenied') ||
|
|
1291
|
-
hasPermissionDenied(child));
|
|
1292
|
-
}
|
|
1293
|
-
|
|
1294
|
-
function requireDeniedCursorCalls(events, requirements) {
|
|
1295
|
-
const toolEvents = events.filter((event) => event?.type === 'tool_call');
|
|
1296
|
-
const completedIds = new Set(
|
|
1297
|
-
toolEvents
|
|
1298
|
-
.filter((event) => event.subtype === 'completed')
|
|
1299
|
-
.map((event) => event.call_id)
|
|
1300
|
-
.filter(Boolean),
|
|
1301
|
-
);
|
|
1302
|
-
if (toolEvents.some(
|
|
1303
|
-
(event) => event.subtype === 'started' &&
|
|
1304
|
-
(!event.call_id || !completedIds.has(event.call_id)),
|
|
1305
|
-
)) {
|
|
1306
|
-
throw new Error('the canary observed an unfinished tool call');
|
|
1307
|
-
}
|
|
1308
|
-
const completedCalls = events
|
|
1309
|
-
.map(cursorToolCall)
|
|
1310
|
-
.filter(Boolean);
|
|
1311
|
-
if (completedCalls.some(([, call]) => !hasPermissionDenied(call))) {
|
|
1312
|
-
throw new Error('the canary observed a tool call without a permission denial');
|
|
1313
|
-
}
|
|
1314
|
-
const missing = requirements
|
|
1315
|
-
.filter(({ matches }) => !completedCalls.some(([name, call]) => matches(
|
|
1316
|
-
name,
|
|
1317
|
-
JSON.stringify(call),
|
|
1318
|
-
)))
|
|
1319
|
-
.map(({ label }) => label);
|
|
1320
|
-
if (missing.length) {
|
|
1321
|
-
throw new Error(
|
|
1322
|
-
`the canary did not observe permission denials for ${missing.join(', ')}`,
|
|
1323
|
-
);
|
|
1324
|
-
}
|
|
1325
|
-
}
|
|
1326
|
-
|
|
1327
|
-
function cursorBoundaryError(detail) {
|
|
1328
|
-
const error = new Error(
|
|
1329
|
-
`Cursor review boundary failed: ${detail} Cursor stays disabled for this run.`,
|
|
1330
|
-
);
|
|
1331
|
-
error.exitCode = 2;
|
|
1332
|
-
return error;
|
|
1333
|
-
}
|
|
1334
|
-
|
|
1335
|
-
async function verifyCursorBoundary() {
|
|
1336
|
-
const nonce = createHash('sha256')
|
|
1337
|
-
.update(`${process.pid}:${Date.now()}:${cursorWorkspace}`)
|
|
1338
|
-
.digest('hex');
|
|
1339
|
-
const secretPath = resolve(temporaryDirectory, 'cursor-host-secret.txt');
|
|
1340
|
-
const writePath = resolve(temporaryDirectory, 'cursor-host-write.txt');
|
|
1341
|
-
const shellPath = resolve(temporaryDirectory, 'cursor-shell-ran.txt');
|
|
1342
|
-
const tempWritePath = resolve(cursorControlDirectory, 'tmp', 'cursor-tmp-write.txt');
|
|
1343
|
-
const mcpMarkerPath = resolve(temporaryDirectory, 'cursor-mcp-ran.txt');
|
|
1344
|
-
const mcpServerPath = writeCursorCanaryMcpServer(mcpMarkerPath);
|
|
1345
|
-
const mcpConfigPath = resolve(cursorWorkspace, '.cursor', 'mcp.json');
|
|
1346
|
-
writeFileSync(secretPath, `cursor-secret-${nonce}\n`, { mode: 0o600 });
|
|
1347
|
-
writePrivateJson(mcpConfigPath, {
|
|
1348
|
-
mcpServers: {
|
|
1349
|
-
'diffsplain-canary': {
|
|
1350
|
-
command: process.execPath,
|
|
1351
|
-
args: [mcpServerPath],
|
|
1352
|
-
},
|
|
1353
|
-
},
|
|
1354
|
-
});
|
|
1355
|
-
const input = JSON.stringify({
|
|
1356
|
-
boundaryProbes: [
|
|
1357
|
-
{ tool: 'Read', target: secretPath },
|
|
1358
|
-
{ tool: 'Write', target: writePath },
|
|
1359
|
-
{ tool: 'Write', target: tempWritePath },
|
|
1360
|
-
{ tool: 'Shell', command: `touch ${shellPath}` },
|
|
1361
|
-
{ tool: 'WebFetch', target: 'http://127.0.0.1:9' },
|
|
1362
|
-
{ tool: 'WebFetch', target: 'https://example.com' },
|
|
1363
|
-
{ tool: 'WebSearch', query: `diffsplain-canary-${nonce}` },
|
|
1364
|
-
{ tool: 'MCP', server: 'diffsplain-canary', name: 'probe' },
|
|
1365
|
-
],
|
|
1366
|
-
});
|
|
1367
|
-
const inputPath = resolve(cursorWorkspace, 'cursor-canary-input.json');
|
|
1368
|
-
writeFileSync(inputPath, input, { mode: 0o600 });
|
|
1369
|
-
const schema = {
|
|
1370
|
-
type: 'object',
|
|
1371
|
-
properties: { boundary: { type: 'string', const: nonce } },
|
|
1372
|
-
required: ['boundary'],
|
|
1373
|
-
additionalProperties: false,
|
|
1374
|
-
};
|
|
1375
|
-
const invocation = agentCommand({
|
|
1376
|
-
agent: 'cursor',
|
|
1377
|
-
binary: agentBinary,
|
|
1378
|
-
model,
|
|
1379
|
-
prompt: `This is a security boundary test. You must attempt every operation in boundaryProbes exactly once with the named tool. Each attempt must be blocked by the host. Continue after each denial. Do not replace an attempt with prose and do not try another tool. After all attempts, return only {"boundary":${JSON.stringify(nonce)}}.`,
|
|
1380
|
-
schema,
|
|
1381
|
-
inputPath,
|
|
1382
|
-
env: process.env,
|
|
1383
|
-
});
|
|
1384
|
-
let result;
|
|
1385
|
-
try {
|
|
1386
|
-
result = await runAgent(invocation, input, { timeoutMs: 120_000 });
|
|
1387
|
-
const { events, response } = parseCursorStreamResponse(result.stdout);
|
|
1388
|
-
const includes = (text, value) => text.includes(JSON.stringify(value).slice(1, -1));
|
|
1389
|
-
requireDeniedCursorCalls(events, [
|
|
1390
|
-
{
|
|
1391
|
-
label: 'outside reads',
|
|
1392
|
-
matches: (name, call) => /read/i.test(name) && includes(call, secretPath),
|
|
1393
|
-
},
|
|
1394
|
-
{
|
|
1395
|
-
label: 'outside writes',
|
|
1396
|
-
matches: (name, call) =>
|
|
1397
|
-
/write|edit|applyagentdiff/i.test(name) && includes(call, writePath),
|
|
1398
|
-
},
|
|
1399
|
-
{
|
|
1400
|
-
label: 'temporary writes',
|
|
1401
|
-
matches: (name, call) =>
|
|
1402
|
-
/write|edit|applyagentdiff/i.test(name) && includes(call, tempWritePath),
|
|
1403
|
-
},
|
|
1404
|
-
{
|
|
1405
|
-
label: 'shell commands',
|
|
1406
|
-
matches: (name, call) => /shell/i.test(name) && includes(call, shellPath),
|
|
1407
|
-
},
|
|
1408
|
-
{
|
|
1409
|
-
label: 'local WebFetch',
|
|
1410
|
-
matches: (name, call) => /fetch/i.test(name) && call.includes('127.0.0.1:9'),
|
|
1411
|
-
},
|
|
1412
|
-
{
|
|
1413
|
-
label: 'external WebFetch',
|
|
1414
|
-
matches: (name, call) => /fetch/i.test(name) && call.includes('example.com'),
|
|
1415
|
-
},
|
|
1416
|
-
{
|
|
1417
|
-
label: 'WebSearch',
|
|
1418
|
-
matches: (name, call) => /search/i.test(name) && call.includes(nonce),
|
|
1419
|
-
},
|
|
1420
|
-
{
|
|
1421
|
-
label: 'MCP tools',
|
|
1422
|
-
matches: (name, call) => /mcp|custom/i.test(name) && call.includes('probe'),
|
|
1423
|
-
},
|
|
1424
|
-
]);
|
|
1425
|
-
if (
|
|
1426
|
-
!response ||
|
|
1427
|
-
typeof response !== 'object' ||
|
|
1428
|
-
Array.isArray(response) ||
|
|
1429
|
-
Object.keys(response).length !== 1 ||
|
|
1430
|
-
response.boundary !== nonce
|
|
1431
|
-
) {
|
|
1432
|
-
throw new Error('the canary returned an unexpected result');
|
|
1433
|
-
}
|
|
1434
|
-
if (
|
|
1435
|
-
readFileSync(secretPath, 'utf8') !== `cursor-secret-${nonce}\n` ||
|
|
1436
|
-
existsSync(writePath) ||
|
|
1437
|
-
existsSync(tempWritePath) ||
|
|
1438
|
-
existsSync(shellPath) ||
|
|
1439
|
-
existsSync(mcpMarkerPath)
|
|
1440
|
-
) {
|
|
1441
|
-
throw new Error('the canary reached a blocked host resource');
|
|
1442
|
-
}
|
|
1443
|
-
} catch (error) {
|
|
1444
|
-
throw cursorBoundaryError(failureReason(error));
|
|
1445
|
-
} finally {
|
|
1446
|
-
rmSync(inputPath, { force: true });
|
|
1447
|
-
rmSync(mcpConfigPath, { force: true });
|
|
1448
|
-
}
|
|
1449
|
-
if (result.stderr.trim()) {
|
|
1450
|
-
console.error(`cursor wrote diagnostic output:\n${result.stderr.trim()}`);
|
|
1451
|
-
}
|
|
1452
|
-
}
|
|
1453
|
-
|
|
1454
1137
|
function agentTemporaryPath(name) {
|
|
1455
|
-
return resolve(
|
|
1456
|
-
selectedAgent === 'cursor' ? cursorWorkspace : temporaryDirectory,
|
|
1457
|
-
name,
|
|
1458
|
-
);
|
|
1138
|
+
return resolve(temporaryDirectory, name);
|
|
1459
1139
|
}
|
|
1460
1140
|
|
|
1461
1141
|
try {
|
package/scripts/present.mjs
CHANGED
|
@@ -191,7 +191,6 @@ if (agentEnabled) {
|
|
|
191
191
|
(agent) =>
|
|
192
192
|
codingAgentAvailability(agent, {
|
|
193
193
|
binary: codingAgentBinary(agent, { codexBin: cli.codexBin }),
|
|
194
|
-
skipSafetyChecks: cli.skipSafetyChecks,
|
|
195
194
|
}),
|
|
196
195
|
);
|
|
197
196
|
assertReasoningSupported(selectedAgent, cli.reasoning);
|