@jpkme/diff-review 0.1.12 → 0.1.14

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 (126) hide show
  1. package/README.md +1 -1
  2. package/bin/diff-review.mjs +72 -53
  3. package/lib/acp-policy.mjs +99 -12
  4. package/lib/acp-policy.test.mjs +56 -3
  5. package/lib/acp-shell.mjs +38 -8
  6. package/lib/acp-shell.test.mjs +32 -1
  7. package/lib/agent-login.mjs +105 -105
  8. package/lib/check-report.mjs +1 -0
  9. package/lib/check-report.test.mjs +1 -0
  10. package/lib/extract-cache.test.mjs +162 -162
  11. package/lib/model-label.mjs +263 -263
  12. package/lib/model-label.test.mjs +77 -77
  13. package/lib/models-cache.mjs +75 -75
  14. package/lib/models-cache.test.mjs +50 -50
  15. package/lib/prompt.mjs +8 -6
  16. package/lib/review-prep.mjs +63 -63
  17. package/package.json +1 -1
  18. package/web/build/client/_app/immutable/assets/{0.CLQiq3Ed.css → 0.BKRG1f36.css} +1 -1
  19. package/web/build/client/_app/immutable/assets/0.BKRG1f36.css.br +0 -0
  20. package/web/build/client/_app/immutable/assets/{0.CLQiq3Ed.css.gz → 0.BKRG1f36.css.gz} +0 -0
  21. package/web/build/client/_app/immutable/assets/2.C4whE4xz.css +1 -0
  22. package/web/build/client/_app/immutable/assets/2.C4whE4xz.css.br +0 -0
  23. package/web/build/client/_app/immutable/assets/2.C4whE4xz.css.gz +0 -0
  24. package/web/build/client/_app/immutable/assets/3.BT54aVKb.css +1 -0
  25. package/web/build/client/_app/immutable/assets/3.BT54aVKb.css.br +0 -0
  26. package/web/build/client/_app/immutable/assets/3.BT54aVKb.css.gz +0 -0
  27. package/web/build/client/_app/immutable/assets/storage.C8XGESwn.css +1 -0
  28. package/web/build/client/_app/immutable/assets/storage.C8XGESwn.css.br +0 -0
  29. package/web/build/client/_app/immutable/assets/storage.C8XGESwn.css.gz +0 -0
  30. package/web/build/client/_app/immutable/chunks/C0hrdiUt.js +2 -0
  31. package/web/build/client/_app/immutable/chunks/C0hrdiUt.js.br +0 -0
  32. package/web/build/client/_app/immutable/chunks/C0hrdiUt.js.gz +0 -0
  33. package/web/build/client/_app/immutable/chunks/CpeIO6nz.js +210 -0
  34. package/web/build/client/_app/immutable/chunks/CpeIO6nz.js.br +0 -0
  35. package/web/build/client/_app/immutable/chunks/CpeIO6nz.js.gz +0 -0
  36. package/web/build/client/_app/immutable/chunks/{CPyWH418.js → FOc6rgIW.js} +1 -1
  37. package/web/build/client/_app/immutable/chunks/FOc6rgIW.js.br +2 -0
  38. package/web/build/client/_app/immutable/chunks/FOc6rgIW.js.gz +0 -0
  39. package/web/build/client/_app/immutable/entry/{app.BiBohpyO.js → app.DcAtSJ3n.js} +2 -2
  40. package/web/build/client/_app/immutable/entry/app.DcAtSJ3n.js.br +0 -0
  41. package/web/build/client/_app/immutable/entry/app.DcAtSJ3n.js.gz +0 -0
  42. package/web/build/client/_app/immutable/entry/start.CO5l0Zbx.js +1 -0
  43. package/web/build/client/_app/immutable/entry/start.CO5l0Zbx.js.br +0 -0
  44. package/web/build/client/_app/immutable/entry/start.CO5l0Zbx.js.gz +0 -0
  45. package/web/build/client/_app/immutable/nodes/{1.BzPXaJJj.js → 1.E-Hs7ura.js} +1 -1
  46. package/web/build/client/_app/immutable/nodes/1.E-Hs7ura.js.br +4 -0
  47. package/web/build/client/_app/immutable/nodes/1.E-Hs7ura.js.gz +0 -0
  48. package/web/build/client/_app/immutable/nodes/2.s1_irV1Q.js +10 -0
  49. package/web/build/client/_app/immutable/nodes/2.s1_irV1Q.js.br +0 -0
  50. package/web/build/client/_app/immutable/nodes/2.s1_irV1Q.js.gz +0 -0
  51. package/web/build/client/_app/immutable/nodes/3.pgwe-_5b.js +171 -0
  52. package/web/build/client/_app/immutable/nodes/3.pgwe-_5b.js.br +0 -0
  53. package/web/build/client/_app/immutable/nodes/3.pgwe-_5b.js.gz +0 -0
  54. package/web/build/client/_app/version.json +1 -1
  55. package/web/build/client/_app/version.json.br +0 -0
  56. package/web/build/client/_app/version.json.gz +0 -0
  57. package/web/build/handler.js +3 -3
  58. package/web/build/index.js +3 -3
  59. package/web/build/server/chunks/{0-BYok4D0n.js → 0-DUStDOsr.js} +3 -3
  60. package/web/build/server/chunks/{0-BYok4D0n.js.map → 0-DUStDOsr.js.map} +1 -1
  61. package/web/build/server/chunks/1-BFhhV5JB.js +7 -0
  62. package/web/build/server/chunks/{1-C7zcB0q0.js.map → 1-BFhhV5JB.js.map} +1 -1
  63. package/web/build/server/chunks/2-BZ_MVqj8.js +7 -0
  64. package/web/build/server/chunks/2-BZ_MVqj8.js.map +1 -0
  65. package/web/build/server/chunks/3-BV-TIUp1.js +7 -0
  66. package/web/build/server/chunks/{3-DAAmG6VX.js.map → 3-BV-TIUp1.js.map} +1 -1
  67. package/web/build/server/chunks/{_server.ts-qYVrMYPB.js → _server.ts-CdFoXw36.js} +4 -4
  68. package/web/build/server/chunks/{_server.ts-qYVrMYPB.js.map → _server.ts-CdFoXw36.js.map} +1 -1
  69. package/web/build/server/chunks/{agent-sessions-DSqGavSn.js → agent-sessions-D0WwtWgR.js} +223 -51
  70. package/web/build/server/chunks/agent-sessions-D0WwtWgR.js.map +1 -0
  71. package/web/build/server/chunks/{git-review-CpO_G8z8.js → git-review-CgcBIbvM.js} +7 -7
  72. package/web/build/server/chunks/{git-review-CpO_G8z8.js.map → git-review-CgcBIbvM.js.map} +1 -1
  73. package/web/build/server/chunks/{handler-Di1wJQuU.js → handler-DbxjtK9M.js} +3 -3
  74. package/web/build/server/chunks/{handler-Di1wJQuU.js.map → handler-DbxjtK9M.js.map} +1 -1
  75. package/web/build/server/chunks/{hooks.server-BLOHobEK.js → hooks.server-Dg6gYUqs.js} +3 -3
  76. package/web/build/server/chunks/{hooks.server-BLOHobEK.js.map → hooks.server-Dg6gYUqs.js.map} +1 -1
  77. package/web/build/server/chunks/{index.js-yLYmrAWy.js → index.js-NNne-njN.js} +876 -879
  78. package/web/build/server/chunks/index.js-NNne-njN.js.map +1 -0
  79. package/web/build/server/chunks/{manifest.js-CXj8IIo3.js → manifest.js-V8ViI5bl.js} +9 -9
  80. package/web/build/server/chunks/{manifest.js-CXj8IIo3.js.map → manifest.js-V8ViI5bl.js.map} +1 -1
  81. package/web/build/server/chunks/{remote-1g4ayag-BiJeU0Pk.js → remote-1g4ayag-CCsG2QXw.js} +4 -4
  82. package/web/build/server/chunks/{remote-1g4ayag-BiJeU0Pk.js.map → remote-1g4ayag-CCsG2QXw.js.map} +1 -1
  83. package/web/build/server/chunks/{remote-wh6t6j-CHanXurm.js → remote-wh6t6j-BCvmX37z.js} +5 -5
  84. package/web/build/server/chunks/{remote-wh6t6j-CHanXurm.js.map → remote-wh6t6j-BCvmX37z.js.map} +1 -1
  85. package/web/build/server/chunks/{validate-BsTKyhsq.js → validate-CAQ31C7k.js} +2 -2
  86. package/web/build/server/chunks/{validate-BsTKyhsq.js.map → validate-CAQ31C7k.js.map} +1 -1
  87. package/web/build/client/_app/immutable/assets/0.CLQiq3Ed.css.br +0 -0
  88. package/web/build/client/_app/immutable/assets/2.CKE12GwZ.css +0 -1
  89. package/web/build/client/_app/immutable/assets/2.CKE12GwZ.css.br +0 -0
  90. package/web/build/client/_app/immutable/assets/2.CKE12GwZ.css.gz +0 -0
  91. package/web/build/client/_app/immutable/assets/3.Dx3c_e7d.css +0 -1
  92. package/web/build/client/_app/immutable/assets/3.Dx3c_e7d.css.br +0 -0
  93. package/web/build/client/_app/immutable/assets/3.Dx3c_e7d.css.gz +0 -0
  94. package/web/build/client/_app/immutable/assets/storage.gDV-BhtT.css +0 -1
  95. package/web/build/client/_app/immutable/assets/storage.gDV-BhtT.css.br +0 -0
  96. package/web/build/client/_app/immutable/assets/storage.gDV-BhtT.css.gz +0 -0
  97. package/web/build/client/_app/immutable/chunks/B5ByWcPE.js +0 -2
  98. package/web/build/client/_app/immutable/chunks/B5ByWcPE.js.br +0 -0
  99. package/web/build/client/_app/immutable/chunks/B5ByWcPE.js.gz +0 -0
  100. package/web/build/client/_app/immutable/chunks/CPyWH418.js.br +0 -0
  101. package/web/build/client/_app/immutable/chunks/CPyWH418.js.gz +0 -0
  102. package/web/build/client/_app/immutable/chunks/NwkMsx0h.js +0 -210
  103. package/web/build/client/_app/immutable/chunks/NwkMsx0h.js.br +0 -0
  104. package/web/build/client/_app/immutable/chunks/NwkMsx0h.js.gz +0 -0
  105. package/web/build/client/_app/immutable/entry/app.BiBohpyO.js.br +0 -0
  106. package/web/build/client/_app/immutable/entry/app.BiBohpyO.js.gz +0 -0
  107. package/web/build/client/_app/immutable/entry/start.CjWbsiSO.js +0 -1
  108. package/web/build/client/_app/immutable/entry/start.CjWbsiSO.js.br +0 -0
  109. package/web/build/client/_app/immutable/entry/start.CjWbsiSO.js.gz +0 -0
  110. package/web/build/client/_app/immutable/nodes/1.BzPXaJJj.js.br +0 -2
  111. package/web/build/client/_app/immutable/nodes/1.BzPXaJJj.js.gz +0 -0
  112. package/web/build/client/_app/immutable/nodes/2.Cp6Ttd_9.js +0 -10
  113. package/web/build/client/_app/immutable/nodes/2.Cp6Ttd_9.js.br +0 -0
  114. package/web/build/client/_app/immutable/nodes/2.Cp6Ttd_9.js.gz +0 -0
  115. package/web/build/client/_app/immutable/nodes/3.DLiDQm__.js +0 -171
  116. package/web/build/client/_app/immutable/nodes/3.DLiDQm__.js.br +0 -0
  117. package/web/build/client/_app/immutable/nodes/3.DLiDQm__.js.gz +0 -0
  118. package/web/build/server/chunks/1-C7zcB0q0.js +0 -7
  119. package/web/build/server/chunks/2-v6ejI_0-.js +0 -7
  120. package/web/build/server/chunks/2-v6ejI_0-.js.map +0 -1
  121. package/web/build/server/chunks/3-DAAmG6VX.js +0 -7
  122. package/web/build/server/chunks/agent-sessions-DSqGavSn.js.map +0 -1
  123. package/web/build/server/chunks/index.js-yLYmrAWy.js.map +0 -1
  124. /package/web/build/client/_app/immutable/nodes/{0.Cak4ehiE.js → 0.Dj97JW46.js} +0 -0
  125. /package/web/build/client/_app/immutable/nodes/{0.Cak4ehiE.js.br → 0.Dj97JW46.js.br} +0 -0
  126. /package/web/build/client/_app/immutable/nodes/{0.Cak4ehiE.js.gz → 0.Dj97JW46.js.gz} +0 -0
