@jpkme/diff-review 0.1.10 → 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 (159) hide show
  1. package/README.md +38 -15
  2. package/bin/diff-review.mjs +151 -34
  3. package/lib/acp-policy.mjs +223 -0
  4. package/lib/acp-policy.test.mjs +136 -0
  5. package/lib/acp-shell.mjs +409 -0
  6. package/lib/acp-shell.test.mjs +158 -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 +30 -11
  13. package/lib/check-report.test.mjs +1 -0
  14. package/lib/extract-cache.test.mjs +162 -0
  15. package/lib/extract.mjs +197 -1
  16. package/lib/model-label.mjs +263 -0
  17. package/lib/model-label.test.mjs +77 -0
  18. package/lib/models-cache.mjs +75 -0
  19. package/lib/models-cache.test.mjs +50 -0
  20. package/lib/output-name.mjs +8 -0
  21. package/lib/prompt.mjs +168 -22
  22. package/lib/review-dir.mjs +108 -0
  23. package/lib/review-meta.mjs +5 -3
  24. package/lib/review-meta.test.mjs +11 -0
  25. package/lib/review-prep.mjs +63 -0
  26. package/lib/validate.mjs +2 -2
  27. package/lib/wait-output.mjs +46 -23
  28. package/lib/wait-output.test.mjs +24 -1
  29. package/package.json +3 -1
  30. package/web/build/client/_app/immutable/assets/{0.CLQiq3Ed.css → 0.BKRG1f36.css} +1 -1
  31. package/web/build/client/_app/immutable/assets/0.BKRG1f36.css.br +0 -0
  32. package/web/build/client/_app/immutable/assets/{0.CLQiq3Ed.css.gz → 0.BKRG1f36.css.gz} +0 -0
  33. package/web/build/client/_app/immutable/assets/2.tqrMRTM0.css +1 -0
  34. package/web/build/client/_app/immutable/assets/2.tqrMRTM0.css.br +0 -0
  35. package/web/build/client/_app/immutable/assets/2.tqrMRTM0.css.gz +0 -0
  36. package/web/build/client/_app/immutable/assets/3.BT54aVKb.css +1 -0
  37. package/web/build/client/_app/immutable/assets/3.BT54aVKb.css.br +0 -0
  38. package/web/build/client/_app/immutable/assets/3.BT54aVKb.css.gz +0 -0
  39. package/web/build/client/_app/immutable/assets/storage.DqYH0w6R.css +1 -0
  40. package/web/build/client/_app/immutable/assets/storage.DqYH0w6R.css.br +0 -0
  41. package/web/build/client/_app/immutable/assets/storage.DqYH0w6R.css.gz +0 -0
  42. package/web/build/client/_app/immutable/chunks/BXUWMim4.js +213 -0
  43. package/web/build/client/_app/immutable/chunks/BXUWMim4.js.br +0 -0
  44. package/web/build/client/_app/immutable/chunks/BXUWMim4.js.gz +0 -0
  45. package/web/build/client/_app/immutable/chunks/Bu1mm6Oz.js +1 -0
  46. package/web/build/client/_app/immutable/chunks/Bu1mm6Oz.js.br +0 -0
  47. package/web/build/client/_app/immutable/chunks/Bu1mm6Oz.js.gz +0 -0
  48. package/web/build/client/_app/immutable/chunks/D8WOD_eS.js +3 -0
  49. package/web/build/client/_app/immutable/chunks/D8WOD_eS.js.br +0 -0
  50. package/web/build/client/_app/immutable/chunks/D8WOD_eS.js.gz +0 -0
  51. package/web/build/client/_app/immutable/chunks/{BBWAo_yW.js → DPfXzQnm.js} +2 -2
  52. package/web/build/client/_app/immutable/chunks/DPfXzQnm.js.br +0 -0
  53. package/web/build/client/_app/immutable/chunks/DPfXzQnm.js.gz +0 -0
  54. package/web/build/client/_app/immutable/chunks/lUYXRDtZ.js +1 -0
  55. package/web/build/client/_app/immutable/chunks/lUYXRDtZ.js.br +0 -0
  56. package/web/build/client/_app/immutable/chunks/lUYXRDtZ.js.gz +0 -0
  57. package/web/build/client/_app/immutable/entry/app.CTgiuiU1.js +2 -0
  58. package/web/build/client/_app/immutable/entry/app.CTgiuiU1.js.br +0 -0
  59. package/web/build/client/_app/immutable/entry/app.CTgiuiU1.js.gz +0 -0
  60. package/web/build/client/_app/immutable/entry/start.CFSl0kht.js +1 -0
  61. package/web/build/client/_app/immutable/entry/start.CFSl0kht.js.br +0 -0
  62. package/web/build/client/_app/immutable/entry/start.CFSl0kht.js.gz +0 -0
  63. package/web/build/client/_app/immutable/nodes/0.Dj97JW46.js +1 -0
  64. package/web/build/client/_app/immutable/nodes/0.Dj97JW46.js.br +0 -0
  65. package/web/build/client/_app/immutable/nodes/0.Dj97JW46.js.gz +0 -0
  66. package/web/build/client/_app/immutable/nodes/1.0PQZpSbJ.js +1 -0
  67. package/web/build/client/_app/immutable/nodes/1.0PQZpSbJ.js.br +2 -0
  68. package/web/build/client/_app/immutable/nodes/1.0PQZpSbJ.js.gz +0 -0
  69. package/web/build/client/_app/immutable/nodes/2.DQaoQoy4.js +10 -0
  70. package/web/build/client/_app/immutable/nodes/2.DQaoQoy4.js.br +0 -0
  71. package/web/build/client/_app/immutable/nodes/2.DQaoQoy4.js.gz +0 -0
  72. package/web/build/client/_app/immutable/nodes/3.AciugOtL.js +171 -0
  73. package/web/build/client/_app/immutable/nodes/3.AciugOtL.js.br +0 -0
  74. package/web/build/client/_app/immutable/nodes/3.AciugOtL.js.gz +0 -0
  75. package/web/build/client/_app/version.json +1 -1
  76. package/web/build/client/_app/version.json.br +0 -0
  77. package/web/build/client/_app/version.json.gz +0 -0
  78. package/web/build/handler.js +3 -3
  79. package/web/build/index.js +3 -3
  80. package/web/build/server/chunks/0-DUStDOsr.js +11 -0
  81. package/web/build/server/chunks/{0-SseDy__O.js.map → 0-DUStDOsr.js.map} +1 -1
  82. package/web/build/server/chunks/1-C2qlInYk.js +7 -0
  83. package/web/build/server/chunks/1-C2qlInYk.js.map +1 -0
  84. package/web/build/server/chunks/2-DyE177MK.js +7 -0
  85. package/web/build/server/chunks/2-DyE177MK.js.map +1 -0
  86. package/web/build/server/chunks/3-SO3atuWO.js +7 -0
  87. package/web/build/server/chunks/3-SO3atuWO.js.map +1 -0
  88. package/web/build/server/chunks/_server.ts-CRNhzL2a.js +62 -0
  89. package/web/build/server/chunks/_server.ts-CRNhzL2a.js.map +1 -0
  90. package/web/build/server/chunks/agent-sessions-kcUaLhK5.js +2819 -0
  91. package/web/build/server/chunks/agent-sessions-kcUaLhK5.js.map +1 -0
  92. package/web/build/server/chunks/{remote-1g4ayag-B67QMR_2.js → git-review-CXKcjYgZ.js} +70 -704
  93. package/web/build/server/chunks/git-review-CXKcjYgZ.js.map +1 -0
  94. package/web/build/server/chunks/{handler-B5P65tLk.js → handler-_E0NucKN.js} +3 -3
  95. package/web/build/server/chunks/{handler-B5P65tLk.js.map → handler-_E0NucKN.js.map} +1 -1
  96. package/web/build/server/chunks/hooks.server-D7yPODoc.js +42 -0
  97. package/web/build/server/chunks/hooks.server-D7yPODoc.js.map +1 -0
  98. package/web/build/server/chunks/{index.js-CL95e0Gl.js → index.js-lMtDM8ZQ.js} +3 -5
  99. package/web/build/server/chunks/index.js-lMtDM8ZQ.js.map +1 -0
  100. package/web/build/server/chunks/manifest.js-BIm6jot5.js +58 -0
  101. package/web/build/server/chunks/manifest.js-BIm6jot5.js.map +1 -0
  102. package/web/build/server/chunks/remote-1g4ayag-DmqxwUIz.js +144 -0
  103. package/web/build/server/chunks/remote-1g4ayag-DmqxwUIz.js.map +1 -0
  104. package/web/build/server/chunks/remote-wh6t6j-DZk0W9Pp.js +162 -0
  105. package/web/build/server/chunks/remote-wh6t6j-DZk0W9Pp.js.map +1 -0
  106. package/web/build/server/chunks/validate-BsTKyhsq.js +1073 -0
  107. package/web/build/server/chunks/validate-BsTKyhsq.js.map +1 -0
  108. package/web/build/client/_app/immutable/assets/0.CLQiq3Ed.css.br +0 -0
  109. package/web/build/client/_app/immutable/assets/2.CNFDEnjx.css +0 -1
  110. package/web/build/client/_app/immutable/assets/2.CNFDEnjx.css.br +0 -0
  111. package/web/build/client/_app/immutable/assets/2.CNFDEnjx.css.gz +0 -0
  112. package/web/build/client/_app/immutable/assets/3.PHTPLlZ7.css +0 -1
  113. package/web/build/client/_app/immutable/assets/3.PHTPLlZ7.css.br +0 -0
  114. package/web/build/client/_app/immutable/assets/3.PHTPLlZ7.css.gz +0 -0
  115. package/web/build/client/_app/immutable/assets/ThemeToggle.DcT79vPK.css +0 -1
  116. package/web/build/client/_app/immutable/assets/ThemeToggle.DcT79vPK.css.br +0 -0
  117. package/web/build/client/_app/immutable/assets/ThemeToggle.DcT79vPK.css.gz +0 -0
  118. package/web/build/client/_app/immutable/chunks/BBWAo_yW.js.br +0 -0
  119. package/web/build/client/_app/immutable/chunks/BBWAo_yW.js.gz +0 -0
  120. package/web/build/client/_app/immutable/chunks/Dur-vdUf.js +0 -3
  121. package/web/build/client/_app/immutable/chunks/Dur-vdUf.js.br +0 -0
  122. package/web/build/client/_app/immutable/chunks/Dur-vdUf.js.gz +0 -0
  123. package/web/build/client/_app/immutable/chunks/QdZF3nkf.js +0 -176
  124. package/web/build/client/_app/immutable/chunks/QdZF3nkf.js.br +0 -0
  125. package/web/build/client/_app/immutable/chunks/QdZF3nkf.js.gz +0 -0
  126. package/web/build/client/_app/immutable/chunks/tYeNsyIK.js +0 -1
  127. package/web/build/client/_app/immutable/chunks/tYeNsyIK.js.br +0 -0
  128. package/web/build/client/_app/immutable/chunks/tYeNsyIK.js.gz +0 -0
  129. package/web/build/client/_app/immutable/entry/app.Dce6uJU3.js +0 -2
  130. package/web/build/client/_app/immutable/entry/app.Dce6uJU3.js.br +0 -0
  131. package/web/build/client/_app/immutable/entry/app.Dce6uJU3.js.gz +0 -0
  132. package/web/build/client/_app/immutable/entry/start.DnBSC38l.js +0 -1
  133. package/web/build/client/_app/immutable/entry/start.DnBSC38l.js.br +0 -0
  134. package/web/build/client/_app/immutable/entry/start.DnBSC38l.js.gz +0 -0
  135. package/web/build/client/_app/immutable/nodes/0.s5xmjXoS.js +0 -1
  136. package/web/build/client/_app/immutable/nodes/0.s5xmjXoS.js.br +0 -1
  137. package/web/build/client/_app/immutable/nodes/0.s5xmjXoS.js.gz +0 -0
  138. package/web/build/client/_app/immutable/nodes/1.5Iv4ssDT.js +0 -1
  139. package/web/build/client/_app/immutable/nodes/1.5Iv4ssDT.js.br +0 -0
  140. package/web/build/client/_app/immutable/nodes/1.5Iv4ssDT.js.gz +0 -0
  141. package/web/build/client/_app/immutable/nodes/2.BomzYVBF.js +0 -66
  142. package/web/build/client/_app/immutable/nodes/2.BomzYVBF.js.br +0 -0
  143. package/web/build/client/_app/immutable/nodes/2.BomzYVBF.js.gz +0 -0
  144. package/web/build/client/_app/immutable/nodes/3.CeUs4lBX.js +0 -170
  145. package/web/build/client/_app/immutable/nodes/3.CeUs4lBX.js.br +0 -0
  146. package/web/build/client/_app/immutable/nodes/3.CeUs4lBX.js.gz +0 -0
  147. package/web/build/server/chunks/0-SseDy__O.js +0 -11
  148. package/web/build/server/chunks/1-2OO2bb7H.js +0 -7
  149. package/web/build/server/chunks/1-2OO2bb7H.js.map +0 -1
  150. package/web/build/server/chunks/2-BJvuqKN6.js +0 -7
  151. package/web/build/server/chunks/2-BJvuqKN6.js.map +0 -1
  152. package/web/build/server/chunks/3-ZIl1rHef.js +0 -7
  153. package/web/build/server/chunks/3-ZIl1rHef.js.map +0 -1
  154. package/web/build/server/chunks/hooks.server-DERFlOe1.js +0 -14
  155. package/web/build/server/chunks/hooks.server-DERFlOe1.js.map +0 -1
  156. package/web/build/server/chunks/index.js-CL95e0Gl.js.map +0 -1
  157. package/web/build/server/chunks/manifest.js-uD_a0079.js +0 -50
  158. package/web/build/server/chunks/manifest.js-uD_a0079.js.map +0 -1
  159. package/web/build/server/chunks/remote-1g4ayag-B67QMR_2.js.map +0 -1
