diffsplain 0.6.0 → 0.8.1

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.
Files changed (82) hide show
  1. package/README.md +8 -4
  2. package/dist/assets/{angular-html-vBvPnd6a.js → angular-html-CphzFC_v.js} +1 -1
  3. package/dist/assets/{angular-ts-CDzF1Z6a.js → angular-ts-Am0-mbFw.js} +1 -1
  4. package/dist/assets/{apl-Bm9bxvP7.js → apl-Cs2rPKDN.js} +1 -1
  5. package/dist/assets/{astro-RNrNWCYO.js → astro-CxKUUn9h.js} +1 -1
  6. package/dist/assets/{blade-On-NBYUv.js → blade-DqeCa6v5.js} +1 -1
  7. package/dist/assets/{c-hRmhFgD0.js → c-DdI6lknk.js} +1 -1
  8. package/dist/assets/{cobol-CIqc3k2t.js → cobol-COuAGE2X.js} +1 -1
  9. package/dist/assets/{coffee-Cd5EyvvS.js → coffee-BFlYT3Lp.js} +1 -1
  10. package/dist/assets/{cpp-zXFAAp_S.js → cpp-nywLU0Np.js} +1 -1
  11. package/dist/assets/{crystal-DRYY4cSw.js → crystal-BWp4dz1s.js} +1 -1
  12. package/dist/assets/{css-C4op_MsD.js → css-CRb9tUpU.js} +1 -1
  13. package/dist/assets/{edge-d3rJkErf.js → edge-rUYSbJy4.js} +1 -1
  14. package/dist/assets/{elixir-DPqlJhYM.js → elixir-DOq9ybIH.js} +1 -1
  15. package/dist/assets/{elm-DgU37I8a.js → elm-C8e38E5-.js} +1 -1
  16. package/dist/assets/{erb-CZVB_ntC.js → erb-BR9Q37Et.js} +1 -1
  17. package/dist/assets/{git-rebase-hUkltHZG.js → git-rebase-Coo2LUtB.js} +1 -1
  18. package/dist/assets/{glimmer-js-5XOT9GQ8.js → glimmer-js-C7hX9jg6.js} +1 -1
  19. package/dist/assets/{glimmer-ts-DT7D0Kfq.js → glimmer-ts-CZLT0F0k.js} +1 -1
  20. package/dist/assets/{glsl-Br4_Eh5O.js → glsl-BDZRv6e-.js} +1 -1
  21. package/dist/assets/{graphql--BmZ_AYP.js → graphql-DyzFc3y3.js} +1 -1
  22. package/dist/assets/{hack-CMdqGDoA.js → hack-CWnO7WsF.js} +1 -1
  23. package/dist/assets/{haml-DLEOHGmg.js → haml-CvDj-ghO.js} +1 -1
  24. package/dist/assets/{handlebars-BfUftoV0.js → handlebars-BnU_YcVA.js} +1 -1
  25. package/dist/assets/{html-uOgx2iYC.js → html-CjB2TKKa.js} +1 -1
  26. package/dist/assets/{html-derivative-D5cZE6C4.js → html-derivative-CYQqEXS6.js} +1 -1
  27. package/dist/assets/{http-cW9c66J_.js → http-NNQbnqIx.js} +1 -1
  28. package/dist/assets/{hurl-BztURO2n.js → hurl-DHaAA1BS.js} +1 -1
  29. package/dist/assets/{index-DZFM4-3L.js → index-f9ekN94b.js} +4 -4
  30. package/dist/assets/{java-Cy4mnKaj.js → java-D1J5V2CD.js} +1 -1
  31. package/dist/assets/{javascript-_Z02t_9Q.js → javascript-C3pX-HsE.js} +1 -1
  32. package/dist/assets/{jinja-BppBm5gt.js → jinja-BY9CIY8g.js} +1 -1
  33. package/dist/assets/{jison-Dzns436M.js → jison-Cr0H89gS.js} +1 -1
  34. package/dist/assets/{json-BYkfLOry.js → json-CA3kKNfq.js} +1 -1
  35. package/dist/assets/{jsx-DtAm8NUb.js → jsx-BuMkR-Lj.js} +1 -1
  36. package/dist/assets/{julia-CLIMRuh0.js → julia-C2sLwg3f.js} +1 -1
  37. package/dist/assets/{just-DMg47an6.js → just-pXpkLBXv.js} +1 -1
  38. package/dist/assets/{latex-D05ztCOx.js → latex-7v3EPOdF.js} +1 -1
  39. package/dist/assets/{liquid-DpImQR4t.js → liquid-CJZq0Z0t.js} +1 -1
  40. package/dist/assets/{lua--ZHUYQUU.js → lua-BimBzvTH.js} +1 -1
  41. package/dist/assets/{marko-Dh3Xi08i.js → marko-CCZHVnQN.js} +1 -1
  42. package/dist/assets/{mdc-Br6Pt78u.js → mdc-SLECNw2A.js} +1 -1
  43. package/dist/assets/{nginx-CwSU6U6d.js → nginx-DUwtUge2.js} +1 -1
  44. package/dist/assets/{nim-CBbTwyeq.js → nim-HD8o_Omp.js} +1 -1
  45. package/dist/assets/{perl-D3jQu3LN.js → perl-BGMeIkNk.js} +1 -1
  46. package/dist/assets/{php-BMeXBzlU.js → php-BL5xuY-v.js} +1 -1
  47. package/dist/assets/{pug-BGdY1g5E.js → pug-C8G1U_u8.js} +1 -1
  48. package/dist/assets/{qml-DsT5biza.js → qml-BGVSyw1x.js} +1 -1
  49. package/dist/assets/{r-CbS9PfN5.js → r-BDnRcclC.js} +1 -1
  50. package/dist/assets/{razor-Bwm8roHi.js → razor-I8tbEIVJ.js} +1 -1
  51. package/dist/assets/{regexp-Bc_9DyAu.js → regexp-D0yakB9Q.js} +1 -1
  52. package/dist/assets/{rst-Cj1Aii91.js → rst-DbTCn07T.js} +1 -1
  53. package/dist/assets/{ruby-BPMtFZnc.js → ruby-BwgPFIcO.js} +1 -1
  54. package/dist/assets/{sas-CoOau1t2.js → sas-dN4ePfXU.js} +1 -1
  55. package/dist/assets/{scss-CvfaI808.js → scss-T7M1A4iM.js} +1 -1
  56. package/dist/assets/{shellscript-B0C-TdLE.js → shellscript-HFjBHS7_.js} +1 -1
  57. package/dist/assets/{shellsession-UhmLiqcA.js → shellsession-DCAGskrx.js} +1 -1
  58. package/dist/assets/{soy-D419u0RL.js → soy-BgT96TF9.js} +1 -1
  59. package/dist/assets/{sql-Do9k0rRn.js → sql-DCIQeRzx.js} +1 -1
  60. package/dist/assets/{stata-x47J6BC4.js → stata-Dn0tad0_.js} +1 -1
  61. package/dist/assets/{surrealql-BERQ6pHu.js → surrealql-CCgFEQfW.js} +1 -1
  62. package/dist/assets/{svelte-dRWkyqKW.js → svelte-ITXimG8u.js} +1 -1
  63. package/dist/assets/{templ-CTq-KoWN.js → templ-DJVZZqPw.js} +1 -1
  64. package/dist/assets/{tex-CdRGjyYI.js → tex-Bn4N-OtH.js} +1 -1
  65. package/dist/assets/{ts-tags-CgaMqyVb.js → ts-tags-CxQ4PoC4.js} +1 -1
  66. package/dist/assets/{tsx-cpBQKcPG.js → tsx-CxwgcjkC.js} +1 -1
  67. package/dist/assets/{twig-vbs_PM4-.js → twig-CtbyTyMH.js} +1 -1
  68. package/dist/assets/{typescript-Cc--nwIo.js → typescript-d6ru12L8.js} +1 -1
  69. package/dist/assets/{vue-CgHwdBmB.js → vue-RvqAsYDm.js} +1 -1
  70. package/dist/assets/{vue-html-BHfoMw47.js → vue-html-BkHDVENe.js} +1 -1
  71. package/dist/assets/{vue-vine-_lVRp_NK.js → vue-vine-DvS3Mlkn.js} +1 -1
  72. package/dist/assets/{xml-G4NVmTde.js → xml-lzTq5A-V.js} +1 -1
  73. package/dist/assets/{xsl-s0so2M1i.js → xsl-BL0KXNWI.js} +1 -1
  74. package/dist/assets/{yaml-CpsmW05t.js → yaml-CF4XlBJS.js} +1 -1
  75. package/dist/demo-diff-data.json +6 -1
  76. package/dist/index.html +1 -1
  77. package/package.json +3 -2
  78. package/scripts/cli-args.mjs +15 -5
  79. package/scripts/coding-agents.mjs +265 -17
  80. package/scripts/doctor.mjs +27 -18
  81. package/scripts/generate-summaries.mjs +416 -58
  82. package/scripts/present.mjs +5 -4