package/README.md CHANGED
@@ -71,7 +71,7 @@ va por el adaptador `@agentclientprotocol/claude-agent-acp` (se baja con `npx` l
71
71
 
72
72
  - El select de modelo muestra los que ofrece cada agente (se consultan por ACP la primera vez).
73
73
  - La web muestra lo que va haciendo (comandos, archivos que lee, plan). La terminal solo avisa fallos duros o JSON válido.
74
- - Al terminar cada turno, el server valida el JSON; si falta algo se lo pide al agente una vez y, cuando valida,
74
+ - Al terminar cada turno, el server valida el JSON; si falta algo se lo pide al agente hasta dos veces y, cuando valida,
75
75
  la web abre el reporte.
76
76
  - Cada pedido de permiso pasa por una política fija: leer dentro del repo, escribir únicamente el JSON del review
77
77
  y comandos de solo lectura: `git` (diff, log, show, blame…), `cat`, `head`, `tail`, `grep`, `rg`, `find`, `ls`,
@@ -201,12 +201,15 @@ function defaultBase(listed, branch) {
201
201
  return listed.branches.find((b) => b !== branch) || preferred || '';
202
202
  }
203
203
 
204
- function branchOptions(listed) {
205
- return listed.branches.map((b) => ({
206
- value: b,
207
- label: b,
208
- hint: b === listed.current ? 'actual' : b === listed.defaultBase ? 'base típica' : undefined
209
- }));
204
+ function branchOptions(listed, exclude = '') {
205
+ const skip = String(exclude || '').trim();
206
+ return listed.branches
207
+ .filter((b) => !skip || b !== skip)
208
+ .map((b) => ({
209
+ value: b,
210
+ label: b,
211
+ hint: b === listed.current ? 'actual' : b === listed.defaultBase ? 'base típica' : undefined
212
+ }));
210
213
  }
211
214
 
212
215
  /** Selects solo con terminal interactiva. (`p.isTTY` de Clack es una función, no un booleano.) */
@@ -270,12 +273,18 @@ async function resolveBranchBase(launch, opts) {
270
273
  p.log.step(`Branch: ${branch}`);
271
274
  }
272
275
 
276
+ const baseOptions = branchOptions(launch.listed, branch);
277
+ if (!baseOptions.length) {
278
+ p.cancel(`No hay otra base disponible distinta de \`${branch}\`.`);
279
+ process.exit(1);
280
+ }
281
+
273
282
  let base = flagBase;
274
283
  if (!base) {
275
284
  base = ensureValue(
276
285
  await autocompleteEs({
277
286
  message: 'Base (merge-base)',
278
- options,
287
+ options: baseOptions,
279
288
  initialValue: defaultBase(launch.listed, branch) || undefined,
280
289
  maxItems: 12
281
290
  }),
@@ -290,7 +299,7 @@ async function resolveBranchBase(launch, opts) {
290
299
  base = ensureValue(
291
300
  await autocompleteEs({
292
301
  message: 'Elegí otra base',
293
- options,
302
+ options: baseOptions,
294
303
  initialValue: defaultBase(launch.listed, branch) || undefined,
295
304
  maxItems: 12
296
305
  }),
@@ -317,6 +326,49 @@ function buildOutputFilename(repo, branch, base) {
317
326
  }
318
327
  }
319
328
 
329
+ /**
330
+ * Precomputa el diff en CLI (spinner si hay Clack). Si el resultado no tiene archivos, sale con error.
331
+ * @returns {ReturnType<typeof prepareReviewPromptOpts>}
332
+ */
333
+ function prepareContextOrFail(launch, branch, base, outputFilename, { useClack }) {
334
+ const spin = useClack ? p.spinner() : null;
335
+ if (!useClack) console.log('Preparando el contexto…');
336
+ spin?.start('Preparando el contexto…');
337
+
338
+ /** @type {ReturnType<typeof prepareReviewPromptOpts>} */
339
+ let promptOpts = { outputFilename };
340
+ try {
341
+ promptOpts = prepareReviewPromptOpts({
342
+ repo: launch.repo,
343
+ branch,
344
+ base,
345
+ outputFilename
346
+ });
347
+ } catch (err) {
348
+ const msg = err instanceof Error ? err.message : String(err);
349
+ spin?.stop('No pude precomputar el diff');
350
+ if (useClack) p.log.warn(`No pude precomputar el diff: ${msg}`);
351
+ else console.warn('No pude precomputar el diff:', msg);
352
+ ensureReviewDir(launch.repo);
353
+ writeDiffCache(launch.repo, outputFilename, { branch, base });
354
+ return promptOpts;
355
+ }
356
+
357
+ const n = Array.isArray(promptOpts.files) ? promptOpts.files.length : 0;
358
+ if (n === 0) {
359
+ spin?.stop('Diff vacío');
360
+ const msg = `El diff entre \`${branch}\` y \`${base}\` está vacío: no hay nada que revisar.`;
361
+ if (useClack) p.cancel(msg);
362
+ else console.error(msg);
363
+ process.exit(1);
364
+ }
365
+
366
+ const done = `Contexto listo · ${n} archivo${n === 1 ? '' : 's'}`;
367
+ spin?.stop(done);
368
+ if (!useClack) console.log(done);
369
+ return promptOpts;
370
+ }
371
+
320
372
  function copyPromptOrWarn(prompt) {
321
373
  if (copyTextToClipboard(prompt)) return true;
322
374
  p.log.warn(
@@ -622,22 +674,9 @@ async function runUiMode(opts, launch, launchCwd) {
622
674
  if (launch) {
623
675
  console.log(`Repo: ${launch.repo}`);
624
676
  console.log(`Branch: ${branch} (base: ${base})`);
625
- let promptOpts = { outputFilename };
626
- try {
627
- promptOpts = prepareReviewPromptOpts({
628
- repo: launch.repo,
629
- branch,
630
- base,
631
- outputFilename
632
- });
633
- } catch (err) {
634
- console.warn(
635
- 'No pude precomputar el diff:',
636
- err instanceof Error ? err.message : err
637
- );
638
- ensureReviewDir(launch.repo);
639
- writeDiffCache(launch.repo, outputFilename, { branch, base });
640
- }
677
+ const promptOpts = prepareContextOrFail(launch, branch, base, outputFilename, {
678
+ useClack: false
679
+ });
641
680
  const prompt = buildPrompt(
642
681
  {
643
682
  source: 'local',
@@ -698,38 +737,13 @@ async function main() {
698
737
  const { branch, base, prompted } = await resolveBranchBase(launch, opts);
699
738
 
700
739
  if (branch === base) {
701
- // Ambos flags iguales / sin TTY: no se puede corregir en selects.
702
- console.log(
703
- `Branch y base son iguales (${branch}): el diff estaría vacío.\nAbro la UI para que elijas otra combinación.`
704
- );
705
- await startServer({
706
- host: opts.host,
707
- port: opts.port,
708
- open: opts.open,
709
- launchCwd,
710
- openPath: '/'
711
- });
712
- return;
740
+ const msg = `Branch y base son iguales (${branch}): no hay diff que revisar.`;
741
+ if (prompted) p.cancel(msg);
742
+ else console.error(msg);
743
+ process.exit(1);
713
744
  }
714
745
 
715
746
  const outputFilename = buildOutputFilename(launch.repo, branch, base);
716
- // El agente escribe en .diff-review/ (que se ignora sola) y lee de ahí el diff ya calculado.
717
- let promptOpts = { outputFilename };
718
- try {
719
- promptOpts = prepareReviewPromptOpts({
720
- repo: launch.repo,
721
- branch,
722
- base,
723
- outputFilename
724
- });
725
- } catch (err) {
726
- const msg = err instanceof Error ? err.message : String(err);
727
- if (prompted) p.log.warn(`No pude precomputar el diff: ${msg}`);
728
- else console.warn('No pude precomputar el diff:', msg);
729
- ensureReviewDir(launch.repo);
730
- writeDiffCache(launch.repo, outputFilename, { branch, base });
731
- }
732
-
733
747
  if (!prompted) {
734
748
  console.log(`Repo: ${launch.repo}`);
735
749
  console.log(`Branch: ${branch} (base: ${base})`);
@@ -737,6 +751,11 @@ async function main() {
737
751
  p.note(`${branch} → ${base}\n${outputRelPath(outputFilename)}`, 'Review');
738
752
  }
739
753
 
754
+ // El agente escribe en .diff-review/ (que se ignora sola) y lee de ahí el diff ya calculado.
755
+ const promptOpts = prepareContextOrFail(launch, branch, base, outputFilename, {
756
+ useClack: prompted || isInteractive()
757
+ });
758
+
740
759
  const prompt = buildPrompt(
741
760
  {
742
761
  source: 'local',
@@ -1,15 +1,16 @@
1
1
  /**
2
2
  * Qué puede hacer el agente cuando corre por ACP. Cada `session/request_permission` pasa por acá: no hay nadie
3
3
  * mirando la terminal para aprobar, así que la política es una allowlist (leer, comandos de solo lectura y escribir
4
- * dentro de `.diff-review/`) y todo lo demás se rechaza.
4
+ * el JSON de salida en `.diff-review/`) y todo lo demás se rechaza.
5
5
  *
6
6
  * Ojo: el agente solo pide permiso para lo que *su* configuración no aprueba sola (p. ej. Claude Code aplica la
7
7
  * allowlist de `~/.claude/settings.json` antes de preguntar). Esto acota lo que pide, no lo que el usuario ya
8
8
  * habilitó en su agente.
9
9
  */
10
- import { isAbsolute, normalize, resolve } from "node:path";
10
+ import { realpathSync } from "node:fs";
11
+ import { basename, dirname, isAbsolute, normalize, resolve } from "node:path";
11
12
  import { checkShell } from "./acp-shell.mjs";
12
- import { OUTPUT_DIR } from "./output-name.mjs";
13
+ import { outputRelPath } from "./output-name.mjs";
13
14
 
14
15
  /**
15
16
  * @typedef {"review" | "ask" | "edit"} PolicyMode
@@ -32,10 +33,34 @@ function samePath(a, b, platform = process.platform) {
32
33
  return platform === "win32" ? na.toLowerCase() === nb.toLowerCase() : na === nb;
33
34
  }
34
35
 
36
+ /**
37
+ * Resuelve symlinks cuando el path (o un ancestro) existe. Sin eso, un link dentro del repo
38
+ * puede apuntar fuera y pasar `isInside` con solo `resolve`.
39
+ * @param {string} path
40
+ */
41
+ function canonicalPath(path) {
42
+ try {
43
+ return realpathSync(path);
44
+ } catch {
45
+ const parts = [];
46
+ let cur = normalize(path);
47
+ for (;;) {
48
+ const parent = dirname(cur);
49
+ if (!parent || parent === cur) return normalize(path);
50
+ parts.unshift(basename(cur));
51
+ try {
52
+ return resolve(realpathSync(parent), ...parts);
53
+ } catch {
54
+ cur = parent;
55
+ }
56
+ }
57
+ }
58
+ }
59
+
35
60
  /** @param {string} child @param {string} root */
36
61
  function isInside(child, root, platform = process.platform) {
37
- const c = normalize(child);
38
- const r = normalize(root).replace(/[\\/]+$/, "");
62
+ const c = normalize(canonicalPath(child));
63
+ const r = normalize(canonicalPath(root)).replace(/[\\/]+$/, "");
39
64
  const [cc, rr] = platform === "win32" ? [c.toLowerCase(), r.toLowerCase()] : [c, r];
40
65
  return cc === rr || cc.startsWith(`${rr}\\`) || cc.startsWith(`${rr}/`);
41
66
  }
@@ -68,6 +93,66 @@ function commandOf(rawInput) {
68
93
  return typeof cmd === "string" ? cmd : "";
69
94
  }
70
95
 
96
+ /**
97
+ * Comando de shell que la política evalúa (rawInput, backticks o título entero).
98
+ * Cursor a veces envuelve el script con `@…@` en el título.
99
+ * @param {ToolCallLike} toolCall
100
+ */
101
+ export function toolCallCommand(toolCall) {
102
+ const fromRaw = commandOf(toolCall.rawInput).trim();
103
+ if (fromRaw) return fromRaw;
104
+ const title = String(toolCall.title ?? "").trim();
105
+ const tick = /^`([\s\S]+)`$/.exec(title);
106
+ if (tick) return tick[1].trim();
107
+ const at = /^@\s*([\s\S]*?)\s*@$/.exec(title);
108
+ if (at) return at[1].trim();
109
+ return title;
110
+ }
111
+
112
+ /**
113
+ * Etiqueta corta del agente si el título no es el comando mismo.
114
+ * @param {ToolCallLike} toolCall
115
+ * @param {string} [command]
116
+ */
117
+ export function toolCallLabel(toolCall, command = toolCallCommand(toolCall)) {
118
+ const title = String(toolCall.title ?? "").trim();
119
+ if (!title) return "";
120
+ if (/^`[\s\S]+`$/.test(title) || /^@\s*[\s\S]*?\s*@$/.test(title)) return "";
121
+ const norm = (s) => s.replace(/\s+/g, " ").trim();
122
+ if (command && norm(title) === norm(command)) return "";
123
+ if (command && command.length >= 24 && title.includes(command.slice(0, 24))) return "";
124
+ return title;
125
+ }
126
+
127
+ /**
128
+ * Motivo de denegación legible en la UI de permisos.
129
+ * @param {string} reason
130
+ */
131
+ export function humanizePolicyReason(reason) {
132
+ const r = String(reason || "").trim();
133
+ if (!r) return "La política bloqueó este pedido.";
134
+ let m;
135
+ if ((m = /^(.+?) no está permitido$/.exec(r))) {
136
+ return `La política bloqueó «${m[1]}» (no está en la allowlist de comandos).`;
137
+ }
138
+ if ((m = /^variables de entorno en el comando:\s*(.*)$/s.exec(r))) {
139
+ const token = String(m[1] || "?").trim().slice(0, 40);
140
+ return `La política interpretó parte del comando como variable de entorno (${token}) y lo bloqueó.`;
141
+ }
142
+ if ((m = /^path fuera del repo:\s*(.*)$/s.exec(r))) {
143
+ return `El path queda fuera del repo: ${m[1]}`;
144
+ }
145
+ if ((m = /^lectura fuera del repo:\s*(.*)$/s.exec(r))) {
146
+ return `Lectura fuera del repo: ${m[1]}`;
147
+ }
148
+ if ((m = /^opción global de git no permitida:\s*(.*)$/s.exec(r))) {
149
+ return `Opción de git no permitida: ${m[1]}`;
150
+ }
151
+ if (r.startsWith("git ")) return `Comando git no permitido por la política: ${r}`;
152
+ if (r.startsWith("solo se puede escribir") || r.includes("sin path") || r.startsWith("modo pregunta")) return r;
153
+ return `La política bloqueó el pedido: ${r}`;
154
+ }
155
+
71
156
  /**
72
157
  * ¿El comando de shell solo lee? Ver `checkShell` en `acp-shell.mjs`.
73
158
  * @param {string} command
@@ -87,15 +172,17 @@ export function decidePermission(toolCall, { repo, outputFilename, mode }) {
87
172
  const paths = toolCallPaths(toolCall, repo);
88
173
 
89
174
  if (kind === "read" || kind === "search") {
175
+ if (!paths.length) {
176
+ return { allow: false, reason: `${kind === "search" ? "búsqueda" : "lectura"} sin path` };
177
+ }
90
178
  const outside = paths.find((p) => !isInside(p, repo));
91
179
  return outside ? { allow: false, reason: `lectura fuera del repo: ${outside}` } : { allow: true, reason: "lectura" };
92
180
  }
93
181
  if (kind === "think") return { allow: true, reason: "pensar" };
94
182
 
95
183
  if (kind === "execute") {
96
- // Cursor no manda `rawInput`: el comando viene solo en el título, entre backticks.
97
- const title = String(toolCall.title ?? "").trim();
98
- const cmd = commandOf(toolCall.rawInput) || (/^`([\s\S]+)`$/.exec(title)?.[1] ?? title);
184
+ // Cursor no manda `rawInput`: el comando viene en el título (backticks / @…@ / texto).
185
+ const cmd = toolCallCommand(toolCall);
99
186
  const check = checkShell(cmd, { repo });
100
187
  return check.ok ? { allow: true, reason: "comando de solo lectura" } : { allow: false, reason: check.reason };
101
188
  }
@@ -103,14 +190,14 @@ export function decidePermission(toolCall, { repo, outputFilename, mode }) {
103
190
  if (kind === "edit" || kind === "delete") {
104
191
  if (mode === "ask") return { allow: false, reason: "modo pregunta: no se edita nada" };
105
192
  if (!paths.length) return { allow: false, reason: `${kind === "delete" ? "borrado" : "edición"} sin path` };
106
- const reviewRoot = resolve(repo, OUTPUT_DIR);
107
- const outside = paths.find((p) => !isInside(p, reviewRoot) && !samePath(p, reviewRoot));
193
+ const target = resolve(repo, outputRelPath(outputFilename));
194
+ const outside = paths.find((p) => !samePath(canonicalPath(p), canonicalPath(target)));
108
195
  return outside
109
196
  ? {
110
197
  allow: false,
111
- reason: `solo se puede escribir en ${OUTPUT_DIR}/ (no ${outside})`
198
+ reason: `solo se puede escribir ${outputRelPath(outputFilename)} (no ${outside})`
112
199
  }
113
- : { allow: true, reason: `escritura en ${OUTPUT_DIR}/` };
200
+ : { allow: true, reason: `escritura de ${outputRelPath(outputFilename)}` };
114
201
  }
115
202
 
116
203
  return { allow: false, reason: `${kind} no permitido: ${toolCall.title ?? "?"}` };
@@ -1,7 +1,15 @@
1
1
  import { test } from "node:test";
2
2
  import assert from "node:assert/strict";
3
+ import { mkdtempSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
3
5
  import { join } from "node:path";
4
- import { decidePermission, permissionOutcome } from "./acp-policy.mjs";
6
+ import {
7
+ decidePermission,
8
+ humanizePolicyReason,
9
+ permissionOutcome,
10
+ toolCallCommand,
11
+ toolCallLabel,
12
+ } from "./acp-policy.mjs";
5
13
 
6
14
  const repo = process.platform === "win32" ? "C:\\repos\\app" : "/repos/app";
7
15
  const out = "diff-rev_75eca0c739_52eb729c8f.json";
@@ -19,12 +27,43 @@ test("decidePermission: lecturas solo dentro del repo", () => {
19
27
  const home = process.platform === "win32" ? "C:\\Users\\me\\.ssh\\id_rsa" : "/home/me/.ssh/id_rsa";
20
28
  assert.equal(decidePermission(read(home), ctx()).allow, false);
21
29
  assert.equal(decidePermission(read(join(repo, "..", "otro", "x")), ctx()).allow, false);
30
+ assert.equal(decidePermission({ kind: "read", title: "Read", rawInput: {} }, ctx()).allow, false);
31
+ assert.equal(decidePermission({ kind: "search", title: "Grep", rawInput: { pattern: "x" } }, ctx()).allow, false);
32
+ assert.equal(decidePermission({ kind: "search", title: "Grep", rawInput: { pattern: "x", path: "lib" } }, ctx()).allow, true);
22
33
  });
23
34
 
24
- test("decidePermission: escritura libre en .diff-review/, nunca en modo pregunta ni fuera", () => {
35
+ test("decidePermission: symlink dentro del repo que apunta afuera no pasa", () => {
36
+ const root = mkdtempSync(join(tmpdir(), "dr-policy-"));
37
+ const outside = mkdtempSync(join(tmpdir(), "dr-policy-out-"));
38
+ writeFileSync(join(outside, "secret.txt"), "secret");
39
+ mkdirSync(join(root, "lib"), { recursive: true });
40
+ writeFileSync(join(root, "lib", "ok.mjs"), "ok");
41
+ const link = join(root, "escape");
42
+ try {
43
+ // junction en Windows no pide admin; 'dir' en posix.
44
+ symlinkSync(outside, link, process.platform === "win32" ? "junction" : "dir");
45
+ } catch (err) {
46
+ rmSync(root, { recursive: true, force: true });
47
+ rmSync(outside, { recursive: true, force: true });
48
+ // Entornos sin permiso de symlink: no fallar el suite.
49
+ if (err && (err.code === "EPERM" || err.code === "EACCES")) return;
50
+ throw err;
51
+ }
52
+ const local = (mode = "review") => ({ repo: root, outputFilename: out, mode });
53
+ try {
54
+ assert.equal(decidePermission(read(join(root, "lib", "ok.mjs")), local()).allow, true);
55
+ assert.equal(decidePermission(read(join(link, "secret.txt")), local()).allow, false);
56
+ } finally {
57
+ rmSync(root, { recursive: true, force: true });
58
+ rmSync(outside, { recursive: true, force: true });
59
+ }
60
+ });
61
+
62
+ test("decidePermission: escritura solo del JSON objetivo, nunca en modo pregunta ni fuera", () => {
25
63
  assert.equal(decidePermission(edit(outPath), ctx("review")).allow, true);
26
64
  assert.equal(decidePermission(edit(`.diff-review/${out}`), ctx("edit")).allow, true);
27
- assert.equal(decidePermission(edit(join(repo, ".diff-review", "scratch.tmp")), ctx("review")).allow, true);
65
+ assert.equal(decidePermission(edit(join(repo, ".diff-review", "scratch.tmp")), ctx("review")).allow, false);
66
+ assert.equal(decidePermission(edit(join(repo, ".diff-review", out.replace(/\.json$/i, ".diff"))), ctx("review")).allow, false);
28
67
  assert.equal(decidePermission(edit(outPath), ctx("ask")).allow, false);
29
68
  // Fuera de .diff-review/: no.
30
69
  assert.equal(decidePermission(edit(join(repo, out)), ctx("review")).allow, false);
@@ -61,6 +100,20 @@ test("decidePermission: comandos según isAllowedShell y el resto rechazado", ()
61
100
  assert.equal(decidePermission({ kind: "delete", title: "rm", rawInput: {} }, ctx("edit")).allow, false);
62
101
  });
63
102
 
103
+ test("toolCallCommand / toolCallLabel y humanizePolicyReason para la UI de permisos", () => {
104
+ assert.equal(toolCallCommand({ kind: "execute", rawInput: { command: "git status" } }), "git status");
105
+ assert.equal(toolCallCommand({ kind: "execute", title: "`ls -la`" }), "ls -la");
106
+ assert.equal(
107
+ toolCallCommand({ kind: "execute", title: "@\nnode -e 'x'\n@" }),
108
+ "node -e 'x'"
109
+ );
110
+ assert.equal(toolCallLabel({ title: "Probe symlink" }, "node -e 'x'"), "Probe symlink");
111
+ assert.equal(toolCallLabel({ title: "`ls`" }, "ls"), "");
112
+ assert.equal(toolCallLabel({ title: "@node@" }, "node"), "");
113
+ assert.match(humanizePolicyReason("node no está permitido"), /allowlist/);
114
+ assert.match(humanizePolicyReason("variables de entorno en el comando: @"), /variable de entorno/);
115
+ });
116
+
64
117
  test("permissionOutcome elige once / always según el scope", () => {
65
118
  const options = [
66
119
  { optionId: "a1", kind: "allow_always", name: "Siempre" },
package/lib/acp-shell.mjs CHANGED
@@ -6,7 +6,8 @@
6
6
  * El comando se tokeniza respetando comillas (un `grep "a\|b"` no es un pipe). Se aceptan `|`, `&&`, `||`, `;` y
7
7
  * descartar salida a /dev/null; cualquier otra redirección, `$(…)`, backticks o `&` lo rechazan.
8
8
  */
9
- import { isAbsolute, normalize, resolve } from "node:path";
9
+ import { realpathSync } from "node:fs";
10
+ import { basename, dirname, isAbsolute, normalize, resolve } from "node:path";
10
11
 
11
12
  /** @typedef {{ ok: true } | { ok: false, reason: string }} ShellCheck */
12
13
 
@@ -99,18 +100,48 @@ const TEXT_ARGS = new Set(["echo", "printf", "tr", "write-output", "which", "typ
99
100
  /** Destinos de redirección que solo descartan salida. */
100
101
  const DISCARD = new Set(["/dev/null", "nul", "$null"]);
101
102
 
102
- /** `/c/Users/...` (Git Bash) → `C:/Users/...` en Windows. */
103
+ /**
104
+ * Paths de shell Unix → forma Windows cuando la política corre en win32:
105
+ * - Git Bash: `/c/Users/...` → `C:/Users/...`
106
+ * - WSL: `/mnt/c/Users/...` → `C:/Users/...`
107
+ */
103
108
  function toNativePath(p, platform) {
104
109
  if (platform === "win32") {
110
+ const wsl = /^\/mnt\/([a-zA-Z])(\/|$)(.*)$/.exec(p);
111
+ if (wsl) return `${wsl[1].toUpperCase()}:/${wsl[3]}`;
105
112
  const msys = /^\/([a-zA-Z])(\/|$)(.*)$/.exec(p);
106
113
  if (msys) return `${msys[1].toUpperCase()}:/${msys[3]}`;
107
114
  }
108
115
  return p;
109
116
  }
110
117
 
118
+ /**
119
+ * Resuelve symlinks cuando el path (o un ancestro) existe. Sin eso, un link
120
+ * dentro del repo puede apuntar fuera y pasar `isInside` con solo `normalize`.
121
+ * @param {string} path
122
+ */
123
+ function canonicalPath(path) {
124
+ try {
125
+ return realpathSync(path);
126
+ } catch {
127
+ const parts = [];
128
+ let cur = normalize(path);
129
+ for (;;) {
130
+ const parent = dirname(cur);
131
+ if (!parent || parent === cur) return normalize(path);
132
+ parts.unshift(basename(cur));
133
+ try {
134
+ return resolve(realpathSync(parent), ...parts);
135
+ } catch {
136
+ cur = parent;
137
+ }
138
+ }
139
+ }
140
+ }
141
+
111
142
  function isInside(child, root, platform) {
112
- const c = normalize(child).replace(/[\\/]+$/, "");
113
- const r = normalize(root).replace(/[\\/]+$/, "");
143
+ const c = normalize(canonicalPath(child)).replace(/[\\/]+$/, "");
144
+ const r = normalize(canonicalPath(root)).replace(/[\\/]+$/, "");
114
145
  const [cc, rr] = platform === "win32" ? [c.toLowerCase(), r.toLowerCase()] : [c, r];
115
146
  return cc === rr || cc.startsWith(`${rr}\\`) || cc.startsWith(`${rr}/`);
116
147
  }
@@ -318,13 +349,12 @@ function isValidate(argv) {
318
349
  */
319
350
  function checkReviewDirMkdir(argv, ctx) {
320
351
  const name = argv[0].toLowerCase();
321
- const reviewRoot = normalize(resolve(ctx.repo, ".diff-review")).replace(/[\\/]+$/, "");
352
+ const reviewRoot = resolve(ctx.repo, ".diff-review");
322
353
  /** @param {string} arg */
323
354
  const underReviewDir = (arg) => {
324
355
  const native = toNativePath(String(arg || "").replace(/^~(?=[\\/]|$)/, "/__home__"), ctx.platform);
325
- const abs = normalize(isAbsolute(native) ? native : resolve(ctx.repo, native)).replace(/[\\/]+$/, "");
326
- const [a, r] = ctx.platform === "win32" ? [abs.toLowerCase(), reviewRoot.toLowerCase()] : [abs, reviewRoot];
327
- return a === r || a.startsWith(`${r}\\`) || a.startsWith(`${r}/`);
356
+ const abs = isAbsolute(native) ? native : resolve(ctx.repo, native);
357
+ return isInside(abs, reviewRoot, ctx.platform);
328
358
  };
329
359
 
330
360
  if (name === "mkdir" || name === "md") {
@@ -1,5 +1,7 @@
1
1
  import { test } from "node:test";
2
2
  import assert from "node:assert/strict";
3
+ import { mkdtempSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
3
5
  import { join } from "node:path";
4
6
  import { checkShell, tokenizeShell } from "./acp-shell.mjs";
5
7
 
@@ -121,7 +123,36 @@ test("checkShell permite mkdir bajo .diff-review en el repo", () => {
121
123
  assert.equal(ok("New-Item -ItemType Directory lib"), false);
122
124
  });
123
125
 
124
- test("checkShell entiende los paths de Git Bash en Windows", { skip: !win }, () => {
126
+ test("checkShell entiende paths de Git Bash y WSL en Windows", { skip: !win }, () => {
125
127
  assert.equal(ok("cat /c/repos/app/lib/a.mjs"), true);
126
128
  assert.equal(ok("cat /c/Users/me/.ssh/id_rsa"), false);
129
+ assert.equal(ok("cd /mnt/c/repos/app && ls -la"), true);
130
+ assert.equal(ok("cat /mnt/c/repos/app/lib/a.mjs"), true);
131
+ assert.equal(ok("cd /mnt/c/Users/me && ls"), false);
132
+ assert.equal(ok("cat /mnt/c/Users/me/.ssh/id_rsa"), false);
133
+ });
134
+
135
+ test("checkShell rechaza symlink dentro del repo que apunta afuera", () => {
136
+ const root = mkdtempSync(join(tmpdir(), "dr-shell-"));
137
+ const outside = mkdtempSync(join(tmpdir(), "dr-shell-out-"));
138
+ writeFileSync(join(outside, "secret.txt"), "secret");
139
+ mkdirSync(join(root, "lib"), { recursive: true });
140
+ writeFileSync(join(root, "lib", "ok.mjs"), "ok");
141
+ const link = join(root, "escape");
142
+ try {
143
+ symlinkSync(outside, link, process.platform === "win32" ? "junction" : "dir");
144
+ } catch (err) {
145
+ rmSync(root, { recursive: true, force: true });
146
+ rmSync(outside, { recursive: true, force: true });
147
+ if (err && (err.code === "EPERM" || err.code === "EACCES")) return;
148
+ throw err;
149
+ }
150
+ try {
151
+ assert.equal(checkShell(`cat "${join(root, "lib", "ok.mjs")}"`, { repo: root }).ok, true);
152
+ assert.equal(checkShell(`cat "${join(link, "secret.txt")}"`, { repo: root }).ok, false);
153
+ assert.equal(checkShell(`cd "${link}" && ls`, { repo: root }).ok, false);
154
+ } finally {
155
+ rmSync(root, { recursive: true, force: true });
156
+ rmSync(outside, { recursive: true, force: true });
157
+ }
127
158
  });