@jpkme/diff-review 0.1.12 → 0.1.13

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 (120) hide show
  1. package/README.md +1 -1
  2. package/lib/acp-policy.mjs +99 -12
  3. package/lib/acp-policy.test.mjs +56 -3
  4. package/lib/acp-shell.mjs +38 -8
  5. package/lib/acp-shell.test.mjs +32 -1
  6. package/lib/agent-login.mjs +105 -105
  7. package/lib/check-report.mjs +1 -0
  8. package/lib/check-report.test.mjs +1 -0
  9. package/lib/extract-cache.test.mjs +162 -162
  10. package/lib/model-label.mjs +263 -263
  11. package/lib/model-label.test.mjs +77 -77
  12. package/lib/models-cache.mjs +75 -75
  13. package/lib/models-cache.test.mjs +50 -50
  14. package/lib/review-prep.mjs +63 -63
  15. package/package.json +1 -1
  16. package/web/build/client/_app/immutable/assets/{0.CLQiq3Ed.css → 0.BKRG1f36.css} +1 -1
  17. package/web/build/client/_app/immutable/assets/0.BKRG1f36.css.br +0 -0
  18. package/web/build/client/_app/immutable/assets/{0.CLQiq3Ed.css.gz → 0.BKRG1f36.css.gz} +0 -0
  19. package/web/build/client/_app/immutable/assets/2.tqrMRTM0.css +1 -0
  20. package/web/build/client/_app/immutable/assets/2.tqrMRTM0.css.br +0 -0
  21. package/web/build/client/_app/immutable/assets/2.tqrMRTM0.css.gz +0 -0
  22. package/web/build/client/_app/immutable/assets/3.BT54aVKb.css +1 -0
  23. package/web/build/client/_app/immutable/assets/3.BT54aVKb.css.br +0 -0
  24. package/web/build/client/_app/immutable/assets/3.BT54aVKb.css.gz +0 -0
  25. package/web/build/client/_app/immutable/assets/storage.DqYH0w6R.css +1 -0
  26. package/web/build/client/_app/immutable/assets/storage.DqYH0w6R.css.br +0 -0
  27. package/web/build/client/_app/immutable/assets/storage.DqYH0w6R.css.gz +0 -0
  28. package/web/build/client/_app/immutable/chunks/BXUWMim4.js +213 -0
  29. package/web/build/client/_app/immutable/chunks/BXUWMim4.js.br +0 -0
  30. package/web/build/client/_app/immutable/chunks/BXUWMim4.js.gz +0 -0
  31. package/web/build/client/_app/immutable/chunks/{CPyWH418.js → Bu1mm6Oz.js} +1 -1
  32. package/web/build/client/_app/immutable/chunks/Bu1mm6Oz.js.br +0 -0
  33. package/web/build/client/_app/immutable/chunks/Bu1mm6Oz.js.gz +0 -0
  34. package/web/build/client/_app/immutable/chunks/{B5ByWcPE.js → DPfXzQnm.js} +2 -2
  35. package/web/build/client/_app/immutable/chunks/DPfXzQnm.js.br +0 -0
  36. package/web/build/client/_app/immutable/chunks/DPfXzQnm.js.gz +0 -0
  37. package/web/build/client/_app/immutable/entry/{app.BiBohpyO.js → app.CTgiuiU1.js} +2 -2
  38. package/web/build/client/_app/immutable/entry/app.CTgiuiU1.js.br +0 -0
  39. package/web/build/client/_app/immutable/entry/app.CTgiuiU1.js.gz +0 -0
  40. package/web/build/client/_app/immutable/entry/start.CFSl0kht.js +1 -0
  41. package/web/build/client/_app/immutable/entry/start.CFSl0kht.js.br +0 -0
  42. package/web/build/client/_app/immutable/entry/start.CFSl0kht.js.gz +0 -0
  43. package/web/build/client/_app/immutable/nodes/{1.BzPXaJJj.js → 1.0PQZpSbJ.js} +1 -1
  44. package/web/build/client/_app/immutable/nodes/1.0PQZpSbJ.js.br +2 -0
  45. package/web/build/client/_app/immutable/nodes/1.0PQZpSbJ.js.gz +0 -0
  46. package/web/build/client/_app/immutable/nodes/2.DQaoQoy4.js +10 -0
  47. package/web/build/client/_app/immutable/nodes/2.DQaoQoy4.js.br +0 -0
  48. package/web/build/client/_app/immutable/nodes/2.DQaoQoy4.js.gz +0 -0
  49. package/web/build/client/_app/immutable/nodes/3.AciugOtL.js +171 -0
  50. package/web/build/client/_app/immutable/nodes/3.AciugOtL.js.br +0 -0
  51. package/web/build/client/_app/immutable/nodes/3.AciugOtL.js.gz +0 -0
  52. package/web/build/client/_app/version.json +1 -1
  53. package/web/build/client/_app/version.json.br +0 -0
  54. package/web/build/client/_app/version.json.gz +0 -0
  55. package/web/build/handler.js +3 -3
  56. package/web/build/index.js +3 -3
  57. package/web/build/server/chunks/{0-BYok4D0n.js → 0-DUStDOsr.js} +3 -3
  58. package/web/build/server/chunks/{0-BYok4D0n.js.map → 0-DUStDOsr.js.map} +1 -1
  59. package/web/build/server/chunks/1-C2qlInYk.js +7 -0
  60. package/web/build/server/chunks/{1-C7zcB0q0.js.map → 1-C2qlInYk.js.map} +1 -1
  61. package/web/build/server/chunks/2-DyE177MK.js +7 -0
  62. package/web/build/server/chunks/2-DyE177MK.js.map +1 -0
  63. package/web/build/server/chunks/3-SO3atuWO.js +7 -0
  64. package/web/build/server/chunks/{3-DAAmG6VX.js.map → 3-SO3atuWO.js.map} +1 -1
  65. package/web/build/server/chunks/{_server.ts-qYVrMYPB.js → _server.ts-CRNhzL2a.js} +3 -3
  66. package/web/build/server/chunks/{_server.ts-qYVrMYPB.js.map → _server.ts-CRNhzL2a.js.map} +1 -1
  67. package/web/build/server/chunks/{agent-sessions-DSqGavSn.js → agent-sessions-kcUaLhK5.js} +171 -40
  68. package/web/build/server/chunks/agent-sessions-kcUaLhK5.js.map +1 -0
  69. package/web/build/server/chunks/{git-review-CpO_G8z8.js → git-review-CXKcjYgZ.js} +2 -2
  70. package/web/build/server/chunks/{git-review-CpO_G8z8.js.map → git-review-CXKcjYgZ.js.map} +1 -1
  71. package/web/build/server/chunks/{handler-Di1wJQuU.js → handler-_E0NucKN.js} +3 -3
  72. package/web/build/server/chunks/{handler-Di1wJQuU.js.map → handler-_E0NucKN.js.map} +1 -1
  73. package/web/build/server/chunks/{hooks.server-BLOHobEK.js → hooks.server-D7yPODoc.js} +2 -2
  74. package/web/build/server/chunks/{hooks.server-BLOHobEK.js.map → hooks.server-D7yPODoc.js.map} +1 -1
  75. package/web/build/server/chunks/{index.js-yLYmrAWy.js → index.js-lMtDM8ZQ.js} +3 -3
  76. package/web/build/server/chunks/{index.js-yLYmrAWy.js.map → index.js-lMtDM8ZQ.js.map} +1 -1
  77. package/web/build/server/chunks/{manifest.js-CXj8IIo3.js → manifest.js-BIm6jot5.js} +9 -9
  78. package/web/build/server/chunks/{manifest.js-CXj8IIo3.js.map → manifest.js-BIm6jot5.js.map} +1 -1
  79. package/web/build/server/chunks/{remote-1g4ayag-BiJeU0Pk.js → remote-1g4ayag-DmqxwUIz.js} +3 -3
  80. package/web/build/server/chunks/{remote-1g4ayag-BiJeU0Pk.js.map → remote-1g4ayag-DmqxwUIz.js.map} +1 -1
  81. package/web/build/server/chunks/{remote-wh6t6j-CHanXurm.js → remote-wh6t6j-DZk0W9Pp.js} +4 -4
  82. package/web/build/server/chunks/{remote-wh6t6j-CHanXurm.js.map → remote-wh6t6j-DZk0W9Pp.js.map} +1 -1
  83. package/web/build/client/_app/immutable/assets/0.CLQiq3Ed.css.br +0 -0
  84. package/web/build/client/_app/immutable/assets/2.CKE12GwZ.css +0 -1
  85. package/web/build/client/_app/immutable/assets/2.CKE12GwZ.css.br +0 -0
  86. package/web/build/client/_app/immutable/assets/2.CKE12GwZ.css.gz +0 -0
  87. package/web/build/client/_app/immutable/assets/3.Dx3c_e7d.css +0 -1
  88. package/web/build/client/_app/immutable/assets/3.Dx3c_e7d.css.br +0 -0
  89. package/web/build/client/_app/immutable/assets/3.Dx3c_e7d.css.gz +0 -0
  90. package/web/build/client/_app/immutable/assets/storage.gDV-BhtT.css +0 -1
  91. package/web/build/client/_app/immutable/assets/storage.gDV-BhtT.css.br +0 -0
  92. package/web/build/client/_app/immutable/assets/storage.gDV-BhtT.css.gz +0 -0
  93. package/web/build/client/_app/immutable/chunks/B5ByWcPE.js.br +0 -0
  94. package/web/build/client/_app/immutable/chunks/B5ByWcPE.js.gz +0 -0
  95. package/web/build/client/_app/immutable/chunks/CPyWH418.js.br +0 -0
  96. package/web/build/client/_app/immutable/chunks/CPyWH418.js.gz +0 -0
  97. package/web/build/client/_app/immutable/chunks/NwkMsx0h.js +0 -210
  98. package/web/build/client/_app/immutable/chunks/NwkMsx0h.js.br +0 -0
  99. package/web/build/client/_app/immutable/chunks/NwkMsx0h.js.gz +0 -0
  100. package/web/build/client/_app/immutable/entry/app.BiBohpyO.js.br +0 -0
  101. package/web/build/client/_app/immutable/entry/app.BiBohpyO.js.gz +0 -0
  102. package/web/build/client/_app/immutable/entry/start.CjWbsiSO.js +0 -1
  103. package/web/build/client/_app/immutable/entry/start.CjWbsiSO.js.br +0 -0
  104. package/web/build/client/_app/immutable/entry/start.CjWbsiSO.js.gz +0 -0
  105. package/web/build/client/_app/immutable/nodes/1.BzPXaJJj.js.br +0 -2
  106. package/web/build/client/_app/immutable/nodes/1.BzPXaJJj.js.gz +0 -0
  107. package/web/build/client/_app/immutable/nodes/2.Cp6Ttd_9.js +0 -10
  108. package/web/build/client/_app/immutable/nodes/2.Cp6Ttd_9.js.br +0 -0
  109. package/web/build/client/_app/immutable/nodes/2.Cp6Ttd_9.js.gz +0 -0
  110. package/web/build/client/_app/immutable/nodes/3.DLiDQm__.js +0 -171
  111. package/web/build/client/_app/immutable/nodes/3.DLiDQm__.js.br +0 -0
  112. package/web/build/client/_app/immutable/nodes/3.DLiDQm__.js.gz +0 -0
  113. package/web/build/server/chunks/1-C7zcB0q0.js +0 -7
  114. package/web/build/server/chunks/2-v6ejI_0-.js +0 -7
  115. package/web/build/server/chunks/2-v6ejI_0-.js.map +0 -1
  116. package/web/build/server/chunks/3-DAAmG6VX.js +0 -7
  117. package/web/build/server/chunks/agent-sessions-DSqGavSn.js.map +0 -1
  118. /package/web/build/client/_app/immutable/nodes/{0.Cak4ehiE.js → 0.Dj97JW46.js} +0 -0
  119. /package/web/build/client/_app/immutable/nodes/{0.Cak4ehiE.js.br → 0.Dj97JW46.js.br} +0 -0
  120. /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`,
@@ -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
  });
@@ -1,105 +1,105 @@
1
- /**
2
- * Login de agentes ACP: mensajes claros + abrir una terminal interactiva cuando se pueda.
3
- */
4
- import { spawn } from "node:child_process";
5
-
6
- /**
7
- * @typedef {{ bin: string, args: string[], display: string }} LoginCommand
8
- */
9
-
10
- /**
11
- * Comando de login interactivo por agente (null = no hay CLI de login, p. ej. Cursor app).
12
- * @param {string} agentId
13
- * @returns {LoginCommand | null}
14
- */
15
- export function loginCommand(agentId) {
16
- switch (agentId) {
17
- case "claude":
18
- return { bin: "claude", args: ["/login"], display: "claude /login" };
19
- case "copilot":
20
- return { bin: "gh", args: ["auth", "login"], display: "gh auth login" };
21
- case "codex":
22
- return { bin: "codex", args: ["login"], display: "codex login" };
23
- case "gemini":
24
- return { bin: "gemini", args: [], display: "gemini" }; // el CLI guía el auth al arrancar
25
- default:
26
- return null;
27
- }
28
- }
29
-
30
- /** @param {string} text */
31
- export function looksLikeAuthFailure(text) {
32
- const s = String(text || "").toLowerCase();
33
- return /(not\s+)?auth|login|unauthor|not logged|sign[\s-]?in|credentials|token.*(invalid|expired|missing)|please\s+run/.test(
34
- s
35
- );
36
- }
37
-
38
- /**
39
- * Mensaje en español si el agente murió / falló por falta de login.
40
- * @param {string} agentId
41
- * @param {string} agentLabel
42
- * @param {string} detail
43
- * @returns {string | null}
44
- */
45
- export function authFailureMessage(agentId, agentLabel, detail) {
46
- if (!looksLikeAuthFailure(detail)) return null;
47
- const cmd = loginCommand(agentId);
48
- if (agentId === "cursor") {
49
- return `${agentLabel} no está autenticado. Iniciá sesión en la app de Cursor y reintentá.`;
50
- }
51
- if (cmd) {
52
- return `${agentLabel} no está autenticado. Corré \`${cmd.display}\` en una terminal y reintentá.`;
53
- }
54
- return `${agentLabel} no está autenticado. Revisá el login del agente y reintentá.`;
55
- }
56
-
57
- /**
58
- * Abre una terminal nueva con el login del agente (Windows / macOS / Linux).
59
- * @param {string} agentId
60
- * @returns {{ display: string }}
61
- */
62
- export function openLoginTerminal(agentId) {
63
- const cmd = loginCommand(agentId);
64
- if (!cmd) {
65
- throw new Error(
66
- agentId === "cursor"
67
- ? "Cursor se autentica desde la app de escritorio, no desde una terminal."
68
- : "Este agente no tiene un comando de login conocido."
69
- );
70
- }
71
-
72
- const platform = process.platform;
73
- if (platform === "win32") {
74
- // `start "title" cmd /k …` — el primer quoted es el título de la ventana.
75
- spawn("cmd.exe", ["/c", "start", `${cmd.bin} login`, "cmd.exe", "/k", cmd.bin, ...cmd.args], {
76
- detached: true,
77
- stdio: "ignore",
78
- windowsHide: false,
79
- }).unref();
80
- } else if (platform === "darwin") {
81
- const script = `tell application "Terminal" to do script ${JSON.stringify(`${cmd.bin} ${cmd.args.join(" ")}`.trim())}`;
82
- spawn("osascript", ["-e", script], { detached: true, stdio: "ignore" }).unref();
83
- } else {
84
- const line = `${cmd.bin} ${cmd.args.join(" ")}`.trim();
85
- const tried = [
86
- ["x-terminal-emulator", ["-e", "bash", "-lc", `${line}; exec bash`]],
87
- ["gnome-terminal", ["--", "bash", "-lc", `${line}; exec bash`]],
88
- ["konsole", ["-e", "bash", "-lc", `${line}; exec bash`]],
89
- ["xterm", ["-e", "bash", "-lc", `${line}; exec bash`]],
90
- ];
91
- let ok = false;
92
- for (const [bin, args] of tried) {
93
- try {
94
- spawn(bin, args, { detached: true, stdio: "ignore" }).unref();
95
- ok = true;
96
- break;
97
- } catch {
98
- /* siguiente */
99
- }
100
- }
101
- if (!ok) throw new Error(`No pude abrir una terminal. Corré a mano: ${cmd.display}`);
102
- }
103
-
104
- return { display: cmd.display };
105
- }
1
+ /**
2
+ * Login de agentes ACP: mensajes claros + abrir una terminal interactiva cuando se pueda.
3
+ */
4
+ import { spawn } from "node:child_process";
5
+
6
+ /**
7
+ * @typedef {{ bin: string, args: string[], display: string }} LoginCommand
8
+ */
9
+
10
+ /**
11
+ * Comando de login interactivo por agente (null = no hay CLI de login, p. ej. Cursor app).
12
+ * @param {string} agentId
13
+ * @returns {LoginCommand | null}
14
+ */
15
+ export function loginCommand(agentId) {
16
+ switch (agentId) {
17
+ case "claude":
18
+ return { bin: "claude", args: ["/login"], display: "claude /login" };
19
+ case "copilot":
20
+ return { bin: "gh", args: ["auth", "login"], display: "gh auth login" };
21
+ case "codex":
22
+ return { bin: "codex", args: ["login"], display: "codex login" };
23
+ case "gemini":
24
+ return { bin: "gemini", args: [], display: "gemini" }; // el CLI guía el auth al arrancar
25
+ default:
26
+ return null;
27
+ }
28
+ }
29
+
30
+ /** @param {string} text */
31
+ export function looksLikeAuthFailure(text) {
32
+ const s = String(text || "").toLowerCase();
33
+ return /(not\s+)?auth|login|unauthor|not logged|sign[\s-]?in|credentials|token.*(invalid|expired|missing)|please\s+run/.test(
34
+ s
35
+ );
36
+ }
37
+
38
+ /**
39
+ * Mensaje en español si el agente murió / falló por falta de login.
40
+ * @param {string} agentId
41
+ * @param {string} agentLabel
42
+ * @param {string} detail
43
+ * @returns {string | null}
44
+ */
45
+ export function authFailureMessage(agentId, agentLabel, detail) {
46
+ if (!looksLikeAuthFailure(detail)) return null;
47
+ const cmd = loginCommand(agentId);
48
+ if (agentId === "cursor") {
49
+ return `${agentLabel} no está autenticado. Iniciá sesión en la app de Cursor y reintentá.`;
50
+ }
51
+ if (cmd) {
52
+ return `${agentLabel} no está autenticado. Corré \`${cmd.display}\` en una terminal y reintentá.`;
53
+ }
54
+ return `${agentLabel} no está autenticado. Revisá el login del agente y reintentá.`;
55
+ }
56
+
57
+ /**
58
+ * Abre una terminal nueva con el login del agente (Windows / macOS / Linux).
59
+ * @param {string} agentId
60
+ * @returns {{ display: string }}
61
+ */
62
+ export function openLoginTerminal(agentId) {
63
+ const cmd = loginCommand(agentId);
64
+ if (!cmd) {
65
+ throw new Error(
66
+ agentId === "cursor"
67
+ ? "Cursor se autentica desde la app de escritorio, no desde una terminal."
68
+ : "Este agente no tiene un comando de login conocido."
69
+ );
70
+ }
71
+
72
+ const platform = process.platform;
73
+ if (platform === "win32") {
74
+ // `start "title" cmd /k …` — el primer quoted es el título de la ventana.
75
+ spawn("cmd.exe", ["/c", "start", `${cmd.bin} login`, "cmd.exe", "/k", cmd.bin, ...cmd.args], {
76
+ detached: true,
77
+ stdio: "ignore",
78
+ windowsHide: false,
79
+ }).unref();
80
+ } else if (platform === "darwin") {
81
+ const script = `tell application "Terminal" to do script ${JSON.stringify(`${cmd.bin} ${cmd.args.join(" ")}`.trim())}`;
82
+ spawn("osascript", ["-e", script], { detached: true, stdio: "ignore" }).unref();
83
+ } else {
84
+ const line = `${cmd.bin} ${cmd.args.join(" ")}`.trim();
85
+ const tried = [
86
+ ["x-terminal-emulator", ["-e", "bash", "-lc", `${line}; exec bash`]],
87
+ ["gnome-terminal", ["--", "bash", "-lc", `${line}; exec bash`]],
88
+ ["konsole", ["-e", "bash", "-lc", `${line}; exec bash`]],
89
+ ["xterm", ["-e", "bash", "-lc", `${line}; exec bash`]],
90
+ ];
91
+ let ok = false;
92
+ for (const [bin, args] of tried) {
93
+ try {
94
+ spawn(bin, args, { detached: true, stdio: "ignore" }).unref();
95
+ ok = true;
96
+ break;
97
+ } catch {
98
+ /* siguiente */
99
+ }
100
+ }
101
+ if (!ok) throw new Error(`No pude abrir una terminal. Corré a mano: ${cmd.display}`);
102
+ }
103
+
104
+ return { display: cmd.display };
105
+ }
@@ -177,6 +177,7 @@ export function formatCheckReport(file, command, result) {
177
177
  "How to continue:",
178
178
  "- Fix every problem above in place. Keep existing meta/intent/groups/blocks/findings; only change what is wrong or missing.",
179
179
  '- Every diff path must appear in "blocks" or "skipped" (a skipped glob counts).',
180
+ "- If a problem lists missing paths: ADD a block or a skipped entry for each exact path. Do not only bump meta.generatedAt, notes, or claim the file is already complete.",
180
181
  "- meta.agent and meta.model are required (product name and the exact model id you are running).",
181
182
  "- intent ≥40 chars; enums exactly as in the schema.",
182
183
  "- Human-readable strings in Spanish.",
@@ -44,4 +44,5 @@ test("formatCheckReport explica cómo seguir", () => {
44
44
  assert.match(text, /How to continue/);
45
45
  assert.match(text, /npx --yes @jpkme\/diff-review validate/);
46
46
  assert.match(text, /same file/);
47
+ assert.match(text, /generatedAt/);
47
48
  });