@@ -1,9 +1,9 @@
1
1
  import { spawnSync } from 'node:child_process';
2
2
  import {
3
- agentDisabledReason,
4
3
  codingAgentBinary,
5
4
  codingAgents,
6
5
  findCommand,
6
+ inspectCursorCompatibility,
7
7
  } from './coding-agents.mjs';
8
8
 
9
9
  const agentLabels = {
@@ -76,6 +76,28 @@ async function inspectDependency(label, command, { env, platform }) {
76
76
  };
77
77
  }
78
78
 
79
+ async function inspectAgent(agent, { env, platform }) {
80
+ const label = agentLabels[agent];
81
+ const command = codingAgentBinary(agent, { env });
82
+ if (agent !== 'cursor') {
83
+ return inspectDependency(label, command, { env, platform });
84
+ }
85
+ const path = await findCommand(command, { env, platform });
86
+ if (!path) {
87
+ return { label, command, installed: false, compatible: 'not-checked' };
88
+ }
89
+ const inspection = inspectCursorCompatibility(path, { env });
90
+ return {
91
+ label,
92
+ command,
93
+ installed: true,
94
+ path,
95
+ version: inspection.version,
96
+ compatible: inspection.compatible ? 'yes' : 'no',
97
+ ...(inspection.reason ? { boundaryError: inspection.reason } : {}),
98
+ };
99
+ }
100
+
79
101
  function dependencyLine(dependency) {
80
102
  const label = dependency.label.padEnd(9);
81
103
  if (dependency.disabled) {
@@ -84,6 +106,9 @@ function dependencyLine(dependency) {
84
106
  if (!dependency.installed) {
85
107
  return ` ✗ ${label} not found (${dependency.command})`;
86
108
  }
109
+ if (dependency.boundaryError) {
110
+ return ` ! ${label} ${dependency.version || 'version unavailable'} (${dependency.path}; ${dependency.boundaryError})`;
111
+ }
87
112
  const mark = dependency.version ? '✓' : '!';
88
113
  return ` ${mark} ${label} ${dependency.version || 'version unavailable'} (${dependency.path})`;
89
114
  }
@@ -132,23 +157,7 @@ async function inspectDependencies(env, platform) {
132
157
  const [git, gh, ...agents] = await Promise.all([
133
158
  inspectDependency('Git', 'git', { env, platform }),
134
159
  inspectDependency('gh', 'gh', { env, platform }),
135
- ...codingAgents.map((agent) => {
136
- const disabled = agentDisabledReason(agent);
137
- if (disabled) {
138
- return Promise.resolve({
139
- label: agentLabels[agent],
140
- command: codingAgentBinary(agent, { env }),
141
- installed: false,
142
- compatible: 'no',
143
- disabled,
144
- });
145
- }
146
- return inspectDependency(
147
- agentLabels[agent],
148
- codingAgentBinary(agent, { env }),
149
- { env, platform },
150
- );
151
- }),
160
+ ...codingAgents.map((agent) => inspectAgent(agent, { env, platform })),
152
161
  ]);
153
162
  return { git, gh, agents };
154
163
  }
@@ -3,7 +3,10 @@
3
3
  import { spawn, spawnSync } from 'node:child_process';
4
4
  import { createHash } from 'node:crypto';
5
5
  import {
6
+ chmodSync,
7
+ copyFileSync,
6
8
  existsSync,
9
+ mkdirSync,
7
10
  mkdtempSync,
8
11
  readFileSync,
9
12
  rmSync,
@@ -15,9 +18,11 @@ import { fileURLToPath } from 'node:url';
15
18
  import {
16
19
  agentCommand,
17
20
  assertReasoningSupported,
21
+ codingAgentAvailability,
18
22
  codingAgentBinary,
19
- commandAvailable,
23
+ cursorAuthPaths,
20
24
  parseAgentResponse,
25
+ parseCursorStreamResponse,
21
26
  selectCodingAgent,
22
27
  } from './coding-agents.mjs';
23
28
  import { summaryPath } from './summary-path.mjs';
@@ -61,6 +66,7 @@ const booleanFlags = new Set([
61
66
  '--checkout',
62
67
  '--force',
63
68
  '--support-record',
69
+ '--skip-safety-checks',
64
70
  '--worktree',
65
71
  ]);
66
72
 
@@ -104,8 +110,8 @@ Options:
104
110
  --summaries FILE Agent note file
105
111
  --output FILE Rebuilt Diffsplain JSON
106
112
  --cache-dir PATH Bare cache for fetched Git objects
107
- --agent NAME Use codex, claude, copilot, or opencode
108
- Cursor is disabled because it cannot meet the review boundary
113
+ --agent NAME Use codex, claude, copilot, cursor, or opencode
114
+ Cursor needs version 2026.08.11 or newer and a passing canary
109
115
  --codex-bin FILE Codex CLI path (default: codex)
110
116
  --model NAME Model passed to the coding agent
111
117
  --reasoning LEVEL Agent reasoning effort when supported
@@ -114,7 +120,9 @@ Options:
114
120
  --support-record Print a safe record if this run fails
115
121
  --support-record-file FILE
116
122
  Write a safe record if this run fails
117
- --force Regenerate all notes instead of using cached notes`);
123
+ --force Regenerate all notes instead of using cached notes
124
+ --skip-safety-checks
125
+ Use Cursor without compatibility or boundary checks`);
118
126
  process.exit(0);
119
127
  }
120
128
 
@@ -133,6 +141,10 @@ const supportRecordPath = supportRecordFile
133
141
  : undefined;
134
142
  const codexBin = option('--codex-bin') || process.env.CODEX_BIN;
135
143
  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
+ }
136
148
  const supportRecorder =