@@ -0,0 +1,136 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { mkdtempSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import {
7
+ decidePermission,
8
+ humanizePolicyReason,
9
+ permissionOutcome,
10
+ toolCallCommand,
11
+ toolCallLabel,
12
+ } from "./acp-policy.mjs";
13
+
14
+ const repo = process.platform === "win32" ? "C:\\repos\\app" : "/repos/app";
15
+ const out = "diff-rev_75eca0c739_52eb729c8f.json";
16
+ const ctx = (mode = "review") => ({ repo, outputFilename: out, mode });
17
+ const outPath = join(repo, ".diff-review", out);
18
+
19
+ // Formas reales que manda claude-agent-acp.
20
+ const bash = (command) => ({ kind: "execute", title: command, rawInput: { command, description: "x" } });
21
+ const read = (file_path) => ({ kind: "read", title: "Read", rawInput: { file_path }, locations: [{ path: file_path, line: 1 }] });
22
+ const edit = (file_path) => ({ kind: "edit", title: "Write", rawInput: { file_path, content: "{}" }, locations: [{ path: file_path }] });
23
+
24
+ test("decidePermission: lecturas solo dentro del repo", () => {
25
+ assert.equal(decidePermission(read(join(repo, "lib", "a.mjs")), ctx()).allow, true);
26
+ assert.equal(decidePermission(read("lib/a.mjs"), ctx()).allow, true);
27
+ const home = process.platform === "win32" ? "C:\\Users\\me\\.ssh\\id_rsa" : "/home/me/.ssh/id_rsa";
28
+ assert.equal(decidePermission(read(home), ctx()).allow, false);
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);
33
+ });
34
+
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", () => {
63
+ assert.equal(decidePermission(edit(outPath), ctx("review")).allow, true);
64
+ assert.equal(decidePermission(edit(`.diff-review/${out}`), ctx("edit")).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);
67
+ assert.equal(decidePermission(edit(outPath), ctx("ask")).allow, false);
68
+ // Fuera de .diff-review/: no.
69
+ assert.equal(decidePermission(edit(join(repo, out)), ctx("review")).allow, false);
70
+ assert.equal(decidePermission(edit(join(repo, "lib", "a.mjs")), ctx("edit")).allow, false);
71
+ assert.equal(decidePermission({ kind: "edit", title: "Edit", rawInput: {} }, ctx("edit")).allow, false);
72
+ // Claude manda el Write con el path solo en el diff de `content`.
73
+ const write = (path) => ({ kind: "edit", title: "Write", rawInput: {}, locations: [], content: [{ type: "diff", path, oldText: null, newText: "{}" }] });
74
+ assert.equal(decidePermission(write(outPath), ctx("review")).allow, true);
75
+ assert.equal(decidePermission(write(join(repo, "x.json")), ctx("review")).allow, false);
76
+ assert.equal(
77
+ decidePermission({ kind: "delete", title: "Delete", rawInput: { path: outPath }, locations: [{ path: outPath }] }, ctx("review"))
78
+ .allow,
79
+ true
80
+ );
81
+ assert.equal(
82
+ decidePermission(
83
+ { kind: "delete", title: "Delete", rawInput: { path: join(repo, "lib", "a.mjs") }, locations: [{ path: join(repo, "lib", "a.mjs") }] },
84
+ ctx("review")
85
+ ).allow,
86
+ false
87
+ );
88
+ });
89
+
90
+ test("decidePermission: comandos según isAllowedShell y el resto rechazado", () => {
91
+ assert.equal(decidePermission(bash("git diff a...b"), ctx("ask")).allow, true);
92
+ assert.equal(decidePermission(bash("rm -rf ."), ctx()).allow, false);
93
+ // Cursor: sin rawInput, el comando entre backticks en el título.
94
+ const cursor = (cmd) => ({ kind: "execute", title: `\`${cmd}\`` });
95
+ assert.equal(decidePermission(cursor("git log --oneline -3"), ctx()).allow, true);
96
+ assert.equal(decidePermission(cursor("echo hola > hola.txt"), ctx()).allow, false);
97
+ // El motivo del rechazo dice qué falló (lo ve el usuario en la web).
98
+ assert.match(decidePermission(bash("git diff > x.patch"), ctx()).reason, /redirección/);
99
+ assert.equal(decidePermission({ kind: "fetch", title: "WebFetch", rawInput: { url: "https://x" } }, ctx()).allow, false);
100
+ assert.equal(decidePermission({ kind: "delete", title: "rm", rawInput: {} }, ctx("edit")).allow, false);
101
+ });
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
+
117
+ test("permissionOutcome elige once / always según el scope", () => {
118
+ const options = [
119
+ { optionId: "a1", kind: "allow_always", name: "Siempre" },
120
+ { optionId: "a", kind: "allow_once", name: "Sí" },
121
+ { optionId: "r", kind: "reject_once", name: "No" },
122
+ ];
123
+ assert.deepEqual(permissionOutcome(options, true), { outcome: { outcome: "selected", optionId: "a" } });
124
+ assert.deepEqual(permissionOutcome(options, true, { always: true }), {
125
+ outcome: { outcome: "selected", optionId: "a1" },
126
+ });
127
+ assert.deepEqual(permissionOutcome(options, false), { outcome: { outcome: "selected", optionId: "r" } });
128
+ // Sin allow_once: allow “una vez” no puede inventar always.
129
+ assert.deepEqual(permissionOutcome([{ optionId: "a1", kind: "allow_always" }], true), {
130
+ outcome: { outcome: "cancelled" },
131
+ });
132
+ // Sin allow_always: “siempre” cae a once.
133
+ assert.deepEqual(permissionOutcome([{ optionId: "a", kind: "allow_once" }], true, { always: true }), {
134
+ outcome: { outcome: "selected", optionId: "a" },
135
+ });
136
+ });
@@ -0,0 +1,409 @@
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 { realpathSync } from "node:fs";
10
+ import { basename, dirname, isAbsolute, normalize, resolve } from "node:path";
11
+
12
+ /** @typedef {{ ok: true } | { ok: false, reason: string }} ShellCheck */
13
+
14
+ /** Subcomandos de git que solo leen. */
15
+ const GIT_READ = new Set([
16
+ "diff",
17
+ "log",
18
+ "show",
19
+ "status",
20
+ "merge-base",
21
+ "rev-parse",
22
+ "rev-list",
23
+ "blame",
24
+ "ls-files",
25
+ "ls-tree",
26
+ "cat-file",
27
+ "describe",
28
+ "shortlog",
29
+ "name-rev",
30
+ "for-each-ref",
31
+ "grep",
32
+ "whatchanged",
33
+ ]);
34
+
35
+ /** Flags de git que ejecutan algo o escriben archivos, en cualquier subcomando. */
36
+ const GIT_UNSAFE_FLAG = /^(--output|--ext-diff|-O|--open-files-in-pager)(=|$)/;
37
+
38
+ const BRANCH_LIST_FLAGS = new Set(["--show-current", "--list", "-a", "--all", "-r", "--remotes", "-v", "-vv"]);
39
+ const CONFIG_READ_FLAGS = new Set(["--get", "--get-all", "--get-regexp", "--list", "-l"]);
40
+
41
+ /** Comandos que solo leen (o imprimen). Los de PowerShell van en minúscula: no distingue mayúsculas. */
42
+ const READ_COMMANDS = new Set([
43
+ "cat",
44
+ "head",
45
+ "tail",
46
+ "wc",
47
+ "ls",
48
+ "nl",
49
+ "sort",
50
+ "uniq",
51
+ "cut",
52
+ "tr",
53
+ "grep",
54
+ "egrep",
55
+ "fgrep",
56
+ "rg",
57
+ "find",
58
+ "sed",
59
+ "file",
60
+ "stat",
61
+ "du",
62
+ "diff",
63
+ "cmp",
64
+ "jq",
65
+ "basename",
66
+ "dirname",
67
+ "realpath",
68
+ "tree",
69
+ "which",
70
+ "type",
71
+ "pwd",
72
+ "echo",
73
+ "printf",
74
+ "true",
75
+ // PowerShell
76
+ "get-content",
77
+ "gc",
78
+ "select-string",
79
+ "sls",
80
+ "get-childitem",
81
+ "gci",
82
+ "dir",
83
+ "measure-object",
84
+ "measure",
85
+ "select-object",
86
+ "select",
87
+ "sort-object",
88
+ "get-location",
89
+ "test-path",
90
+ "resolve-path",
91
+ "write-output",
92
+ "out-string",
93
+ "format-list",
94
+ "format-table",
95
+ ]);
96
+
97
+ /** Comandos cuyos argumentos son texto, no paths. */
98
+ const TEXT_ARGS = new Set(["echo", "printf", "tr", "write-output", "which", "type"]);
99
+
100
+ /** Destinos de redirección que solo descartan salida. */
101
+ const DISCARD = new Set(["/dev/null", "nul", "$null"]);
102
+
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
+ */
108
+ function toNativePath(p, platform) {
109
+ if (platform === "win32") {
110
+ const wsl = /^\/mnt\/([a-zA-Z])(\/|$)(.*)$/.exec(p);
111
+ if (wsl) return `${wsl[1].toUpperCase()}:/${wsl[3]}`;
112
+ const msys = /^\/([a-zA-Z])(\/|$)(.*)$/.exec(p);
113
+ if (msys) return `${msys[1].toUpperCase()}:/${msys[3]}`;
114
+ }
115
+ return p;
116
+ }
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
+
142
+ function isInside(child, root, platform) {
143
+ const c = normalize(canonicalPath(child)).replace(/[\\/]+$/, "");
144
+ const r = normalize(canonicalPath(root)).replace(/[\\/]+$/, "");
145
+ const [cc, rr] = platform === "win32" ? [c.toLowerCase(), r.toLowerCase()] : [c, r];
146
+ return cc === rr || cc.startsWith(`${rr}\\`) || cc.startsWith(`${rr}/`);
147
+ }
148
+
149
+ /** ¿El argumento parece un path que puede salir del repo? (absoluto, `~` o con `..`). */
150
+ function escapingPath(arg) {
151
+ return /^([a-zA-Z]:[\\/]|[\\/]|~)/.test(arg) || /(^|[\\/])\.\.([\\/]|$)/.test(arg);
152
+ }
153
+
154
+ /**
155
+ * Parte el comando en cadenas (separadas por `&&`, `||`, `;`, saltos de línea) de pipelines (`|`) de argv.
156
+ * @param {string} command
157
+ * @returns {{ ok: true, pipelines: string[][][] } | { ok: false, reason: string }}
158
+ */
159
+ export function tokenizeShell(command) {
160
+ const src = String(command ?? "");
161
+ /** @type {string[][][]} */
162
+ const pipelines = [];
163
+ /** @type {string[][]} */
164
+ let pipeline = [];
165
+ /** @type {string[]} */
166
+ let words = [];
167
+ let word = "";
168
+ let inWord = false;
169
+ /** @type {null | "'" | '"'} */
170
+ let quote = null;
171
+
172
+ const endWord = () => {
173
+ if (inWord) words.push(word);
174
+ word = "";
175
+ inWord = false;
176
+ };
177
+ const endSegment = () => {
178
+ endWord();
179
+ if (words.length) pipeline.push(words);
180
+ words = [];
181
+ };
182
+ const endPipeline = () => {
183
+ endSegment();
184
+ if (pipeline.length) pipelines.push(pipeline);
185
+ pipeline = [];
186
+ };
187
+ const fail = (reason) => ({ ok: /** @type {const} */ (false), reason });
188
+
189
+ for (let i = 0; i < src.length; i++) {
190
+ const ch = src[i];
191
+ const next = src[i + 1];
192
+
193
+ if (quote === "'") {
194
+ if (ch === "'") quote = null;
195
+ else word += ch;
196
+ continue;
197
+ }
198
+ if (quote === '"') {
199
+ if (ch === '"') quote = null;
200
+ else if (ch === "`") return fail("backticks");
201
+ else if (ch === "$" && next === "(") return fail("sustitución $(…)");
202
+ else if (ch === "\\" && next !== undefined && '"\\$`'.includes(next)) {
203
+ word += next;
204
+ i++;
205
+ } else word += ch;
206
+ continue;
207
+ }
208
+
209
+ if (ch === "'" || ch === '"') {
210
+ quote = ch;
211
+ inWord = true;
212
+ continue;
213
+ }
214
+ if (ch === "\\" && next !== undefined) {
215
+ word += next;
216
+ inWord = true;
217
+ i++;
218
+ continue;
219
+ }
220
+ if (ch === "`") return fail("backticks");
221
+ if (ch === "$" && next === "(") return fail("sustitución $(…)");
222
+ if (/\s/.test(ch) && ch !== "\n" && ch !== "\r") {
223
+ endWord();
224
+ continue;
225
+ }
226
+ if (ch === "\n" || ch === "\r" || ch === ";") {
227
+ endPipeline();
228
+ continue;
229
+ }
230
+ if ((ch === "&" && next === "&") || (ch === "|" && next === "|")) {
231
+ endPipeline();
232
+ i++;
233
+ continue;
234
+ }
235
+ if (ch === "|") {
236
+ endSegment();
237
+ continue;
238
+ }
239
+ if (ch === ">" || (ch === "&" && next === ">")) {
240
+ // `2>&1`, `2>/dev/null`, `>nul`, `&>/dev/null`: descartar o juntar salidas no escribe nada.
241
+ if (ch === "&") i++;
242
+ const fd = inWord && /^[12]$/.test(word) ? word : "";
243
+ if (inWord && !fd) return fail("redirección");
244
+ word = "";
245
+ inWord = false;
246
+ let j = i + 1;
247
+ if (src[j] === ">") j++;
248
+ if (src[j] === "&" && /[12]/.test(src[j + 1] ?? "")) {
249
+ i = j + 1;
250
+ continue;
251
+ }
252
+ while (src[j] === " " || src[j] === "\t") j++;
253
+ let target = "";
254
+ while (j < src.length && !/[\s;|&<>]/.test(src[j])) target += src[j++];
255
+ if (!DISCARD.has(target.toLowerCase())) return fail(`redirección a ${target || "archivo"}`);
256
+ i = j - 1;
257
+ continue;
258
+ }
259
+ if (ch === "<") return fail("redirección de entrada");
260
+ if (ch === "&") return fail("comando en segundo plano (&)");
261
+ word += ch;
262
+ inWord = true;
263
+ }
264
+ if (quote) return fail("comillas sin cerrar");
265
+ endPipeline();
266
+ return { ok: true, pipelines };
267
+ }
268
+
269
+ /**
270
+ * @param {string} arg
271
+ * @param {{ repo: string, platform: NodeJS.Platform }} ctx
272
+ * @returns {ShellCheck}
273
+ */
274
+ function checkPath(arg, { repo, platform }) {
275
+ if (DISCARD.has(arg.toLowerCase())) return { ok: true };
276
+ const native = toNativePath(arg.replace(/^~(?=[\\/]|$)/, "/__home__"), platform);
277
+ const abs = isAbsolute(native) ? native : resolve(repo, native);
278
+ return isInside(abs, repo, platform) ? { ok: true } : { ok: false, reason: `path fuera del repo: ${arg}` };
279
+ }
280
+
281
+ /** Argumentos (y valores de `--opt=valor`) que parecen paths que salen del repo deben quedar adentro. */
282
+ function checkArgsInside(args, ctx) {
283
+ for (const arg of args) {
284
+ const value = arg.startsWith("-") ? (arg.includes("=") ? arg.slice(arg.indexOf("=") + 1) : "") : arg;
285
+ if (value && escapingPath(value)) {
286
+ const res = checkPath(value, ctx);
287
+ if (!res.ok) return res;
288
+ }
289
+ }
290
+ return { ok: true };
291
+ }
292
+
293
+ /** @param {string[]} argv @returns {ShellCheck} */
294
+ function checkGit(argv, ctx) {
295
+ let i = 1;
296
+ while (argv[i]?.startsWith("-")) {
297
+ const flag = argv[i];
298
+ if (flag === "--no-pager" || flag === "-P") i++;
299
+ else if (flag === "-C") {
300
+ const res = checkPath(argv[i + 1] ?? "", ctx);
301
+ if (!res.ok) return res;
302
+ i += 2;
303
+ } else return { ok: false, reason: `opción global de git no permitida: ${flag}` };
304
+ }
305
+ const sub = argv[i] ?? "";
306
+ const rest = argv.slice(i + 1);
307
+ const unsafe = rest.find((w) => GIT_UNSAFE_FLAG.test(w));
308
+ if (unsafe) return { ok: false, reason: `git ${sub} ${unsafe}` };
309
+ if (GIT_READ.has(sub)) return { ok: true };
310
+ if (sub === "branch" && rest.every((w) => BRANCH_LIST_FLAGS.has(w))) return { ok: true };
311
+ if (sub === "remote" && (rest.length === 0 || (rest.length === 1 && rest[0] === "-v") || rest[0] === "get-url"))
312
+ return { ok: true };
313
+ if (sub === "config" && rest.some((w) => CONFIG_READ_FLAGS.has(w))) return { ok: true };
314
+ return { ok: false, reason: `git ${sub || "(sin subcomando)"} no es de solo lectura` };
315
+ }
316
+
317
+ /** @param {string[]} argv @returns {ShellCheck} */
318
+ function checkReadCommand(name, argv, ctx) {
319
+ const args = argv.slice(1);
320
+ if (name === "sort" && args.some((a) => /^(-o|--output)/.test(a))) return { ok: false, reason: "sort -o escribe un archivo" };
321
+ if (name === "uniq" && args.filter((a) => !a.startsWith("-")).length > 1)
322
+ return { ok: false, reason: "uniq con archivo de salida" };
323
+ if (name === "find" && args.some((a) => /^-(exec|execdir|ok|okdir|delete|fprint|fprint0|fprintf|fls)$/.test(a)))
324
+ return { ok: false, reason: "find que ejecuta o escribe" };
325
+ if (name === "rg" && args.some((a) => /^--pre(-glob)?(=|$)/.test(a))) return { ok: false, reason: "rg --pre ejecuta comandos" };
326
+ if (name === "sed") {
327
+ if (!args.includes("-n") || args.some((a) => /^(-i|--in-place|-e|-f|--expression|--file)/.test(a)))
328
+ return { ok: false, reason: "sed solo como `sed -n 'N,Mp'`" };
329
+ const script = args.find((a) => !a.startsWith("-"));
330
+ if (!script || !/^\s*(\d+|\$)(\s*,\s*(\d+|\$))?\s*p\s*$/.test(script))
331
+ return { ok: false, reason: "sed solo como `sed -n 'N,Mp'`" };
332
+ return checkArgsInside(args.filter((a) => a !== script), ctx);
333
+ }
334
+ if (TEXT_ARGS.has(name)) return { ok: true };
335
+ return checkArgsInside(args, ctx);
336
+ }
337
+
338
+ /** El validate del paquete (por npx o con el bin instalado), con el archivo como único argumento. */
339
+ function isValidate(argv) {
340
+ const line = argv.join(" ");
341
+ return /^(?:npx\s+(?:--yes\s+|-y\s+)?@jpkme\/diff-review(?:@[\w.^~-]+)?|diff-review)\s+validate\s+\S+$/.test(line);
342
+ }
343
+
344
+ /**
345
+ * `mkdir` / `md` / `New-Item -ItemType Directory` solo bajo `.diff-review/` del repo.
346
+ * @param {string[]} argv
347
+ * @param {{ repo: string, platform: NodeJS.Platform }} ctx
348
+ * @returns {ShellCheck | null} null = no es este caso
349
+ */
350
+ function checkReviewDirMkdir(argv, ctx) {
351
+ const name = argv[0].toLowerCase();
352
+ const reviewRoot = resolve(ctx.repo, ".diff-review");
353
+ /** @param {string} arg */
354
+ const underReviewDir = (arg) => {
355
+ const native = toNativePath(String(arg || "").replace(/^~(?=[\\/]|$)/, "/__home__"), ctx.platform);
356
+ const abs = isAbsolute(native) ? native : resolve(ctx.repo, native);
357
+ return isInside(abs, reviewRoot, ctx.platform);
358
+ };
359
+
360
+ if (name === "mkdir" || name === "md") {
361
+ const paths = argv.slice(1).filter((a) => !a.startsWith("-"));
362
+ if (!paths.length || !paths.every(underReviewDir)) {
363
+ return { ok: false, reason: "mkdir solo permitido bajo .diff-review/" };
364
+ }
365
+ return { ok: true };
366
+ }
367
+ if (name === "new-item") {
368
+ const joined = argv.join(" ").toLowerCase();
369
+ if (!/-itemtype\s+directory\b/.test(joined) && !/\b-type\s+directory\b/.test(joined)) return null;
370
+ const paths = argv.slice(1).filter((a) => !a.startsWith("-") && !/^(directory|file)$/i.test(a));
371
+ if (!paths.length || !paths.every(underReviewDir)) {
372
+ return { ok: false, reason: "New-Item Directory solo permitido bajo .diff-review/" };
373
+ }
374
+ return { ok: true };
375
+ }
376
+ return null;
377
+ }
378
+
379
+ /**
380
+ * @param {string} command
381
+ * @param {{ repo?: string, platform?: NodeJS.Platform }} [opts]
382
+ * @returns {ShellCheck}
383
+ */
384
+ export function checkShell(command, { repo, platform = process.platform } = {}) {
385
+ const parsed = tokenizeShell(command);
386
+ if (!parsed.ok) return parsed;
387
+ if (!parsed.pipelines.length) return { ok: false, reason: "comando vacío" };
388
+ const ctx = { repo: repo || process.cwd(), platform };
389
+ for (const pipeline of parsed.pipelines) {
390
+ for (const argv of pipeline) {
391
+ const name = argv[0].toLowerCase();
392
+ if (argv[0].includes("=")) return { ok: false, reason: `variables de entorno en el comando: ${argv[0]}` };
393
+ /** @type {ShellCheck} */
394
+ let res;
395
+ // `cd` solo dentro del repo (sin argumento iría al home).
396
+ if (name === "cd" || name === "set-location")
397
+ res = repo && argv[1] ? checkPath(argv[1], ctx) : { ok: false, reason: "cd fuera del repo" };
398
+ else if (name === "git") res = checkGit(argv, ctx);
399
+ else if (name === "npx" || name === "diff-review") res = isValidate(argv) ? { ok: true } : { ok: false, reason: `${argv.join(" ")}` };
400
+ else if (READ_COMMANDS.has(name)) res = checkReadCommand(name, argv, ctx);
401
+ else {
402
+ const mkdir = checkReviewDirMkdir(argv, ctx);
403
+ res = mkdir ?? { ok: false, reason: `${argv[0]} no está permitido` };
404
+ }
405
+ if (!res.ok) return res;
406
+ }
407
+ }
408
+ return { ok: true };
409
+ }