@jpkme/diff-review 0.1.10 → 0.1.12

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 (153) hide show
  1. package/README.md +38 -15
  2. package/bin/diff-review.mjs +151 -34
  3. package/lib/acp-policy.mjs +136 -0
  4. package/lib/acp-policy.test.mjs +83 -0
  5. package/lib/acp-shell.mjs +379 -0
  6. package/lib/acp-shell.test.mjs +127 -0
  7. package/lib/acp.mjs +368 -0
  8. package/lib/acp.test.mjs +58 -0
  9. package/lib/agent-login.mjs +105 -0
  10. package/lib/agents.mjs +87 -14
  11. package/lib/agents.test.mjs +4 -6
  12. package/lib/check-report.mjs +29 -11
  13. package/lib/extract-cache.test.mjs +162 -0
  14. package/lib/extract.mjs +197 -1
  15. package/lib/model-label.mjs +263 -0
  16. package/lib/model-label.test.mjs +77 -0
  17. package/lib/models-cache.mjs +75 -0
  18. package/lib/models-cache.test.mjs +50 -0
  19. package/lib/output-name.mjs +8 -0
  20. package/lib/prompt.mjs +168 -22
  21. package/lib/review-dir.mjs +108 -0
  22. package/lib/review-meta.mjs +5 -3
  23. package/lib/review-meta.test.mjs +11 -0
  24. package/lib/review-prep.mjs +63 -0
  25. package/lib/validate.mjs +2 -2
  26. package/lib/wait-output.mjs +46 -23
  27. package/lib/wait-output.test.mjs +24 -1
  28. package/package.json +3 -1
  29. package/web/build/client/_app/immutable/assets/2.CKE12GwZ.css +1 -0
  30. package/web/build/client/_app/immutable/assets/2.CKE12GwZ.css.br +0 -0
  31. package/web/build/client/_app/immutable/assets/2.CKE12GwZ.css.gz +0 -0
  32. package/web/build/client/_app/immutable/assets/3.Dx3c_e7d.css +1 -0
  33. package/web/build/client/_app/immutable/assets/3.Dx3c_e7d.css.br +0 -0
  34. package/web/build/client/_app/immutable/assets/3.Dx3c_e7d.css.gz +0 -0
  35. package/web/build/client/_app/immutable/assets/storage.gDV-BhtT.css +1 -0
  36. package/web/build/client/_app/immutable/assets/storage.gDV-BhtT.css.br +0 -0
  37. package/web/build/client/_app/immutable/assets/storage.gDV-BhtT.css.gz +0 -0
  38. package/web/build/client/_app/immutable/chunks/{BBWAo_yW.js → B5ByWcPE.js} +2 -2
  39. package/web/build/client/_app/immutable/chunks/B5ByWcPE.js.br +0 -0
  40. package/web/build/client/_app/immutable/chunks/B5ByWcPE.js.gz +0 -0
  41. package/web/build/client/_app/immutable/chunks/CPyWH418.js +1 -0
  42. package/web/build/client/_app/immutable/chunks/CPyWH418.js.br +0 -0
  43. package/web/build/client/_app/immutable/chunks/CPyWH418.js.gz +0 -0
  44. package/web/build/client/_app/immutable/chunks/D8WOD_eS.js +3 -0
  45. package/web/build/client/_app/immutable/chunks/D8WOD_eS.js.br +0 -0
  46. package/web/build/client/_app/immutable/chunks/D8WOD_eS.js.gz +0 -0
  47. package/web/build/client/_app/immutable/chunks/NwkMsx0h.js +210 -0
  48. package/web/build/client/_app/immutable/chunks/NwkMsx0h.js.br +0 -0
  49. package/web/build/client/_app/immutable/chunks/NwkMsx0h.js.gz +0 -0
  50. package/web/build/client/_app/immutable/chunks/lUYXRDtZ.js +1 -0
  51. package/web/build/client/_app/immutable/chunks/lUYXRDtZ.js.br +0 -0
  52. package/web/build/client/_app/immutable/chunks/lUYXRDtZ.js.gz +0 -0
  53. package/web/build/client/_app/immutable/entry/app.BiBohpyO.js +2 -0
  54. package/web/build/client/_app/immutable/entry/app.BiBohpyO.js.br +0 -0
  55. package/web/build/client/_app/immutable/entry/app.BiBohpyO.js.gz +0 -0
  56. package/web/build/client/_app/immutable/entry/start.CjWbsiSO.js +1 -0
  57. package/web/build/client/_app/immutable/entry/start.CjWbsiSO.js.br +0 -0
  58. package/web/build/client/_app/immutable/entry/start.CjWbsiSO.js.gz +0 -0
  59. package/web/build/client/_app/immutable/nodes/0.Cak4ehiE.js +1 -0
  60. package/web/build/client/_app/immutable/nodes/0.Cak4ehiE.js.br +0 -0
  61. package/web/build/client/_app/immutable/nodes/0.Cak4ehiE.js.gz +0 -0
  62. package/web/build/client/_app/immutable/nodes/1.BzPXaJJj.js +1 -0
  63. package/web/build/client/_app/immutable/nodes/1.BzPXaJJj.js.br +2 -0
  64. package/web/build/client/_app/immutable/nodes/1.BzPXaJJj.js.gz +0 -0
  65. package/web/build/client/_app/immutable/nodes/2.Cp6Ttd_9.js +10 -0
  66. package/web/build/client/_app/immutable/nodes/2.Cp6Ttd_9.js.br +0 -0
  67. package/web/build/client/_app/immutable/nodes/2.Cp6Ttd_9.js.gz +0 -0
  68. package/web/build/client/_app/immutable/nodes/3.DLiDQm__.js +171 -0
  69. package/web/build/client/_app/immutable/nodes/3.DLiDQm__.js.br +0 -0
  70. package/web/build/client/_app/immutable/nodes/3.DLiDQm__.js.gz +0 -0
  71. package/web/build/client/_app/version.json +1 -1
  72. package/web/build/client/_app/version.json.br +0 -0
  73. package/web/build/client/_app/version.json.gz +0 -0
  74. package/web/build/handler.js +3 -3
  75. package/web/build/index.js +3 -3
  76. package/web/build/server/chunks/{0-SseDy__O.js → 0-BYok4D0n.js} +2 -2
  77. package/web/build/server/chunks/{0-SseDy__O.js.map → 0-BYok4D0n.js.map} +1 -1
  78. package/web/build/server/chunks/1-C7zcB0q0.js +7 -0
  79. package/web/build/server/chunks/1-C7zcB0q0.js.map +1 -0
  80. package/web/build/server/chunks/2-v6ejI_0-.js +7 -0
  81. package/web/build/server/chunks/2-v6ejI_0-.js.map +1 -0
  82. package/web/build/server/chunks/3-DAAmG6VX.js +7 -0
  83. package/web/build/server/chunks/3-DAAmG6VX.js.map +1 -0
  84. package/web/build/server/chunks/_server.ts-qYVrMYPB.js +62 -0
  85. package/web/build/server/chunks/_server.ts-qYVrMYPB.js.map +1 -0
  86. package/web/build/server/chunks/agent-sessions-DSqGavSn.js +2688 -0
  87. package/web/build/server/chunks/agent-sessions-DSqGavSn.js.map +1 -0
  88. package/web/build/server/chunks/{remote-1g4ayag-B67QMR_2.js → git-review-CpO_G8z8.js} +70 -704
  89. package/web/build/server/chunks/git-review-CpO_G8z8.js.map +1 -0
  90. package/web/build/server/chunks/{handler-B5P65tLk.js → handler-Di1wJQuU.js} +3 -3
  91. package/web/build/server/chunks/{handler-B5P65tLk.js.map → handler-Di1wJQuU.js.map} +1 -1
  92. package/web/build/server/chunks/hooks.server-BLOHobEK.js +42 -0
  93. package/web/build/server/chunks/hooks.server-BLOHobEK.js.map +1 -0
  94. package/web/build/server/chunks/{index.js-CL95e0Gl.js → index.js-yLYmrAWy.js} +3 -5
  95. package/web/build/server/chunks/index.js-yLYmrAWy.js.map +1 -0
  96. package/web/build/server/chunks/manifest.js-CXj8IIo3.js +58 -0
  97. package/web/build/server/chunks/manifest.js-CXj8IIo3.js.map +1 -0
  98. package/web/build/server/chunks/remote-1g4ayag-BiJeU0Pk.js +144 -0
  99. package/web/build/server/chunks/remote-1g4ayag-BiJeU0Pk.js.map +1 -0
  100. package/web/build/server/chunks/remote-wh6t6j-CHanXurm.js +162 -0
  101. package/web/build/server/chunks/remote-wh6t6j-CHanXurm.js.map +1 -0
  102. package/web/build/server/chunks/validate-BsTKyhsq.js +1073 -0
  103. package/web/build/server/chunks/validate-BsTKyhsq.js.map +1 -0
  104. package/web/build/client/_app/immutable/assets/2.CNFDEnjx.css +0 -1
  105. package/web/build/client/_app/immutable/assets/2.CNFDEnjx.css.br +0 -0
  106. package/web/build/client/_app/immutable/assets/2.CNFDEnjx.css.gz +0 -0
  107. package/web/build/client/_app/immutable/assets/3.PHTPLlZ7.css +0 -1
  108. package/web/build/client/_app/immutable/assets/3.PHTPLlZ7.css.br +0 -0
  109. package/web/build/client/_app/immutable/assets/3.PHTPLlZ7.css.gz +0 -0
  110. package/web/build/client/_app/immutable/assets/ThemeToggle.DcT79vPK.css +0 -1
  111. package/web/build/client/_app/immutable/assets/ThemeToggle.DcT79vPK.css.br +0 -0
  112. package/web/build/client/_app/immutable/assets/ThemeToggle.DcT79vPK.css.gz +0 -0
  113. package/web/build/client/_app/immutable/chunks/BBWAo_yW.js.br +0 -0
  114. package/web/build/client/_app/immutable/chunks/BBWAo_yW.js.gz +0 -0
  115. package/web/build/client/_app/immutable/chunks/Dur-vdUf.js +0 -3
  116. package/web/build/client/_app/immutable/chunks/Dur-vdUf.js.br +0 -0
  117. package/web/build/client/_app/immutable/chunks/Dur-vdUf.js.gz +0 -0
  118. package/web/build/client/_app/immutable/chunks/QdZF3nkf.js +0 -176
  119. package/web/build/client/_app/immutable/chunks/QdZF3nkf.js.br +0 -0
  120. package/web/build/client/_app/immutable/chunks/QdZF3nkf.js.gz +0 -0
  121. package/web/build/client/_app/immutable/chunks/tYeNsyIK.js +0 -1
  122. package/web/build/client/_app/immutable/chunks/tYeNsyIK.js.br +0 -0
  123. package/web/build/client/_app/immutable/chunks/tYeNsyIK.js.gz +0 -0
  124. package/web/build/client/_app/immutable/entry/app.Dce6uJU3.js +0 -2
  125. package/web/build/client/_app/immutable/entry/app.Dce6uJU3.js.br +0 -0
  126. package/web/build/client/_app/immutable/entry/app.Dce6uJU3.js.gz +0 -0
  127. package/web/build/client/_app/immutable/entry/start.DnBSC38l.js +0 -1
  128. package/web/build/client/_app/immutable/entry/start.DnBSC38l.js.br +0 -0
  129. package/web/build/client/_app/immutable/entry/start.DnBSC38l.js.gz +0 -0
  130. package/web/build/client/_app/immutable/nodes/0.s5xmjXoS.js +0 -1
  131. package/web/build/client/_app/immutable/nodes/0.s5xmjXoS.js.br +0 -1
  132. package/web/build/client/_app/immutable/nodes/0.s5xmjXoS.js.gz +0 -0
  133. package/web/build/client/_app/immutable/nodes/1.5Iv4ssDT.js +0 -1
  134. package/web/build/client/_app/immutable/nodes/1.5Iv4ssDT.js.br +0 -0
  135. package/web/build/client/_app/immutable/nodes/1.5Iv4ssDT.js.gz +0 -0
  136. package/web/build/client/_app/immutable/nodes/2.BomzYVBF.js +0 -66
  137. package/web/build/client/_app/immutable/nodes/2.BomzYVBF.js.br +0 -0
  138. package/web/build/client/_app/immutable/nodes/2.BomzYVBF.js.gz +0 -0
  139. package/web/build/client/_app/immutable/nodes/3.CeUs4lBX.js +0 -170
  140. package/web/build/client/_app/immutable/nodes/3.CeUs4lBX.js.br +0 -0
  141. package/web/build/client/_app/immutable/nodes/3.CeUs4lBX.js.gz +0 -0
  142. package/web/build/server/chunks/1-2OO2bb7H.js +0 -7
  143. package/web/build/server/chunks/1-2OO2bb7H.js.map +0 -1
  144. package/web/build/server/chunks/2-BJvuqKN6.js +0 -7
  145. package/web/build/server/chunks/2-BJvuqKN6.js.map +0 -1
  146. package/web/build/server/chunks/3-ZIl1rHef.js +0 -7
  147. package/web/build/server/chunks/3-ZIl1rHef.js.map +0 -1
  148. package/web/build/server/chunks/hooks.server-DERFlOe1.js +0 -14
  149. package/web/build/server/chunks/hooks.server-DERFlOe1.js.map +0 -1
  150. package/web/build/server/chunks/index.js-CL95e0Gl.js.map +0 -1
  151. package/web/build/server/chunks/manifest.js-uD_a0079.js +0 -50
  152. package/web/build/server/chunks/manifest.js-uD_a0079.js.map +0 -1
  153. package/web/build/server/chunks/remote-1g4ayag-B67QMR_2.js.map +0 -1