137
149
  printSupportRecord || supportRecordPath
138
150
  ? createSupportRecorder()
@@ -173,6 +185,7 @@ const proseCodePointLimit = 1_200;
173
185
  const detailItemLimit = 4;
174
186
  const riskItemLimit = 3;
175
187
  const listItemCodePointLimit = 500;
188
+ const fileNoteAttemptLimit = 3;
176
189
  const jobsValue = option('--jobs') || '3';
177
190
  if (!/^[1-9]\d*$/.test(jobsValue) || Number(jobsValue) > 8) {
178
191
  fail('--jobs must be a number from 1 to 8');
@@ -930,10 +943,17 @@ async function selectAgentForNotes() {
930
943
  try {
931
944
  selectedAgent = await selectCodingAgent(
932
945
  requestedAgent,
933
- (agent) => commandAvailable(codingAgentBinary(agent, { codexBin })),
946
+ (agent) => codingAgentAvailability(agent, {
947
+ binary: codingAgentBinary(agent, { codexBin }),
948
+ skipSafetyChecks,
949
+ }),
934
950
  );
935
951
  assertReasoningSupported(selectedAgent, reasoning);
936
952
  agentBinary = codingAgentBinary(selectedAgent, { codexBin });
953
+ if (selectedAgent === 'cursor') {
954
+ prepareCursorWorkspace();
955
+ if (!skipSafetyChecks) await verifyCursorBoundary();
956
+ }
937
957
  supportRecorder?.setProvider(
938
958
  selectedAgent,
939
959
  safeCommandVersion(selectedAgent, agentBinary),
@@ -973,7 +993,7 @@ function failureReason(error) {
973
993
  'Agent note generation failed.';
974
994
  }
975
995
 
976
- function runAgent(invocation, input) {
996
+ function runAgent(invocation, input, { timeoutMs } = {}) {
977
997
  return new Promise((resolvePromise, rejectPromise) => {
978
998
  const child = spawn(invocation.command, invocation.args, {
979
999
  cwd: invocation.cwd || root,
@@ -981,6 +1001,15 @@ function runAgent(invocation, input) {
981
1001
  stdio: ['pipe', 'pipe', 'pipe'],
982
1002
  });
983
1003
  activeAgentProcesses.add(child);
1004
+ const timeout = timeoutMs
1005
+ ? setTimeout(() => {
1006
+ child.kill('SIGTERM');
1007
+ rejectPromise(
1008
+ new Error(`${selectedAgent} boundary check timed out`),
1009
+ );
1010
+ }, timeoutMs)
1011
+ : undefined;
1012
+ timeout?.unref();
984
1013
  const stdout = [];
985
1014
  const stderr = [];
986
1015
  let outputBytes = 0;
@@ -1001,10 +1030,12 @@ function runAgent(invocation, input) {
1001
1030
  if (error.code !== 'EPIPE') rejectPromise(error);
1002
1031
  });
1003
1032
  child.once('error', (error) => {
1033
+ if (timeout) clearTimeout(timeout);
1004
1034
  activeAgentProcesses.delete(child);
1005
1035
  rejectPromise(error);
1006
1036
  });
1007
1037
  child.once('close', (status, signal) => {
1038
+ if (timeout) clearTimeout(timeout);
1008
1039
  activeAgentProcesses.delete(child);
1009
1040
  if (interrupted) {
1010
1041
  rejectPromise(new Error('Agent note generation was interrupted'));
@@ -1117,9 +1148,316 @@ function generationSettingsMatch(meta, generationSettings) {
1117
1148
  const temporaryDirectory = mkdtempSync(
1118
1149
  resolve(tmpdir(), 'diffsplain-agent-'),
1119
1150
  );
1151
+ const cursorWorkspace = resolve(temporaryDirectory, 'cursor-workspace');
1152
+ const cursorControlDirectory = resolve(temporaryDirectory, 'cursor-control');
1120
1153
  let workingSummaries;
1121
1154
  let workingSnapshot;
1122
1155
 
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
+ function agentTemporaryPath(name) {
1455
+ return resolve(
1456
+ selectedAgent === 'cursor' ? cursorWorkspace : temporaryDirectory,
1457
+ name,
1458
+ );
1459
+ }
1460
+
1123
1461
  try {
1124
1462
  recordSyncStage('cache', acquireOwnership);
1125
1463
  const { rawSnapshot, snapshot } = recordSyncStage('snapshot', () => {
@@ -1269,10 +1607,9 @@ try {
1269
1607
  }
1270
1608
  if (batch.length) batches.push(batch);
1271
1609
  let nextBatch = 0;
1272
- const requestBatch = async (index, batchPaths) => {
1273
- const schemaPath = resolve(
1274
- temporaryDirectory,
1275
- `summary-schema-${index + 1}.json`,
1610
+ const requestBatch = async (index, batchPaths, attempt) => {
1611
+ const schemaPath = agentTemporaryPath(
1612
+ `summary-schema-${index + 1}-${attempt}.json`,
1276
1613
  );
1277
1614
  writeFileSync(
1278
1615
  schemaPath,
@@ -1289,9 +1626,8 @@ try {
1289
1626
  batchPaths,
1290
1627
  workingSummaries.files,
1291
1628
  );
1292
- const inputPath = resolve(
1293
- temporaryDirectory,
1294
- `summary-input-${index + 1}.json`,
1629
+ const inputPath = agentTemporaryPath(
1630
+ `summary-input-${index + 1}-${attempt}.json`,
1295
1631
  );
1296
1632
  writeFileSync(inputPath, input);
1297
1633
  const invocation = agentCommand({
@@ -1307,7 +1643,7 @@ try {
1307
1643
  });
1308
1644
 
1309
1645
  console.error(
1310
- `Asking ${selectedAgent} for batch ${index + 1} of ${batches.length} (${batchPaths.length} changed files)...`,
1646
+ `Asking ${selectedAgent} for batch ${index + 1} of ${batches.length} (${batchPaths.length} changed files, attempt ${attempt} of ${fileNoteAttemptLimit})...`,
1311
1647
  );
1312
1648
  return requestAgent(
1313
1649
  invocation,
@@ -1321,45 +1657,73 @@ try {
1321
1657
  };
1322
1658
  const runBatch = async (index) => {
1323
1659
  const batchPaths = batches[index];
1324
- let outcome;
1325
- try {
1326
- outcome = await requestBatch(index, batchPaths);
1327
- } catch (error) {
1328
- if (interrupted) throw error;
1329
- const reason = failureReason(error);
1330
- console.error(
1331
- error instanceof Error ? error.message : String(error),
1660
+ let pendingPaths = batchPaths;
1661
+ for (
1662
+ let attempt = 1;
1663
+ attempt <= fileNoteAttemptLimit && pendingPaths.length;
1664
+ attempt += 1
1665
+ ) {
1666
+ let outcome;
1667
+ let requestFailed = false;
1668
+ try {
1669
+ outcome = await requestBatch(index, pendingPaths, attempt);
1670
+ } catch (error) {
1671
+ if (interrupted) throw error;
1672
+ requestFailed = true;
1673
+ const reason = failureReason(error);
1674
+ console.error(
1675
+ error instanceof Error ? error.message : String(error),
1676
+ );
1677
+ outcome = {
1678
+ files: {},
1679
+ failedFiles: pendingPaths.map((path) => ({ path, reason })),
1680
+ errors: [],
1681
+ };
1682
+ }
1683
+ const requestedPaths = new Set(pendingPaths);
1684
+ const retryableFailures = requestFailed
1685
+ ? []
1686
+ : outcome.failedFiles.filter(
1687
+ (failure) => requestedPaths.has(failure.path),
1688
+ );
1689
+ const finalAttempt =
1690
+ requestFailed || attempt === fileNoteAttemptLimit;
1691
+ const keptFailures = outcome.failedFiles.filter(
1692
+ (failure) =>
1693
+ requestedPaths.has(failure.path)
1694
+ ? finalAttempt
1695
+ : !completeFileNote(workingSummaries.files[failure.path]),
1332
1696
  );
1333
- outcome = {
1334
- files: {},
1335
- failedFiles: batchPaths.map((path) => ({ path, reason })),
1336
- errors: [],
1697
+ workingSummaries = {
1698
+ ...(workingSummaries.change
1699
+ ? { change: workingSummaries.change }
1700
+ : {}),
1701
+ files: {
1702
+ ...workingSummaries.files,
1703
+ ...outcome.files,
1704
+ },
1705
+ meta: {
1706
+ ...workingSummaries.meta,
1707
+ status: 'generating',
1708
+ generatedAt: new Date().toISOString(),
1709
+ },
1337
1710
  };
1338
- }
1339
- workingSummaries = {
1340
- ...(workingSummaries.change
1341
- ? { change: workingSummaries.change }
1342
- : {}),
1343
- files: {
1344
- ...workingSummaries.files,
1345
- ...outcome.files,
1346
- },
1347
- meta: {
1348
- ...workingSummaries.meta,
1349
- status: 'generating',
1350
- generatedAt: new Date().toISOString(),
1351
- },
1352
- };
1353
- workingSummaries = addFailures(
1354
- workingSummaries,
1355
- outcome.failedFiles,
1356
- outcome.errors,
1357
- );
1358
- storeProgress(rawSnapshot, workingSummaries);
1359
- if (batchPaths.length) {
1360
- console.log(
1361
- `Wrote ${Object.keys(workingSummaries.files).length} of ${paths.length} agent notes to ${summariesPath}`,
1711
+ workingSummaries = addFailures(
1712
+ workingSummaries,
1713
+ keptFailures,
1714
+ finalAttempt || retryableFailures.length === 0
1715
+ ? outcome.errors
1716
+ : [],
1362
1717
  );
1718
+ storeProgress(rawSnapshot, workingSummaries);
1719
+ if (pendingPaths.length) {
1720
+ console.log(
1721
+ `Wrote ${Object.keys(workingSummaries.files).length} of ${paths.length} agent notes to ${summariesPath}`,
1722
+ );
1723
+ }
1724
+ pendingPaths = [...new Set(
1725
+ retryableFailures.map((failure) => failure.path),
1726
+ )];
1363
1727
  }
1364
1728
  };
1365
1729
  const workers = Array.from(
@@ -1375,10 +1739,7 @@ try {
1375
1739
  await Promise.all(workers);
1376
1740
  if (changeNeedsRefresh) {
1377
1741
  try {
1378
- const schemaPath = resolve(
1379
- temporaryDirectory,
1380
- 'change-summary-schema.json',
1381
- );
1742
+ const schemaPath = agentTemporaryPath('change-summary-schema.json');
1382
1743
  const schema = outputSchema([]);
1383
1744
  writeFileSync(
1384
1745
  schemaPath,
@@ -1390,10 +1751,7 @@ try {
1390
1751
  [],
1391
1752
  workingSummaries.files,
1392
1753
  );
1393
- const inputPath = resolve(
1394
- temporaryDirectory,
1395
- 'change-summary-input.json',
1396
- );
1754
+ const inputPath = agentTemporaryPath('change-summary-input.json');
1397
1755
  writeFileSync(inputPath, input);
1398
1756
  const invocation = agentCommand({
1399
1757
  agent: selectedAgent,