@jpkme/diff-review 0.1.12 → 0.1.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/bin/diff-review.mjs +72 -53
- package/lib/acp-policy.mjs +99 -12
- package/lib/acp-policy.test.mjs +56 -3
- package/lib/acp-shell.mjs +38 -8
- package/lib/acp-shell.test.mjs +32 -1
- package/lib/agent-login.mjs +105 -105
- package/lib/check-report.mjs +1 -0
- package/lib/check-report.test.mjs +1 -0
- package/lib/extract-cache.test.mjs +162 -162
- package/lib/model-label.mjs +263 -263
- package/lib/model-label.test.mjs +77 -77
- package/lib/models-cache.mjs +75 -75
- package/lib/models-cache.test.mjs +50 -50
- package/lib/prompt.mjs +8 -6
- package/lib/review-prep.mjs +63 -63
- package/package.json +1 -1
- package/web/build/client/_app/immutable/assets/{0.CLQiq3Ed.css → 0.BKRG1f36.css} +1 -1
- package/web/build/client/_app/immutable/assets/0.BKRG1f36.css.br +0 -0
- package/web/build/client/_app/immutable/assets/{0.CLQiq3Ed.css.gz → 0.BKRG1f36.css.gz} +0 -0
- package/web/build/client/_app/immutable/assets/2.C4whE4xz.css +1 -0
- package/web/build/client/_app/immutable/assets/2.C4whE4xz.css.br +0 -0
- package/web/build/client/_app/immutable/assets/2.C4whE4xz.css.gz +0 -0
- package/web/build/client/_app/immutable/assets/3.BT54aVKb.css +1 -0
- package/web/build/client/_app/immutable/assets/3.BT54aVKb.css.br +0 -0
- package/web/build/client/_app/immutable/assets/3.BT54aVKb.css.gz +0 -0
- package/web/build/client/_app/immutable/assets/storage.C8XGESwn.css +1 -0
- package/web/build/client/_app/immutable/assets/storage.C8XGESwn.css.br +0 -0
- package/web/build/client/_app/immutable/assets/storage.C8XGESwn.css.gz +0 -0
- package/web/build/client/_app/immutable/chunks/C0hrdiUt.js +2 -0
- package/web/build/client/_app/immutable/chunks/C0hrdiUt.js.br +0 -0
- package/web/build/client/_app/immutable/chunks/C0hrdiUt.js.gz +0 -0
- package/web/build/client/_app/immutable/chunks/CpeIO6nz.js +210 -0
- package/web/build/client/_app/immutable/chunks/CpeIO6nz.js.br +0 -0
- package/web/build/client/_app/immutable/chunks/CpeIO6nz.js.gz +0 -0
- package/web/build/client/_app/immutable/chunks/{CPyWH418.js → FOc6rgIW.js} +1 -1
- package/web/build/client/_app/immutable/chunks/FOc6rgIW.js.br +2 -0
- package/web/build/client/_app/immutable/chunks/FOc6rgIW.js.gz +0 -0
- package/web/build/client/_app/immutable/entry/{app.BiBohpyO.js → app.DcAtSJ3n.js} +2 -2
- package/web/build/client/_app/immutable/entry/app.DcAtSJ3n.js.br +0 -0
- package/web/build/client/_app/immutable/entry/app.DcAtSJ3n.js.gz +0 -0
- package/web/build/client/_app/immutable/entry/start.CO5l0Zbx.js +1 -0
- package/web/build/client/_app/immutable/entry/start.CO5l0Zbx.js.br +0 -0
- package/web/build/client/_app/immutable/entry/start.CO5l0Zbx.js.gz +0 -0
- package/web/build/client/_app/immutable/nodes/{1.BzPXaJJj.js → 1.E-Hs7ura.js} +1 -1
- package/web/build/client/_app/immutable/nodes/1.E-Hs7ura.js.br +4 -0
- package/web/build/client/_app/immutable/nodes/1.E-Hs7ura.js.gz +0 -0
- package/web/build/client/_app/immutable/nodes/2.s1_irV1Q.js +10 -0
- package/web/build/client/_app/immutable/nodes/2.s1_irV1Q.js.br +0 -0
- package/web/build/client/_app/immutable/nodes/2.s1_irV1Q.js.gz +0 -0
- package/web/build/client/_app/immutable/nodes/3.pgwe-_5b.js +171 -0
- package/web/build/client/_app/immutable/nodes/3.pgwe-_5b.js.br +0 -0
- package/web/build/client/_app/immutable/nodes/3.pgwe-_5b.js.gz +0 -0
- package/web/build/client/_app/version.json +1 -1
- package/web/build/client/_app/version.json.br +0 -0
- package/web/build/client/_app/version.json.gz +0 -0
- package/web/build/handler.js +3 -3
- package/web/build/index.js +3 -3
- package/web/build/server/chunks/{0-BYok4D0n.js → 0-DUStDOsr.js} +3 -3
- package/web/build/server/chunks/{0-BYok4D0n.js.map → 0-DUStDOsr.js.map} +1 -1
- package/web/build/server/chunks/1-BFhhV5JB.js +7 -0
- package/web/build/server/chunks/{1-C7zcB0q0.js.map → 1-BFhhV5JB.js.map} +1 -1
- package/web/build/server/chunks/2-BZ_MVqj8.js +7 -0
- package/web/build/server/chunks/2-BZ_MVqj8.js.map +1 -0
- package/web/build/server/chunks/3-BV-TIUp1.js +7 -0
- package/web/build/server/chunks/{3-DAAmG6VX.js.map → 3-BV-TIUp1.js.map} +1 -1
- package/web/build/server/chunks/{_server.ts-qYVrMYPB.js → _server.ts-CdFoXw36.js} +4 -4
- package/web/build/server/chunks/{_server.ts-qYVrMYPB.js.map → _server.ts-CdFoXw36.js.map} +1 -1
- package/web/build/server/chunks/{agent-sessions-DSqGavSn.js → agent-sessions-D0WwtWgR.js} +223 -51
- package/web/build/server/chunks/agent-sessions-D0WwtWgR.js.map +1 -0
- package/web/build/server/chunks/{git-review-CpO_G8z8.js → git-review-CgcBIbvM.js} +7 -7
- package/web/build/server/chunks/{git-review-CpO_G8z8.js.map → git-review-CgcBIbvM.js.map} +1 -1
- package/web/build/server/chunks/{handler-Di1wJQuU.js → handler-DbxjtK9M.js} +3 -3
- package/web/build/server/chunks/{handler-Di1wJQuU.js.map → handler-DbxjtK9M.js.map} +1 -1
- package/web/build/server/chunks/{hooks.server-BLOHobEK.js → hooks.server-Dg6gYUqs.js} +3 -3
- package/web/build/server/chunks/{hooks.server-BLOHobEK.js.map → hooks.server-Dg6gYUqs.js.map} +1 -1
- package/web/build/server/chunks/{index.js-yLYmrAWy.js → index.js-NNne-njN.js} +876 -879
- package/web/build/server/chunks/index.js-NNne-njN.js.map +1 -0
- package/web/build/server/chunks/{manifest.js-CXj8IIo3.js → manifest.js-V8ViI5bl.js} +9 -9
- package/web/build/server/chunks/{manifest.js-CXj8IIo3.js.map → manifest.js-V8ViI5bl.js.map} +1 -1
- package/web/build/server/chunks/{remote-1g4ayag-BiJeU0Pk.js → remote-1g4ayag-CCsG2QXw.js} +4 -4
- package/web/build/server/chunks/{remote-1g4ayag-BiJeU0Pk.js.map → remote-1g4ayag-CCsG2QXw.js.map} +1 -1
- package/web/build/server/chunks/{remote-wh6t6j-CHanXurm.js → remote-wh6t6j-BCvmX37z.js} +5 -5
- package/web/build/server/chunks/{remote-wh6t6j-CHanXurm.js.map → remote-wh6t6j-BCvmX37z.js.map} +1 -1
- package/web/build/server/chunks/{validate-BsTKyhsq.js → validate-CAQ31C7k.js} +2 -2
- package/web/build/server/chunks/{validate-BsTKyhsq.js.map → validate-CAQ31C7k.js.map} +1 -1
- package/web/build/client/_app/immutable/assets/0.CLQiq3Ed.css.br +0 -0
- package/web/build/client/_app/immutable/assets/2.CKE12GwZ.css +0 -1
- package/web/build/client/_app/immutable/assets/2.CKE12GwZ.css.br +0 -0
- package/web/build/client/_app/immutable/assets/2.CKE12GwZ.css.gz +0 -0
- package/web/build/client/_app/immutable/assets/3.Dx3c_e7d.css +0 -1
- package/web/build/client/_app/immutable/assets/3.Dx3c_e7d.css.br +0 -0
- package/web/build/client/_app/immutable/assets/3.Dx3c_e7d.css.gz +0 -0
- package/web/build/client/_app/immutable/assets/storage.gDV-BhtT.css +0 -1
- package/web/build/client/_app/immutable/assets/storage.gDV-BhtT.css.br +0 -0
- package/web/build/client/_app/immutable/assets/storage.gDV-BhtT.css.gz +0 -0
- package/web/build/client/_app/immutable/chunks/B5ByWcPE.js +0 -2
- package/web/build/client/_app/immutable/chunks/B5ByWcPE.js.br +0 -0
- package/web/build/client/_app/immutable/chunks/B5ByWcPE.js.gz +0 -0
- package/web/build/client/_app/immutable/chunks/CPyWH418.js.br +0 -0
- package/web/build/client/_app/immutable/chunks/CPyWH418.js.gz +0 -0
- package/web/build/client/_app/immutable/chunks/NwkMsx0h.js +0 -210
- package/web/build/client/_app/immutable/chunks/NwkMsx0h.js.br +0 -0
- package/web/build/client/_app/immutable/chunks/NwkMsx0h.js.gz +0 -0
- package/web/build/client/_app/immutable/entry/app.BiBohpyO.js.br +0 -0
- package/web/build/client/_app/immutable/entry/app.BiBohpyO.js.gz +0 -0
- package/web/build/client/_app/immutable/entry/start.CjWbsiSO.js +0 -1
- package/web/build/client/_app/immutable/entry/start.CjWbsiSO.js.br +0 -0
- package/web/build/client/_app/immutable/entry/start.CjWbsiSO.js.gz +0 -0
- package/web/build/client/_app/immutable/nodes/1.BzPXaJJj.js.br +0 -2
- package/web/build/client/_app/immutable/nodes/1.BzPXaJJj.js.gz +0 -0
- package/web/build/client/_app/immutable/nodes/2.Cp6Ttd_9.js +0 -10
- package/web/build/client/_app/immutable/nodes/2.Cp6Ttd_9.js.br +0 -0
- package/web/build/client/_app/immutable/nodes/2.Cp6Ttd_9.js.gz +0 -0
- package/web/build/client/_app/immutable/nodes/3.DLiDQm__.js +0 -171
- package/web/build/client/_app/immutable/nodes/3.DLiDQm__.js.br +0 -0
- package/web/build/client/_app/immutable/nodes/3.DLiDQm__.js.gz +0 -0
- package/web/build/server/chunks/1-C7zcB0q0.js +0 -7
- package/web/build/server/chunks/2-v6ejI_0-.js +0 -7
- package/web/build/server/chunks/2-v6ejI_0-.js.map +0 -1
- package/web/build/server/chunks/3-DAAmG6VX.js +0 -7
- package/web/build/server/chunks/agent-sessions-DSqGavSn.js.map +0 -1
- package/web/build/server/chunks/index.js-yLYmrAWy.js.map +0 -1
- /package/web/build/client/_app/immutable/nodes/{0.Cak4ehiE.js → 0.Dj97JW46.js} +0 -0
- /package/web/build/client/_app/immutable/nodes/{0.Cak4ehiE.js.br → 0.Dj97JW46.js.br} +0 -0
- /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
|
|
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`,
|
package/bin/diff-review.mjs
CHANGED
|
@@ -201,12 +201,15 @@ function defaultBase(listed, branch) {
|
|
|
201
201
|
return listed.branches.find((b) => b !== branch) || preferred || '';
|
|
202
202
|
}
|
|
203
203
|
|
|
204
|
-
function branchOptions(listed) {
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
204
|
+
function branchOptions(listed, exclude = '') {
|
|
205
|
+
const skip = String(exclude || '').trim();
|
|
206
|
+
return listed.branches
|
|
207
|
+
.filter((b) => !skip || b !== skip)
|
|
208
|
+
.map((b) => ({
|
|
209
|
+
value: b,
|
|
210
|
+
label: b,
|
|
211
|
+
hint: b === listed.current ? 'actual' : b === listed.defaultBase ? 'base típica' : undefined
|
|
212
|
+
}));
|
|
210
213
|
}
|
|
211
214
|
|
|
212
215
|
/** Selects solo con terminal interactiva. (`p.isTTY` de Clack es una función, no un booleano.) */
|
|
@@ -270,12 +273,18 @@ async function resolveBranchBase(launch, opts) {
|
|
|
270
273
|
p.log.step(`Branch: ${branch}`);
|
|
271
274
|
}
|
|
272
275
|
|
|
276
|
+
const baseOptions = branchOptions(launch.listed, branch);
|
|
277
|
+
if (!baseOptions.length) {
|
|
278
|
+
p.cancel(`No hay otra base disponible distinta de \`${branch}\`.`);
|
|
279
|
+
process.exit(1);
|
|
280
|
+
}
|
|
281
|
+
|
|
273
282
|
let base = flagBase;
|
|
274
283
|
if (!base) {
|
|
275
284
|
base = ensureValue(
|
|
276
285
|
await autocompleteEs({
|
|
277
286
|
message: 'Base (merge-base)',
|
|
278
|
-
options,
|
|
287
|
+
options: baseOptions,
|
|
279
288
|
initialValue: defaultBase(launch.listed, branch) || undefined,
|
|
280
289
|
maxItems: 12
|
|
281
290
|
}),
|
|
@@ -290,7 +299,7 @@ async function resolveBranchBase(launch, opts) {
|
|
|
290
299
|
base = ensureValue(
|
|
291
300
|
await autocompleteEs({
|
|
292
301
|
message: 'Elegí otra base',
|
|
293
|
-
options,
|
|
302
|
+
options: baseOptions,
|
|
294
303
|
initialValue: defaultBase(launch.listed, branch) || undefined,
|
|
295
304
|
maxItems: 12
|
|
296
305
|
}),
|
|
@@ -317,6 +326,49 @@ function buildOutputFilename(repo, branch, base) {
|
|
|
317
326
|
}
|
|
318
327
|
}
|
|
319
328
|
|
|
329
|
+
/**
|
|
330
|
+
* Precomputa el diff en CLI (spinner si hay Clack). Si el resultado no tiene archivos, sale con error.
|
|
331
|
+
* @returns {ReturnType<typeof prepareReviewPromptOpts>}
|
|
332
|
+
*/
|
|
333
|
+
function prepareContextOrFail(launch, branch, base, outputFilename, { useClack }) {
|
|
334
|
+
const spin = useClack ? p.spinner() : null;
|
|
335
|
+
if (!useClack) console.log('Preparando el contexto…');
|
|
336
|
+
spin?.start('Preparando el contexto…');
|
|
337
|
+
|
|
338
|
+
/** @type {ReturnType<typeof prepareReviewPromptOpts>} */
|
|
339
|
+
let promptOpts = { outputFilename };
|
|
340
|
+
try {
|
|
341
|
+
promptOpts = prepareReviewPromptOpts({
|
|
342
|
+
repo: launch.repo,
|
|
343
|
+
branch,
|
|
344
|
+
base,
|
|
345
|
+
outputFilename
|
|
346
|
+
});
|
|
347
|
+
} catch (err) {
|
|
348
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
349
|
+
spin?.stop('No pude precomputar el diff');
|
|
350
|
+
if (useClack) p.log.warn(`No pude precomputar el diff: ${msg}`);
|
|
351
|
+
else console.warn('No pude precomputar el diff:', msg);
|
|
352
|
+
ensureReviewDir(launch.repo);
|
|
353
|
+
writeDiffCache(launch.repo, outputFilename, { branch, base });
|
|
354
|
+
return promptOpts;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const n = Array.isArray(promptOpts.files) ? promptOpts.files.length : 0;
|
|
358
|
+
if (n === 0) {
|
|
359
|
+
spin?.stop('Diff vacío');
|
|
360
|
+
const msg = `El diff entre \`${branch}\` y \`${base}\` está vacío: no hay nada que revisar.`;
|
|
361
|
+
if (useClack) p.cancel(msg);
|
|
362
|
+
else console.error(msg);
|
|
363
|
+
process.exit(1);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const done = `Contexto listo · ${n} archivo${n === 1 ? '' : 's'}`;
|
|
367
|
+
spin?.stop(done);
|
|
368
|
+
if (!useClack) console.log(done);
|
|
369
|
+
return promptOpts;
|
|
370
|
+
}
|
|
371
|
+
|
|
320
372
|
function copyPromptOrWarn(prompt) {
|
|
321
373
|
if (copyTextToClipboard(prompt)) return true;
|
|
322
374
|
p.log.warn(
|
|
@@ -622,22 +674,9 @@ async function runUiMode(opts, launch, launchCwd) {
|
|
|
622
674
|
if (launch) {
|
|
623
675
|
console.log(`Repo: ${launch.repo}`);
|
|
624
676
|
console.log(`Branch: ${branch} (base: ${base})`);
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
repo: launch.repo,
|
|
629
|
-
branch,
|
|
630
|
-
base,
|
|
631
|
-
outputFilename
|
|
632
|
-
});
|
|
633
|
-
} catch (err) {
|
|
634
|
-
console.warn(
|
|
635
|
-
'No pude precomputar el diff:',
|
|
636
|
-
err instanceof Error ? err.message : err
|
|
637
|
-
);
|
|
638
|
-
ensureReviewDir(launch.repo);
|
|
639
|
-
writeDiffCache(launch.repo, outputFilename, { branch, base });
|
|
640
|
-
}
|
|
677
|
+
const promptOpts = prepareContextOrFail(launch, branch, base, outputFilename, {
|
|
678
|
+
useClack: false
|
|
679
|
+
});
|
|
641
680
|
const prompt = buildPrompt(
|
|
642
681
|
{
|
|
643
682
|
source: 'local',
|
|
@@ -698,38 +737,13 @@ async function main() {
|
|
|
698
737
|
const { branch, base, prompted } = await resolveBranchBase(launch, opts);
|
|
699
738
|
|
|
700
739
|
if (branch === base) {
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
);
|
|
705
|
-
await startServer({
|
|
706
|
-
host: opts.host,
|
|
707
|
-
port: opts.port,
|
|
708
|
-
open: opts.open,
|
|
709
|
-
launchCwd,
|
|
710
|
-
openPath: '/'
|
|
711
|
-
});
|
|
712
|
-
return;
|
|
740
|
+
const msg = `Branch y base son iguales (${branch}): no hay diff que revisar.`;
|
|
741
|
+
if (prompted) p.cancel(msg);
|
|
742
|
+
else console.error(msg);
|
|
743
|
+
process.exit(1);
|
|
713
744
|
}
|
|
714
745
|
|
|
715
746
|
const outputFilename = buildOutputFilename(launch.repo, branch, base);
|
|
716
|
-
// El agente escribe en .diff-review/ (que se ignora sola) y lee de ahí el diff ya calculado.
|
|
717
|
-
let promptOpts = { outputFilename };
|
|
718
|
-
try {
|
|
719
|
-
promptOpts = prepareReviewPromptOpts({
|
|
720
|
-
repo: launch.repo,
|
|
721
|
-
branch,
|
|
722
|
-
base,
|
|
723
|
-
outputFilename
|
|
724
|
-
});
|
|
725
|
-
} catch (err) {
|
|
726
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
727
|
-
if (prompted) p.log.warn(`No pude precomputar el diff: ${msg}`);
|
|
728
|
-
else console.warn('No pude precomputar el diff:', msg);
|
|
729
|
-
ensureReviewDir(launch.repo);
|
|
730
|
-
writeDiffCache(launch.repo, outputFilename, { branch, base });
|
|
731
|
-
}
|
|
732
|
-
|
|
733
747
|
if (!prompted) {
|
|
734
748
|
console.log(`Repo: ${launch.repo}`);
|
|
735
749
|
console.log(`Branch: ${branch} (base: ${base})`);
|
|
@@ -737,6 +751,11 @@ async function main() {
|
|
|
737
751
|
p.note(`${branch} → ${base}\n${outputRelPath(outputFilename)}`, 'Review');
|
|
738
752
|
}
|
|
739
753
|
|
|
754
|
+
// El agente escribe en .diff-review/ (que se ignora sola) y lee de ahí el diff ya calculado.
|
|
755
|
+
const promptOpts = prepareContextOrFail(launch, branch, base, outputFilename, {
|
|
756
|
+
useClack: prompted || isInteractive()
|
|
757
|
+
});
|
|
758
|
+
|
|
740
759
|
const prompt = buildPrompt(
|
|
741
760
|
{
|
|
742
761
|
source: 'local',
|
package/lib/acp-policy.mjs
CHANGED
|
@@ -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
|
-
*
|
|
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 {
|
|
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 {
|
|
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
|
|
97
|
-
const
|
|
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
|
|
107
|
-
const outside = paths.find((p) => !
|
|
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
|
|
198
|
+
reason: `solo se puede escribir ${outputRelPath(outputFilename)} (no ${outside})`
|
|
112
199
|
}
|
|
113
|
-
: { allow: true, reason: `escritura
|
|
200
|
+
: { allow: true, reason: `escritura de ${outputRelPath(outputFilename)}` };
|
|
114
201
|
}
|
|
115
202
|
|
|
116
203
|
return { allow: false, reason: `${kind} no permitido: ${toolCall.title ?? "?"}` };
|
package/lib/acp-policy.test.mjs
CHANGED
|
@@ -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 {
|
|
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:
|
|
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,
|
|
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 {
|
|
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
|
-
/**
|
|
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 =
|
|
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 =
|
|
326
|
-
|
|
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") {
|
package/lib/acp-shell.test.mjs
CHANGED
|
@@ -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
|
|
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
|
});
|