@@ -0,0 +1,379 @@
1
+ /**
2
+ * Qué comandos de terminal puede correr el agente por ACP: git de solo lectura, comandos que solo leen (cat, grep,
3
+ * find, sed -n …, y sus equivalentes de PowerShell) y el validate del paquete. Los paths tienen que quedar dentro
4
+ * del repo.
5
+ *
6
+ * El comando se tokeniza respetando comillas (un `grep "a\|b"` no es un pipe). Se aceptan `|`, `&&`, `||`, `;` y
7
+ * descartar salida a /dev/null; cualquier otra redirección, `$(…)`, backticks o `&` lo rechazan.
8
+ */
9
+ import { isAbsolute, normalize, resolve } from "node:path";
10
+
11
+ /** @typedef {{ ok: true } | { ok: false, reason: string }} ShellCheck */
12
+
13
+ /** Subcomandos de git que solo leen. */
14
+ const GIT_READ = new Set([
15
+ "diff",
16
+ "log",
17
+ "show",
18
+ "status",
19
+ "merge-base",
20
+ "rev-parse",
21
+ "rev-list",
22
+ "blame",
23
+ "ls-files",
24
+ "ls-tree",
25
+ "cat-file",
26
+ "describe",
27
+ "shortlog",
28
+ "name-rev",
29
+ "for-each-ref",
30
+ "grep",
31
+ "whatchanged",
32
+ ]);
33
+
34
+ /** Flags de git que ejecutan algo o escriben archivos, en cualquier subcomando. */
35
+ const GIT_UNSAFE_FLAG = /^(--output|--ext-diff|-O|--open-files-in-pager)(=|$)/;
36
+
37
+ const BRANCH_LIST_FLAGS = new Set(["--show-current", "--list", "-a", "--all", "-r", "--remotes", "-v", "-vv"]);
38
+ const CONFIG_READ_FLAGS = new Set(["--get", "--get-all", "--get-regexp", "--list", "-l"]);
39
+
40
+ /** Comandos que solo leen (o imprimen). Los de PowerShell van en minúscula: no distingue mayúsculas. */
41
+ const READ_COMMANDS = new Set([
42
+ "cat",
43
+ "head",
44
+ "tail",
45
+ "wc",
46
+ "ls",
47
+ "nl",
48
+ "sort",
49
+ "uniq",
50
+ "cut",
51
+ "tr",
52
+ "grep",
53
+ "egrep",
54
+ "fgrep",
55
+ "rg",
56
+ "find",
57
+ "sed",
58
+ "file",
59
+ "stat",
60
+ "du",
61
+ "diff",
62
+ "cmp",
63
+ "jq",
64
+ "basename",
65
+ "dirname",
66
+ "realpath",
67
+ "tree",
68
+ "which",
69
+ "type",
70
+ "pwd",
71
+ "echo",
72
+ "printf",
73
+ "true",
74
+ // PowerShell
75
+ "get-content",
76
+ "gc",
77
+ "select-string",
78
+ "sls",
79
+ "get-childitem",
80
+ "gci",
81
+ "dir",
82
+ "measure-object",
83
+ "measure",
84
+ "select-object",
85
+ "select",
86
+ "sort-object",
87
+ "get-location",
88
+ "test-path",
89
+ "resolve-path",
90
+ "write-output",
91
+ "out-string",
92
+ "format-list",
93
+ "format-table",
94
+ ]);
95
+
96
+ /** Comandos cuyos argumentos son texto, no paths. */
97
+ const TEXT_ARGS = new Set(["echo", "printf", "tr", "write-output", "which", "type"]);
98
+
99
+ /** Destinos de redirección que solo descartan salida. */
100
+ const DISCARD = new Set(["/dev/null", "nul", "$null"]);
101
+
102
+ /** `/c/Users/...` (Git Bash) → `C:/Users/...` en Windows. */
103
+ function toNativePath(p, platform) {
104
+ if (platform === "win32") {
105
+ const msys = /^\/([a-zA-Z])(\/|$)(.*)$/.exec(p);
106
+ if (msys) return `${msys[1].toUpperCase()}:/${msys[3]}`;
107
+ }
108
+ return p;
109
+ }
110
+
111
+ function isInside(child, root, platform) {
112
+ const c = normalize(child).replace(/[\\/]+$/, "");
113
+ const r = normalize(root).replace(/[\\/]+$/, "");
114
+ const [cc, rr] = platform === "win32" ? [c.toLowerCase(), r.toLowerCase()] : [c, r];
115
+ return cc === rr || cc.startsWith(`${rr}\\`) || cc.startsWith(`${rr}/`);
116
+ }
117
+
118
+ /** ¿El argumento parece un path que puede salir del repo? (absoluto, `~` o con `..`). */
119
+ function escapingPath(arg) {
120
+ return /^([a-zA-Z]:[\\/]|[\\/]|~)/.test(arg) || /(^|[\\/])\.\.([\\/]|$)/.test(arg);
121
+ }
122
+
123
+ /**
124
+ * Parte el comando en cadenas (separadas por `&&`, `||`, `;`, saltos de línea) de pipelines (`|`) de argv.
125
+ * @param {string} command
126
+ * @returns {{ ok: true, pipelines: string[][][] } | { ok: false, reason: string }}
127
+ */
128
+ export function tokenizeShell(command) {
129
+ const src = String(command ?? "");
130
+ /** @type {string[][][]} */
131
+ const pipelines = [];
132
+ /** @type {string[][]} */
133
+ let pipeline = [];
134
+ /** @type {string[]} */
135
+ let words = [];
136
+ let word = "";
137
+ let inWord = false;
138
+ /** @type {null | "'" | '"'} */
139
+ let quote = null;
140
+
141
+ const endWord = () => {
142
+ if (inWord) words.push(word);
143
+ word = "";
144
+ inWord = false;
145
+ };
146
+ const endSegment = () => {
147
+ endWord();
148
+ if (words.length) pipeline.push(words);
149
+ words = [];
150
+ };
151
+ const endPipeline = () => {
152
+ endSegment();
153
+ if (pipeline.length) pipelines.push(pipeline);
154
+ pipeline = [];
155
+ };
156
+ const fail = (reason) => ({ ok: /** @type {const} */ (false), reason });
157
+
158
+ for (let i = 0; i < src.length; i++) {
159
+ const ch = src[i];
160
+ const next = src[i + 1];
161
+
162
+ if (quote === "'") {
163
+ if (ch === "'") quote = null;
164
+ else word += ch;
165
+ continue;
166
+ }
167
+ if (quote === '"') {
168
+ if (ch === '"') quote = null;
169
+ else if (ch === "`") return fail("backticks");
170
+ else if (ch === "$" && next === "(") return fail("sustitución $(…)");
171
+ else if (ch === "\\" && next !== undefined && '"\\$`'.includes(next)) {
172
+ word += next;
173
+ i++;
174
+ } else word += ch;
175
+ continue;
176
+ }
177
+
178
+ if (ch === "'" || ch === '"') {
179
+ quote = ch;
180
+ inWord = true;
181
+ continue;
182
+ }
183
+ if (ch === "\\" && next !== undefined) {
184
+ word += next;
185
+ inWord = true;
186
+ i++;
187
+ continue;
188
+ }
189
+ if (ch === "`") return fail("backticks");
190
+ if (ch === "$" && next === "(") return fail("sustitución $(…)");
191
+ if (/\s/.test(ch) && ch !== "\n" && ch !== "\r") {
192
+ endWord();
193
+ continue;
194
+ }
195
+ if (ch === "\n" || ch === "\r" || ch === ";") {
196
+ endPipeline();
197
+ continue;
198
+ }
199
+ if ((ch === "&" && next === "&") || (ch === "|" && next === "|")) {
200
+ endPipeline();
201
+ i++;
202
+ continue;
203
+ }
204
+ if (ch === "|") {
205
+ endSegment();
206
+ continue;
207
+ }
208
+ if (ch === ">" || (ch === "&" && next === ">")) {
209
+ // `2>&1`, `2>/dev/null`, `>nul`, `&>/dev/null`: descartar o juntar salidas no escribe nada.
210
+ if (ch === "&") i++;
211
+ const fd = inWord && /^[12]$/.test(word) ? word : "";
212
+ if (inWord && !fd) return fail("redirección");
213
+ word = "";
214
+ inWord = false;
215
+ let j = i + 1;
216
+ if (src[j] === ">") j++;
217
+ if (src[j] === "&" && /[12]/.test(src[j + 1] ?? "")) {
218
+ i = j + 1;
219
+ continue;
220
+ }
221
+ while (src[j] === " " || src[j] === "\t") j++;
222
+ let target = "";
223
+ while (j < src.length && !/[\s;|&<>]/.test(src[j])) target += src[j++];
224
+ if (!DISCARD.has(target.toLowerCase())) return fail(`redirección a ${target || "archivo"}`);
225
+ i = j - 1;
226
+ continue;
227
+ }
228
+ if (ch === "<") return fail("redirección de entrada");
229
+ if (ch === "&") return fail("comando en segundo plano (&)");
230
+ word += ch;
231
+ inWord = true;
232
+ }
233
+ if (quote) return fail("comillas sin cerrar");
234
+ endPipeline();
235
+ return { ok: true, pipelines };
236
+ }
237
+
238
+ /**
239
+ * @param {string} arg
240
+ * @param {{ repo: string, platform: NodeJS.Platform }} ctx
241
+ * @returns {ShellCheck}
242
+ */
243
+ function checkPath(arg, { repo, platform }) {
244
+ if (DISCARD.has(arg.toLowerCase())) return { ok: true };
245
+ const native = toNativePath(arg.replace(/^~(?=[\\/]|$)/, "/__home__"), platform);
246
+ const abs = isAbsolute(native) ? native : resolve(repo, native);
247
+ return isInside(abs, repo, platform) ? { ok: true } : { ok: false, reason: `path fuera del repo: ${arg}` };
248
+ }
249
+
250
+ /** Argumentos (y valores de `--opt=valor`) que parecen paths que salen del repo deben quedar adentro. */
251
+ function checkArgsInside(args, ctx) {
252
+ for (const arg of args) {
253
+ const value = arg.startsWith("-") ? (arg.includes("=") ? arg.slice(arg.indexOf("=") + 1) : "") : arg;
254
+ if (value && escapingPath(value)) {
255
+ const res = checkPath(value, ctx);
256
+ if (!res.ok) return res;
257
+ }
258
+ }
259
+ return { ok: true };
260
+ }
261
+
262
+ /** @param {string[]} argv @returns {ShellCheck} */
263
+ function checkGit(argv, ctx) {
264
+ let i = 1;
265
+ while (argv[i]?.startsWith("-")) {
266
+ const flag = argv[i];
267
+ if (flag === "--no-pager" || flag === "-P") i++;
268
+ else if (flag === "-C") {
269
+ const res = checkPath(argv[i + 1] ?? "", ctx);
270
+ if (!res.ok) return res;
271
+ i += 2;
272
+ } else return { ok: false, reason: `opción global de git no permitida: ${flag}` };
273
+ }
274
+ const sub = argv[i] ?? "";
275
+ const rest = argv.slice(i + 1);
276
+ const unsafe = rest.find((w) => GIT_UNSAFE_FLAG.test(w));
277
+ if (unsafe) return { ok: false, reason: `git ${sub} ${unsafe}` };
278
+ if (GIT_READ.has(sub)) return { ok: true };
279
+ if (sub === "branch" && rest.every((w) => BRANCH_LIST_FLAGS.has(w))) return { ok: true };
280
+ if (sub === "remote" && (rest.length === 0 || (rest.length === 1 && rest[0] === "-v") || rest[0] === "get-url"))
281
+ return { ok: true };
282
+ if (sub === "config" && rest.some((w) => CONFIG_READ_FLAGS.has(w))) return { ok: true };
283
+ return { ok: false, reason: `git ${sub || "(sin subcomando)"} no es de solo lectura` };
284
+ }
285
+
286
+ /** @param {string[]} argv @returns {ShellCheck} */
287
+ function checkReadCommand(name, argv, ctx) {
288
+ const args = argv.slice(1);
289
+ if (name === "sort" && args.some((a) => /^(-o|--output)/.test(a))) return { ok: false, reason: "sort -o escribe un archivo" };
290
+ if (name === "uniq" && args.filter((a) => !a.startsWith("-")).length > 1)
291
+ return { ok: false, reason: "uniq con archivo de salida" };
292
+ if (name === "find" && args.some((a) => /^-(exec|execdir|ok|okdir|delete|fprint|fprint0|fprintf|fls)$/.test(a)))
293
+ return { ok: false, reason: "find que ejecuta o escribe" };
294
+ if (name === "rg" && args.some((a) => /^--pre(-glob)?(=|$)/.test(a))) return { ok: false, reason: "rg --pre ejecuta comandos" };
295
+ if (name === "sed") {
296
+ if (!args.includes("-n") || args.some((a) => /^(-i|--in-place|-e|-f|--expression|--file)/.test(a)))
297
+ return { ok: false, reason: "sed solo como `sed -n 'N,Mp'`" };
298
+ const script = args.find((a) => !a.startsWith("-"));
299
+ if (!script || !/^\s*(\d+|\$)(\s*,\s*(\d+|\$))?\s*p\s*$/.test(script))
300
+ return { ok: false, reason: "sed solo como `sed -n 'N,Mp'`" };
301
+ return checkArgsInside(args.filter((a) => a !== script), ctx);
302
+ }
303
+ if (TEXT_ARGS.has(name)) return { ok: true };
304
+ return checkArgsInside(args, ctx);
305
+ }
306
+
307
+ /** El validate del paquete (por npx o con el bin instalado), con el archivo como único argumento. */
308
+ function isValidate(argv) {
309
+ const line = argv.join(" ");
310
+ return /^(?:npx\s+(?:--yes\s+|-y\s+)?@jpkme\/diff-review(?:@[\w.^~-]+)?|diff-review)\s+validate\s+\S+$/.test(line);
311
+ }
312
+
313
+ /**
314
+ * `mkdir` / `md` / `New-Item -ItemType Directory` solo bajo `.diff-review/` del repo.
315
+ * @param {string[]} argv
316
+ * @param {{ repo: string, platform: NodeJS.Platform }} ctx
317
+ * @returns {ShellCheck | null} null = no es este caso
318
+ */
319
+ function checkReviewDirMkdir(argv, ctx) {
320
+ const name = argv[0].toLowerCase();
321
+ const reviewRoot = normalize(resolve(ctx.repo, ".diff-review")).replace(/[\\/]+$/, "");
322
+ /** @param {string} arg */
323
+ const underReviewDir = (arg) => {
324
+ 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}/`);
328
+ };
329
+
330
+ if (name === "mkdir" || name === "md") {
331
+ const paths = argv.slice(1).filter((a) => !a.startsWith("-"));
332
+ if (!paths.length || !paths.every(underReviewDir)) {
333
+ return { ok: false, reason: "mkdir solo permitido bajo .diff-review/" };
334
+ }
335
+ return { ok: true };
336
+ }
337
+ if (name === "new-item") {
338
+ const joined = argv.join(" ").toLowerCase();
339
+ if (!/-itemtype\s+directory\b/.test(joined) && !/\b-type\s+directory\b/.test(joined)) return null;
340
+ const paths = argv.slice(1).filter((a) => !a.startsWith("-") && !/^(directory|file)$/i.test(a));
341
+ if (!paths.length || !paths.every(underReviewDir)) {
342
+ return { ok: false, reason: "New-Item Directory solo permitido bajo .diff-review/" };
343
+ }
344
+ return { ok: true };
345
+ }
346
+ return null;
347
+ }
348
+
349
+ /**
350
+ * @param {string} command
351
+ * @param {{ repo?: string, platform?: NodeJS.Platform }} [opts]
352
+ * @returns {ShellCheck}
353
+ */
354
+ export function checkShell(command, { repo, platform = process.platform } = {}) {
355
+ const parsed = tokenizeShell(command);
356
+ if (!parsed.ok) return parsed;
357
+ if (!parsed.pipelines.length) return { ok: false, reason: "comando vacío" };
358
+ const ctx = { repo: repo || process.cwd(), platform };
359
+ for (const pipeline of parsed.pipelines) {
360
+ for (const argv of pipeline) {
361
+ const name = argv[0].toLowerCase();
362
+ if (argv[0].includes("=")) return { ok: false, reason: `variables de entorno en el comando: ${argv[0]}` };
363
+ /** @type {ShellCheck} */
364
+ let res;
365
+ // `cd` solo dentro del repo (sin argumento iría al home).
366
+ if (name === "cd" || name === "set-location")
367
+ res = repo && argv[1] ? checkPath(argv[1], ctx) : { ok: false, reason: "cd fuera del repo" };
368
+ else if (name === "git") res = checkGit(argv, ctx);
369
+ else if (name === "npx" || name === "diff-review") res = isValidate(argv) ? { ok: true } : { ok: false, reason: `${argv.join(" ")}` };
370
+ else if (READ_COMMANDS.has(name)) res = checkReadCommand(name, argv, ctx);
371
+ else {
372
+ const mkdir = checkReviewDirMkdir(argv, ctx);
373
+ res = mkdir ?? { ok: false, reason: `${argv[0]} no está permitido` };
374
+ }
375
+ if (!res.ok) return res;
376
+ }
377
+ }
378
+ return { ok: true };
379
+ }
@@ -0,0 +1,127 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { join } from "node:path";
4
+ import { checkShell, tokenizeShell } from "./acp-shell.mjs";
5
+
6
+ const win = process.platform === "win32";
7
+ const repo = win ? "C:\\repos\\app" : "/repos/app";
8
+ const out = "diff-rev_75eca0c739_52eb729c8f.json";
9
+ const ok = (cmd) => checkShell(cmd, { repo }).ok;
10
+
11
+ test("tokenizeShell respeta comillas y separa cadenas y pipes", () => {
12
+ const parsed = tokenizeShell(`git show main:a.ts | grep -n "a\\|b|c" ; echo 'x; y' && ls`);
13
+ assert.equal(parsed.ok, true);
14
+ assert.deepEqual(parsed.pipelines, [
15
+ [
16
+ ["git", "show", "main:a.ts"],
17
+ ["grep", "-n", "a\\|b|c"],
18
+ ],
19
+ [["echo", "x; y"]],
20
+ [["ls"]],
21
+ ]);
22
+ });
23
+
24
+ test("tokenizeShell descarta salida a /dev/null y rechaza el resto de redirecciones", () => {
25
+ assert.equal(tokenizeShell("git diff 2>&1").ok, true);
26
+ assert.equal(tokenizeShell("git diff 2>/dev/null || true").ok, true);
27
+ assert.equal(tokenizeShell("git diff &>/dev/null").ok, true);
28
+ assert.equal(tokenizeShell("Get-Content a.txt 2>$null").ok, true);
29
+ assert.equal(tokenizeShell("git diff > x.patch").ok, false);
30
+ assert.equal(tokenizeShell("git diff >> x.patch").ok, false);
31
+ assert.equal(tokenizeShell("cat < a").ok, false);
32
+ assert.equal(tokenizeShell('echo "$(whoami)"').ok, false);
33
+ assert.equal(tokenizeShell("echo `whoami`").ok, false);
34
+ assert.equal(tokenizeShell("sleep 1 & curl x").ok, false);
35
+ assert.equal(tokenizeShell('echo "sin cerrar').ok, false);
36
+ });
37
+
38
+ test("checkShell deja git de lectura, el validate y comandos que solo leen", () => {
39
+ for (const cmd of [
40
+ "git diff main~1...main",
41
+ "git --no-pager log --oneline -5",
42
+ `git -C "${repo}" diff --stat a...b`,
43
+ "git merge-base develop feat/x && git diff --stat develop...feat/x",
44
+ "git show HEAD:lib/a.mjs | head -50",
45
+ "git branch --show-current",
46
+ "git remote get-url origin",
47
+ "git config --get remote.origin.url",
48
+ `npx --yes @jpkme/diff-review validate ${out}`,
49
+ `npx --yes @jpkme/diff-review@latest validate ${out}`,
50
+ `cd "${repo}" && git diff a...b`,
51
+ `cd "${repo}"; git diff`,
52
+ // Lo que mandó Claude en las pruebas.
53
+ `git show main:web/src/routes/+page.svelte | sed -n 560,660p; git show main:web/src/lib/report/storage.ts | sed -n 1,60p`,
54
+ `git show main:web/src/routes/+page.svelte | grep -n -A6 "function updateMeta\\|openNewReport(pendingDoc\\|const ctx"`,
55
+ `grep -rn "validateDocument(" web/src --include="*.ts"`,
56
+ "cat package.json | head -20",
57
+ "ls -la lib",
58
+ "find web/src -name '*.svelte' -type f",
59
+ "wc -l lib/*.mjs",
60
+ "rg -n 'export function' lib",
61
+ "sed -n '10,40p' lib/agents.mjs",
62
+ "git diff a...b -- lib 2>/dev/null || true",
63
+ "Get-Content lib/agents.mjs | Select-Object -First 40",
64
+ "Select-String -Path lib/*.mjs -Pattern 'export'",
65
+ ]) {
66
+ assert.equal(ok(cmd), true, cmd);
67
+ }
68
+ });
69
+
70
+ test("checkShell rechaza escrituras, ejecución y paths fuera del repo", () => {
71
+ const home = win ? "C:\\Users\\me\\.ssh\\id_rsa" : "/home/me/.ssh/id_rsa";
72
+ for (const cmd of [
73
+ "",
74
+ "git checkout main",
75
+ "git branch -D feat",
76
+ "git push",
77
+ "git -c core.pager=sh log",
78
+ "git diff --output=x.patch main",
79
+ "git diff --ext-diff",
80
+ "git grep -O foo",
81
+ "git diff; rm -rf .",
82
+ "git log | xargs rm",
83
+ "git diff > x.patch",
84
+ "GIT_PAGER=sh git log",
85
+ "npm install",
86
+ "curl https://example.com",
87
+ "node -e 'x'",
88
+ `npx --yes @jpkme/diff-review validate ${out} && rm x`,
89
+ "npx --yes other-pkg validate x.json",
90
+ "sed -i 's/a/b/' lib/a.mjs",
91
+ "sed -n 's/a/b/w out' lib/a.mjs",
92
+ "find . -name '*.js' -delete",
93
+ "find . -exec rm {} ;",
94
+ "sort -o out.txt a.txt",
95
+ "uniq a.txt b.txt",
96
+ "rg --pre sh foo",
97
+ `cat "${home}"`,
98
+ "cat ~/.ssh/id_rsa",
99
+ "cat ../otro/secreto",
100
+ `cd "${join(repo, "..")}" && git diff a...b`,
101
+ "cd && cat .bashrc",
102
+ "Remove-Item lib",
103
+ "Get-ChildItem | ForEach-Object { Remove-Item $_ }",
104
+ ]) {
105
+ assert.equal(ok(cmd), false, cmd);
106
+ }
107
+ });
108
+
109
+ test("checkShell sin repo no acepta cd", () => {
110
+ assert.equal(checkShell(`cd "${repo}" && git diff`).ok, false);
111
+ });
112
+
113
+ test("checkShell permite mkdir bajo .diff-review en el repo", () => {
114
+ assert.equal(ok("mkdir .diff-review"), true);
115
+ assert.equal(ok("mkdir -p .diff-review"), true);
116
+ assert.equal(ok("mkdir .diff-review/tmp"), true);
117
+ assert.equal(ok(`mkdir "${join(repo, ".diff-review")}"`), true);
118
+ assert.equal(ok("mkdir lib"), false);
119
+ assert.equal(ok("mkdir .diff-review other"), false);
120
+ assert.equal(ok("New-Item -ItemType Directory .diff-review"), true);
121
+ assert.equal(ok("New-Item -ItemType Directory lib"), false);
122
+ });
123
+
124
+ test("checkShell entiende los paths de Git Bash en Windows", { skip: !win }, () => {
125
+ assert.equal(ok("cat /c/repos/app/lib/a.mjs"), true);
126
+ assert.equal(ok("cat /c/Users/me/.ssh/id_rsa"), false);
127
+ });