paperlint 11.1.2 → 12.0.0

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 (38) hide show
  1. package/dist/build.d.ts.map +1 -1
  2. package/dist/build.js +18 -8
  3. package/dist/build.js.map +1 -1
  4. package/dist/cli.d.ts +1 -1
  5. package/dist/cli.js +2 -2
  6. package/dist/cli.js.map +1 -1
  7. package/dist/latex-log.d.ts +61 -26
  8. package/dist/latex-log.d.ts.map +1 -1
  9. package/dist/latex-log.js +59 -6
  10. package/dist/latex-log.js.map +1 -1
  11. package/dist/latex-loop.d.ts +21 -6
  12. package/dist/latex-loop.d.ts.map +1 -1
  13. package/dist/latex-loop.js +32 -12
  14. package/dist/latex-loop.js.map +1 -1
  15. package/dist/submission-confirm-hook.d.ts +27 -19
  16. package/dist/submission-confirm-hook.d.ts.map +1 -1
  17. package/dist/submission-confirm-hook.js +302 -95
  18. package/dist/submission-confirm-hook.js.map +1 -1
  19. package/docs/configuration.md +30 -1
  20. package/docs/e2e.md +1 -0
  21. package/docs/layout.md +22 -22
  22. package/docs/submission.md +32 -8
  23. package/fixtures/build-e2e/lost-character/PIPELINE-STATUS.md +3 -0
  24. package/fixtures/build-e2e/lost-character/expect.json +15 -0
  25. package/fixtures/build-e2e/lost-character/paper.tex +8 -0
  26. package/package.json +1 -1
  27. package/skills/submission-portal/SKILL.md +18 -12
  28. package/skills/submission-portal/SKILL.md.spec.ts +17 -11
  29. package/src/build.test.ts +46 -3
  30. package/src/build.ts +20 -8
  31. package/src/cli.test.ts +3 -3
  32. package/src/cli.ts +2 -2
  33. package/src/latex-log.harness.ts +145 -14
  34. package/src/latex-log.ts +105 -16
  35. package/src/latex-loop.harness.ts +70 -34
  36. package/src/latex-loop.ts +51 -30
  37. package/src/submission-confirm-hook.test.ts +493 -60
  38. package/src/submission-confirm-hook.ts +448 -116
@@ -1,6 +1,7 @@
1
1
  /**
2
- * The `submission-confirm` hook: which commands send a submission (asked), which do not (silent), and
3
- * what the person is told — including when what it reads is missing, stale or broken.
2
+ * The `submission-confirm` hook: which commands send a submission, which do not (silent); a send runs
3
+ * only after the person's AskUserQuestion yes naming its paper folder and its PDF's sha256, once per send; what the agent is told to
4
+ * ask — including when what it reads is missing, stale or broken.
4
5
  */
5
6
  import { expect, test } from "vitest";
6
7
  import { memoryFiles } from "./adapters/memory/index.ts";
@@ -23,8 +24,8 @@ const kind = (command: string, hosts: () => readonly string[]) => {
23
24
  portalHosts: hosts,
24
25
  })?.hookSpecificOutput.permissionDecisionReason ?? null;
25
26
  if (reason === null) return null;
26
- if (reason.startsWith(HEAD)) return "paperlint";
27
- return reason.startsWith("This command names") ? "direct" : reason;
27
+ if (reason.startsWith(NOT_SENT)) return "paperlint";
28
+ return reason.startsWith("Not run: this command names") ? "direct" : reason;
28
29
  };
29
30
 
30
31
  /** The portal hosts the project declares, in these tests: one venue on its own domain. */
@@ -45,8 +46,15 @@ const submissionConfirm = (
45
46
  ...more,
46
47
  });
47
48
 
48
- const payload = (command: string, cwd = "/w"): string =>
49
- JSON.stringify({ tool_name: "Bash", tool_input: { command }, cwd });
49
+ /** The session's transcript, in every test that does not give its own. */
50
+ const T = "/t/session.jsonl";
51
+
52
+ const payload = (
53
+ command: string,
54
+ cwd = "/w",
55
+ more: object = { transcript_path: T, tool_use_id: "now" },
56
+ ): string =>
57
+ JSON.stringify({ tool_name: "Bash", tool_input: { command }, cwd, ...more });
50
58
 
51
59
  test.each([
52
60
  "npx paperlint submission update papers/p --save",
@@ -60,7 +68,7 @@ test.each([
60
68
  `npx paperlint "submission" 'update' papers/p "--save"`,
61
69
  "npx paperlint --save submission update papers/p",
62
70
  `npx paperlint submis"sion" up'date' papers/p --sa"ve"`,
63
- ])("asked: %s", (command) => {
71
+ ])("a send: %s", (command) => {
64
72
  expect(kind(command, () => [])).toBe("paperlint");
65
73
  });
66
74
 
@@ -75,7 +83,7 @@ test.each([
75
83
  "node upload.mjs submit.example-venue.org",
76
84
  "curl -X POST CONFERENCE.org/api/paper",
77
85
  "echo https://venue.hotcrp.com",
78
- ])("asked, straight at HotCRP: %s", (command) => {
86
+ ])("refused, straight at HotCRP: %s", (command) => {
79
87
  expect(kind(command, () => HOSTS)).toBe("direct");
80
88
  });
81
89
 
@@ -108,26 +116,29 @@ test("the hosts are read only for a command that makes a request: anything else
108
116
  expect(kind("npx paperlint submission new --save", never)).toBe("paperlint");
109
117
  });
110
118
 
111
- test("hosts that cannot be read: a request still asks, rather than passing unseen", () => {
112
- expect(
113
- submissionConfirm(payload("curl https://example.org/x"), memoryFiles({}), {
114
- portalHosts: () => {
115
- throw new Error("broken preset");
116
- },
117
- }),
118
- ).toStrictEqual({
119
- hookSpecificOutput: {
120
- hookEventName: "PreToolUse",
121
- permissionDecision: "ask",
122
- permissionDecisionReason:
123
- "paperlint could not tell what this command sends (broken preset). Allow only if you have read the dry run's venue check.",
119
+ test("presets that cannot be read: only HotCRP-hosted sites and paperlint sends are recognised, and a refusal says why", () => {
120
+ const broken = {
121
+ portalHosts: () => {
122
+ throw new Error("broken preset");
124
123
  },
125
- });
124
+ };
125
+ const reason = (command: string) =>
126
+ submissionConfirm(payload(command), paper(facts()), broken)
127
+ ?.hookSpecificOutput.permissionDecisionReason ?? null;
128
+ const FALLBACK =
129
+ "The venue presets could not be read (broken preset), so only HotCRP-hosted sites and paperlint sends are recognised until they are fixed.";
130
+ // A command that names no HotCRP site runs: one bad preset does not stop the shell.
131
+ expect(reason("curl https://submit.example-venue.org/x")).toBe(null);
132
+ expect(reason("cat notes.txt")).toBe(null);
133
+ expect(
134
+ reason("curl https://venue.hotcrp.com/api/paper")?.split("\n").at(-1),
135
+ ).toBe(FALLBACK);
136
+ expect(reason(SEND)?.split("\n").at(-1)).toBe(FALLBACK);
126
137
  });
127
-
128
- /** The made-up paper: its PDF, and its facts when given. */
138
+ /** The made-up paper: its PDF, its facts when given, and an empty transcript. */
129
139
  const paper = (factsText: string | null): Files =>
130
140
  memoryFiles({
141
+ [T]: "",
131
142
  "/w/papers/p/paper.pdf": PDF,
132
143
  ...(factsText === null
133
144
  ? {}
@@ -136,21 +147,29 @@ const paper = (factsText: string | null): Files =>
136
147
 
137
148
  const reasonOf = (command: string, fs: Files, cwd = "/w"): string =>
138
149
  submissionConfirm(payload(command, cwd), fs)?.hookSpecificOutput
139
- .permissionDecisionReason ?? "(no ask)";
150
+ .permissionDecisionReason ?? "(let through)";
140
151
 
141
152
  const SEND = "npx paperlint submission update papers/p --submit --save";
142
- const HEAD =
143
- "This sends the paper to the venue's portal (paperlint submission with --save or --submit).";
144
- const LAST = "Allow only if you have read the dry run's venue check.";
153
+ const NOT_SENT = "Not run: this sends the paper to the venue's portal";
154
+ /** The first line when no yes was found. */
155
+ const HEAD = `${NOT_SENT}, and the person has not said yes to it since the last send.`;
156
+ /** What the agent is told to ask: the last lines of that refusal. */
157
+ const LAST = [
158
+ "Ask the person with AskUserQuestion first, then run this command again. In the question, give:",
159
+ "- the paper folder (the full path, as written above), the PDF and its sha256 as written above (the folder path and the sha256 must both be in the question text),",
160
+ "- what the lines above say about the build (the body pages, or that it is stale or missing),",
161
+ "- the dry run's venue check, quoted word for word (its `venue rules at their default options` line and every error under it).",
162
+ 'Options: "Yes, send it" and "No". One yes lets one send through: ask one question per send, each naming its own folder and sha256.',
163
+ ].join("\n");
145
164
 
146
- test("the ask: the paper folder, the PDF and its sha256, the body pages the build counted, and the line to read the venue check", () => {
165
+ test("no yes: refused, telling the agent what to ask — the paper folder, the PDF and its sha256, the body pages, the venue check, yes or no", () => {
147
166
  expect(submissionConfirm(payload(SEND), paper(facts()))).toStrictEqual({
148
167
  hookSpecificOutput: {
149
168
  hookEventName: "PreToolUse",
150
- permissionDecision: "ask",
169
+ permissionDecision: "deny",
151
170
  permissionDecisionReason: [
152
171
  HEAD,
153
- "paper folder: papers/p",
172
+ "paper folder: /w/papers/p",
154
173
  `PDF: papers/p/paper.pdf, sha256 ${HEX.slice(0, 12)}`,
155
174
  "body pages: 4 (up to the references on page 5)",
156
175
  LAST,
@@ -159,11 +178,11 @@ test("the ask: the paper folder, the PDF and its sha256, the body pages the buil
159
178
  });
160
179
  });
161
180
 
162
- test("a stale build is said loudly; no facts, broken facts and a missing PDF are said too — and every one still asks", () => {
181
+ test("a stale build is said loudly; no facts and broken facts are said too", () => {
163
182
  expect(reasonOf(SEND, paper(facts({ sha: "b".repeat(64) })))).toBe(
164
183
  [
165
184
  HEAD,
166
- "paper folder: papers/p",
185
+ "paper folder: /w/papers/p",
167
186
  `PDF: papers/p/paper.pdf, sha256 ${HEX.slice(0, 12)}`,
168
187
  "!! THE LAST BUILD IS NOT OF THIS PDF — the check did not see it. Rebuild (`paperlint build`) and run the dry run again.",
169
188
  "body pages: 4 (up to the references on page 5)",
@@ -179,12 +198,30 @@ test("a stale build is said loudly; no facts, broken facts and a missing PDF are
179
198
  expect(
180
199
  reasonOf(SEND, paper(JSON.stringify({ schema: 1 }))).split("\n")[3],
181
200
  ).toBe("could not read the build facts (_build/paper.facts.json): schema 1");
201
+ });
202
+
203
+ /** The refusal of a send whose PDF is not known or not there: nothing to ask about yet. */
204
+ const NO_PDF = (...lines: readonly string[]): string =>
205
+ [
206
+ `${NOT_SENT}, and paperlint cannot find the PDF it uploads, so there is no sha256 to ask the person about.`,
207
+ ...lines,
208
+ "Name the paper folder and the PDF plainly (no cd, no $…), build the PDF, run the dry run, and ask then.",
209
+ ].join("\n");
210
+
211
+ test("a PDF that is not there: refused, whatever the transcript says — there is no sha256 to ask about", () => {
182
212
  expect(
183
213
  reasonOf(
184
214
  "npx paperlint submission update papers/p --pdf gone.pdf --save",
185
215
  paper(facts()),
186
- ).split("\n")[2],
187
- ).toBe("PDF: gone.pdf — NOT FOUND");
216
+ ),
217
+ ).toBe(
218
+ NO_PDF(
219
+ "paper folder: /w/papers/p",
220
+ "PDF: gone.pdf — NOT FOUND",
221
+ "!! THE LAST BUILD IS NOT OF THIS PDF — the check did not see it. Rebuild (`paperlint build`) and run the dry run again.",
222
+ "body pages: 4 (up to the references on page 5)",
223
+ ),
224
+ );
188
225
  });
189
226
 
190
227
  test("the body pages: up to the references when the build found them, else banal's count, else not counted", () => {
@@ -203,6 +240,7 @@ test("the body pages: up to the references when the build found them, else banal
203
240
 
204
241
  test("the paper and the PDF as the command names them: the current folder by default, --pdf=<file> too", () => {
205
242
  const fs = memoryFiles({
243
+ [T]: "",
206
244
  "/w/papers/p/paper.pdf": PDF,
207
245
  "/w/papers/p/out.pdf": PDF,
208
246
  });
@@ -211,7 +249,7 @@ test("the paper and the PDF as the command names them: the current folder by def
211
249
  .split("\n")
212
250
  .slice(1, 3),
213
251
  ).toStrictEqual([
214
- "paper folder: p (the current folder)",
252
+ "paper folder: /w/papers/p",
215
253
  `PDF: paper.pdf, sha256 ${HEX.slice(0, 12)}`,
216
254
  ]);
217
255
  expect(
@@ -222,13 +260,14 @@ test("the paper and the PDF as the command names them: the current folder by def
222
260
  .split("\n")
223
261
  .slice(1, 3),
224
262
  ).toStrictEqual([
225
- "paper folder: papers/p",
263
+ "paper folder: /w/papers/p",
226
264
  `PDF: papers/p/out.pdf, sha256 ${HEX.slice(0, 12)}`,
227
265
  ]);
228
266
  });
229
267
 
230
268
  test("the paper's own `pdf` setting is the PDF hashed when the command names no --pdf, as the command sends it", () => {
231
269
  const fs = memoryFiles({
270
+ [T]: "",
232
271
  "/w/papers/p/paperlint.json": JSON.stringify({ pdf: "out/main.pdf" }),
233
272
  "/w/papers/p/out/main.pdf": PDF,
234
273
  });
@@ -237,8 +276,9 @@ test("the paper's own `pdf` setting is the PDF hashed when the command names no
237
276
  );
238
277
  });
239
278
 
240
- test("a paperlint.json that does not parse: the PDF is the default paper.pdf, and the ask still goes out", () => {
279
+ test("a paperlint.json that does not parse: the PDF is the default paper.pdf, and the send is still described", () => {
241
280
  const fs = memoryFiles({
281
+ [T]: "",
242
282
  "/w/papers/p/paperlint.json": "{ nope",
243
283
  "/w/papers/p/paper.pdf": PDF,
244
284
  });
@@ -253,7 +293,7 @@ test("a --pdf at the end with no value, which the command refuses: unresolved",
253
293
  "npx paperlint submission update papers/p --save --pdf",
254
294
  paper(facts()),
255
295
  ),
256
- ).toBe([HEAD, UNRESOLVED, LAST].join("\n"));
296
+ ).toBe(NO_PDF(UNRESOLVED));
257
297
  });
258
298
 
259
299
  test("a command that only mentions `submission` beside a send is no second send", () => {
@@ -262,7 +302,7 @@ test("a command that only mentions `submission` beside a send is no second send"
262
302
  "npx paperlint submission update papers/p --save; echo submission",
263
303
  paper(facts()),
264
304
  ).split("\n")[1],
265
- ).toBe("paper folder: papers/p");
305
+ ).toBe("paper folder: /w/papers/p");
266
306
  });
267
307
 
268
308
  /** What the reason says for a send the parse cannot resolve. */
@@ -275,13 +315,13 @@ test("the send's words are read by the command's own parser: a value flag before
275
315
  "npx paperlint submission update --max-warnings 0 --config paperlint.json papers/p --save",
276
316
  paper(facts()),
277
317
  ).split("\n")[1],
278
- ).toBe("paper folder: papers/p");
318
+ ).toBe("paper folder: /w/papers/p");
279
319
  expect(
280
320
  reasonOf(
281
321
  "npx paperlint submission update papers/p --nope --save",
282
322
  paper(null),
283
323
  ),
284
- ).toBe([HEAD, UNRESOLVED, LAST].join("\n"));
324
+ ).toBe(NO_PDF(UNRESOLVED));
285
325
  });
286
326
 
287
327
  test("anything that may change the directory before the send makes its PDF unresolved, not guessed", () => {
@@ -290,13 +330,11 @@ test("anything that may change the directory before the send makes its PDF unres
290
330
  "pushd papers/p; npx paperlint submission update --save",
291
331
  "env -C papers/p npx paperlint submission update --save",
292
332
  ])
293
- expect(reasonOf(command, paper(facts())), command).toBe(
294
- [HEAD, UNRESOLVED, LAST].join("\n"),
295
- );
333
+ expect(reasonOf(command, paper(facts())), command).toBe(NO_PDF(UNRESOLVED));
296
334
  });
297
335
 
298
336
  test("a quoted --pdf=<file> with a space is one word: the PDF is that file", () => {
299
- const fs = memoryFiles({ "/w/final copies/paper.pdf": PDF });
337
+ const fs = memoryFiles({ [T]: "", "/w/final copies/paper.pdf": PDF });
300
338
  expect(
301
339
  reasonOf(
302
340
  `npx paperlint submission update papers/p --pdf="final copies/paper.pdf" --save`,
@@ -310,6 +348,7 @@ const B = new TextEncoder().encode("%PDF b");
310
348
 
311
349
  test("every send in a chain is described, one block each", () => {
312
350
  const fs = memoryFiles({
351
+ [T]: "",
313
352
  "/w/papers/a/paper.pdf": PDF,
314
353
  "/w/papers/b/paper.pdf": B,
315
354
  });
@@ -321,24 +360,24 @@ test("every send in a chain is described, one block each", () => {
321
360
  reason.filter((l) => /^(?:send|paper folder|PDF)/u.test(l)),
322
361
  ).toStrictEqual([
323
362
  "send 1 of 2:",
324
- "paper folder: papers/a",
363
+ "paper folder: /w/papers/a",
325
364
  `PDF: papers/a/paper.pdf, sha256 ${HEX.slice(0, 12)}`,
326
365
  "send 2 of 2:",
327
- "paper folder: papers/b",
366
+ "paper folder: /w/papers/b",
328
367
  `PDF: papers/b/paper.pdf, sha256 ${sha256Hex(B).slice(0, 12)}`,
329
368
  ]);
330
369
  });
331
370
 
332
- test("a send whose words the shell would expand, or one inside a string, is asked about as unresolved", () => {
371
+ test("a send whose words the shell would expand, or one inside a string, is refused as unresolved", () => {
333
372
  expect(
334
373
  reasonOf('npx paperlint submission update "$P" --save', paper(null)),
335
- ).toBe([HEAD, UNRESOLVED, LAST].join("\n"));
374
+ ).toBe(NO_PDF(UNRESOLVED));
336
375
  expect(
337
376
  reasonOf(
338
377
  `bash -c "npx paperlint submission update papers/p --save"`,
339
378
  paper(null),
340
379
  ),
341
- ).toBe([HEAD, UNRESOLVED, LAST].join("\n"));
380
+ ).toBe(NO_PDF(UNRESOLVED));
342
381
  });
343
382
 
344
383
  test("a client run by its path or quoted is a request: the program is read by its basename", () => {
@@ -353,17 +392,27 @@ test("a client run by its path or quoted is a request: the program is read by it
353
392
  ).toBe("direct");
354
393
  });
355
394
 
356
- test("straight at HotCRP: asked, naming that paperlint's check is skipped", () => {
395
+ /** What the agent is told to ask before a command that names a portal. */
396
+ const ASK_PORTAL = [
397
+ "It may send to the portal around paperlint's venue check. Ask the person with AskUserQuestion first, then run this command again: the question must contain the portal host and the command start above, exactly as written, and say what the command does.",
398
+ 'Options: "Yes, run it" and "No". One yes lets one command through. To send a paper, use `paperlint submission new|update --save` instead.',
399
+ ].join("\n");
400
+
401
+ test("straight at a portal, with no yes: refused, naming the host and the command's first 60 characters to ask about", () => {
357
402
  expect(
358
403
  reasonOf(
359
- "curl -F file=@paper.pdf https://venue.hotcrp.com/api/upload",
404
+ " curl -F file=@paper.pdf https://Venue.HotCRP.com/api/upload?p=1&token=secret",
360
405
  paper(null),
361
406
  ),
362
407
  ).toBe(
363
- "This command names a venue's submission portal, so it may send to it outside paperlint and its venue check. Allow only if you have read the dry run's venue check.",
408
+ [
409
+ "Not run: this command names a venue's submission portal, and the person has not said yes to it since the last send.",
410
+ "portal host: hotcrp.com",
411
+ "command start: curl -F file=@paper.pdf https://Venue.HotCRP.com/api/upload?",
412
+ ASK_PORTAL,
413
+ ].join("\n"),
364
414
  );
365
415
  });
366
-
367
416
  test("a command that sends nothing: no output at all — neither allow nor deny", () => {
368
417
  expect(
369
418
  submissionConfirm(
@@ -376,7 +425,7 @@ test("a command that sends nothing: no output at all — neither allow nor deny"
376
425
  ).toBe(null);
377
426
  });
378
427
 
379
- test("a crash while describing the send still asks; a payload that is not JSON is asked about", () => {
428
+ test("a crash while describing the send is refused; a payload that is not JSON is refused", () => {
380
429
  const broken: Files = {
381
430
  isFile: () => false,
382
431
  readBytes: (p: AbsolutePath) => {
@@ -385,15 +434,399 @@ test("a crash while describing the send still asks; a payload that is not JSON i
385
434
  writeAtomic: () => undefined,
386
435
  };
387
436
  expect(reasonOf(SEND, broken)).toBe(
388
- `${HEAD} paperlint could not say what will be sent: disk on fire at /w/papers/p/paperlint.json. ${LAST}`,
437
+ "Not run: paperlint could not check whether this command sends a paper: disk on fire at /w/papers/p/paperlint.json.",
389
438
  );
390
439
  expect(
391
440
  submissionConfirm(`{"tool_input": {"command": "${SEND}"`, paper(null))
392
- ?.hookSpecificOutput.permissionDecision,
393
- ).toBe("ask");
441
+ ?.hookSpecificOutput,
442
+ ).toStrictEqual({
443
+ hookEventName: "PreToolUse",
444
+ permissionDecision: "deny",
445
+ permissionDecisionReason:
446
+ "Not run: paperlint could not read this command, so it cannot tell whether it sends a paper to a venue's portal.",
447
+ });
394
448
  // A relative or missing cwd: the hook's own working directory.
395
449
  expect(
396
450
  submissionConfirm(payload(SEND, "relative"), paper(null))
397
451
  ?.hookSpecificOutput.permissionDecision,
398
- ).toBe("ask");
452
+ ).toBe("deny");
453
+ });
454
+
455
+ // ── the person's yes, read from the transcript ───────────────────────────────────────────
456
+ // Hand-built entries in the shape Claude Code writes: the assistant's `tool_use`, and the user
457
+ // entry with the matching `tool_result` and `toolUseResult.answers` (question text → option).
458
+
459
+ const SHA = HEX.slice(0, 12);
460
+
461
+ /** The assistant asks one AskUserQuestion; with an answer, the person's reply follows. */
462
+ const question = (id: string, text: string, answer?: unknown): string[] => {
463
+ const questions = [
464
+ {
465
+ question: text,
466
+ header: "Send",
467
+ multiSelect: false,
468
+ options: [
469
+ { label: "Yes, send it", description: "Send this PDF." },
470
+ { label: "No", description: "Do not send." },
471
+ ],
472
+ },
473
+ ];
474
+ return [
475
+ JSON.stringify({
476
+ type: "assistant",
477
+ message: {
478
+ role: "assistant",
479
+ content: [
480
+ { type: "text", text: "Asking first." },
481
+ {
482
+ type: "tool_use",
483
+ id,
484
+ name: "AskUserQuestion",
485
+ input: { questions },
486
+ },
487
+ ],
488
+ },
489
+ }),
490
+ ...(answer === undefined
491
+ ? []
492
+ : [
493
+ JSON.stringify({
494
+ type: "user",
495
+ message: {
496
+ role: "user",
497
+ content: [
498
+ { type: "tool_result", tool_use_id: id, content: "answered" },
499
+ ],
500
+ },
501
+ toolUseResult: { questions, answers: { [text]: answer } },
502
+ }),
503
+ ]),
504
+ ];
505
+ };
506
+
507
+ /** The assistant runs a Bash command. */
508
+ const bash = (id: string, command: unknown): string[] => [
509
+ JSON.stringify({
510
+ type: "assistant",
511
+ message: {
512
+ role: "assistant",
513
+ content: [{ type: "tool_use", id, name: "Bash", input: { command } }],
514
+ },
515
+ }),
516
+ ];
517
+
518
+ /** A question about the send of `sha` from `folder`, as the hook asks the agent to write it. */
519
+ const asksAbout = (folder: string, sha: string) =>
520
+ `Send ${folder}/paper.pdf (paper folder ${folder}, sha256 ${sha}) to the portal? Venue check: venue rules at their default options: no errors`;
521
+
522
+ const asks = (sha: string) => asksAbout("/w/papers/p", sha);
523
+
524
+ /** The hook's answer to SEND, over the paper with this transcript. */
525
+ const withTranscript = (lines: readonly string[], command = SEND) =>
526
+ submissionConfirm(
527
+ payload(command),
528
+ memoryFiles({
529
+ [T]: `${lines.join("\n")}\n`,
530
+ "/w/papers/p/paper.pdf": PDF,
531
+ "/w/papers/p/_build/paper.facts.json": facts(),
532
+ }),
533
+ );
534
+
535
+ test("a yes to a question naming the PDF's sha256: the send runs — the hook says nothing, so Claude Code's own permissions apply", () => {
536
+ expect(withTranscript(question("q1", asks(SHA), "Yes, send it"))).toBe(null);
537
+ // The answer is a yes by its first word, in any case, typed or chosen.
538
+ expect(withTranscript(question("q1", asks(SHA), " yes"))).toBe(null);
539
+ // The call being decided may already be in the transcript: it is no earlier send.
540
+ expect(
541
+ withTranscript([
542
+ ...question("q1", asks(SHA), "Yes, send it"),
543
+ ...bash("now", SEND),
544
+ ]),
545
+ ).toBe(null);
546
+ });
547
+
548
+ test("one yes, one send: the same send again is refused until the person is asked again", () => {
549
+ const once = [
550
+ ...question("q1", asks(SHA), "Yes, send it"),
551
+ ...bash("b1", SEND),
552
+ ];
553
+ expect(
554
+ withTranscript(once)?.hookSpecificOutput.permissionDecisionReason,
555
+ ).toMatch(new RegExp(`^${HEAD}`, "u"));
556
+ expect(
557
+ withTranscript([...once, ...question("q2", asks(SHA), "Yes, send it")]),
558
+ ).toBe(null);
559
+ // A send the hook refused uses the yes up too: a question asked BEFORE it does not count.
560
+ expect(
561
+ withTranscript([
562
+ ...question("q1", asks(SHA), "Yes"),
563
+ ...bash("b1", `${SEND} && echo`),
564
+ ])?.hookSpecificOutput.permissionDecision,
565
+ ).toBe("deny");
566
+ // Other commands in between use up nothing.
567
+ expect(
568
+ withTranscript([
569
+ ...question("q1", asks(SHA), "Yes"),
570
+ ...bash("b1", "npx paperlint submission show papers/p"),
571
+ ...bash("b2", 42),
572
+ ]),
573
+ ).toBe(null);
574
+ });
575
+
576
+ test("no yes: a 'No', no answer yet, an answer that is not a yes, a yes withdrawn later — refused", () => {
577
+ const refused = (lines: readonly string[]) =>
578
+ withTranscript(lines)?.hookSpecificOutput.permissionDecisionReason.split(
579
+ "\n",
580
+ )[0];
581
+ expect(refused(question("q1", asks(SHA), "No"))).toBe(HEAD);
582
+ // Asked in the same turn as the send: not answered yet.
583
+ expect(refused(question("q1", asks(SHA)))).toBe(HEAD);
584
+ expect(
585
+ refused(question("q1", asks(SHA), "Yesterday's build, not this one")),
586
+ ).toBe(HEAD);
587
+ expect(refused(question("q1", asks(SHA), ["Yes"]))).toBe(HEAD);
588
+ // A reply with no answers in it, and a tool other than a question or Bash, count for nothing.
589
+ const reply = question("q1", asks(SHA), "Yes");
590
+ expect(
591
+ refused([
592
+ reply[0] ?? "",
593
+ (reply[1] ?? "")
594
+ .replace('"toolUseResult"', '"otherResult"')
595
+ .replace('"type":"user"', '"type":"user","toolUseResult":"Yes"'),
596
+ JSON.stringify({
597
+ type: "assistant",
598
+ message: {
599
+ content: [{ type: "tool_use", id: "r1", name: "Read", input: {} }],
600
+ },
601
+ }),
602
+ ]),
603
+ ).toBe(HEAD);
604
+ // The latest question naming the sha decides.
605
+ expect(
606
+ refused([
607
+ ...question("q1", asks(SHA), "Yes"),
608
+ ...question("q2", asks(SHA), "No"),
609
+ ]),
610
+ ).toBe(HEAD);
611
+ // An answer that arrives after a send, to a question asked before it, is no fresh yes.
612
+ const late = question("q1", asks(SHA), "Yes");
613
+ expect(refused([late[0] ?? "", ...bash("b1", SEND), late[1] ?? ""])).toBe(
614
+ HEAD,
615
+ );
616
+ });
617
+
618
+ test("a stale sha: a yes to another PDF's bytes does not let this one through", () => {
619
+ expect(
620
+ withTranscript(
621
+ question("q1", asks("b".repeat(12)), "Yes, send it"),
622
+ )?.hookSpecificOutput.permissionDecisionReason.split("\n")[0],
623
+ ).toBe(HEAD);
624
+ });
625
+
626
+ /** Two sends in one command, and the hook's answer over a transcript. */
627
+ const chained = (command: string, lines: readonly string[]) =>
628
+ submissionConfirm(
629
+ payload(command),
630
+ memoryFiles({
631
+ [T]: lines.join("\n"),
632
+ "/w/papers/a/paper.pdf": PDF,
633
+ "/w/papers/b/paper.pdf": B,
634
+ "/w/papers/c/paper.pdf": PDF,
635
+ }),
636
+ )?.hookSpecificOutput.permissionDecision ?? "runs";
637
+
638
+ const AB =
639
+ "npx paperlint submission update papers/a --save && npx paperlint submission new papers/b --save";
640
+
641
+ test("two sends in one command: each needs a yes of its own, naming its own folder and sha256", () => {
642
+ const shaB = sha256Hex(B).slice(0, 12);
643
+ const yesA = question("q1", asksAbout("/w/papers/a", SHA), "Yes");
644
+ const yesB = question("q2", asksAbout("/w/papers/b", shaB), "Yes");
645
+ expect(chained(AB, yesA)).toBe("deny");
646
+ expect(chained(AB, [...yesA, ...yesB])).toBe("runs");
647
+ // The order of the answers does not matter.
648
+ expect(chained(AB, [...yesB, ...yesA])).toBe("runs");
649
+ // A No to the second send refuses the command.
650
+ expect(
651
+ chained(AB, [
652
+ ...yesA,
653
+ ...question("q2", asksAbout("/w/papers/b", shaB), "No"),
654
+ ]),
655
+ ).toBe("deny");
656
+ });
657
+
658
+ test("one yes whose question names two shas covers one send, not two", () => {
659
+ const shaB = sha256Hex(B).slice(0, 12);
660
+ const both = question(
661
+ "q1",
662
+ `${asksAbout("/w/papers/a", SHA)} and ${asksAbout("/w/papers/b", shaB)}`,
663
+ "Yes",
664
+ );
665
+ expect(chained(AB, both)).toBe("deny");
666
+ // Asked again for the second send, the command runs.
667
+ expect(
668
+ chained(AB, [
669
+ ...both,
670
+ ...question("q2", asksAbout("/w/papers/b", shaB), "Yes"),
671
+ ]),
672
+ ).toBe("runs");
673
+ });
674
+
675
+ test("two sends of the same PDF: one yes denies, two yeses run", () => {
676
+ const twice =
677
+ "npx paperlint submission update papers/a --save && npx paperlint submission update papers/a --submit --save";
678
+ const yes = question("q1", asksAbout("/w/papers/a", SHA), "Yes");
679
+ expect(chained(twice, yes)).toBe("deny");
680
+ expect(
681
+ chained(twice, [
682
+ ...yes,
683
+ ...question("q2", asksAbout("/w/papers/a", SHA), "Yes"),
684
+ ]),
685
+ ).toBe("runs");
686
+ });
687
+
688
+ test("a yes naming the right sha but another folder does not count: identical bytes in two folders share no yes", () => {
689
+ const sendC = "npx paperlint submission update papers/c --save";
690
+ // papers/a and papers/c hold the same bytes.
691
+ expect(
692
+ chained(sendC, question("q1", asksAbout("/w/papers/a", SHA), "Yes")),
693
+ ).toBe("deny");
694
+ expect(
695
+ chained(sendC, question("q1", asksAbout("/w/papers/c", SHA), "Yes")),
696
+ ).toBe("runs");
697
+ // Neither a longer folder that starts with this one, nor one this one ends.
698
+ for (const other of ["/w/papers/c2", "/w/papers/c.old", "/x/w/papers/c"])
699
+ expect(chained(sendC, question("q1", asksAbout(other, SHA), "Yes"))).toBe(
700
+ "deny",
701
+ );
702
+ // The folder path is case-sensitive; the sha is not.
703
+ expect(
704
+ chained(sendC, question("q1", asksAbout("/W/papers/c", SHA), "Yes")),
705
+ ).toBe("deny");
706
+ expect(
707
+ chained(
708
+ sendC,
709
+ question("q1", asksAbout("/w/papers/c", SHA.toUpperCase()), "Yes"),
710
+ ),
711
+ ).toBe("runs");
712
+ // The folder as part of a longer path to the PDF is named.
713
+ expect(
714
+ chained(
715
+ sendC,
716
+ question("q1", `Send /w/papers/c/paper.pdf, sha256 ${SHA}?`, "Yes"),
717
+ ),
718
+ ).toBe("runs");
719
+ });
720
+
721
+ test("a transcript that cannot be read, or no transcript: refused", () => {
722
+ const reason = (fs: Files, more?: object) =>
723
+ submissionConfirm(payload(SEND, "/w", more), fs)?.hookSpecificOutput
724
+ .permissionDecisionReason;
725
+ const files = (transcript?: string) =>
726
+ memoryFiles({
727
+ "/w/papers/p/paper.pdf": PDF,
728
+ ...(transcript === undefined ? {} : { [T]: transcript }),
729
+ });
730
+ expect(reason(files())).toBe(
731
+ `${NOT_SENT}, and the transcript ${T} could not be read, so paperlint cannot see the person's yes.`,
732
+ );
733
+ expect(reason(files(""), {})).toBe(
734
+ `${NOT_SENT}, and the hook was given no transcript_path, so paperlint cannot see the person's yes.`,
735
+ );
736
+ expect(reason(files(""), { transcript_path: "session.jsonl" })).toBe(
737
+ `${NOT_SENT}, and the hook was given no transcript_path, so paperlint cannot see the person's yes.`,
738
+ );
739
+ });
740
+
741
+ test("a broken transcript line: one that may hold a send uses the yes before it up; any other is skipped", () => {
742
+ const yes = question("q1", asks(SHA), "Yes");
743
+ const decide = (lines: readonly string[]) =>
744
+ withTranscript(lines)?.hookSpecificOutput.permissionDecision ?? "runs";
745
+ // An entry cut short and run into the next, as real transcripts have them.
746
+ const cut = (entry: string) => `${entry.slice(0, 80)}${entry}`;
747
+ const send = bash("b1", SEND)[0] ?? "";
748
+ const other = bash("b1", "ls -la")[0] ?? "";
749
+ expect(decide([...yes, cut(send)])).toBe("deny");
750
+ expect(decide([...yes, cut(other)])).toBe("runs");
751
+ // A yes asked after it counts again; a line with no content list is skipped.
752
+ expect(
753
+ decide([
754
+ cut(send),
755
+ JSON.stringify({ type: "user", message: { content: "toolUseResult" } }),
756
+ ...yes,
757
+ ]),
758
+ ).toBe("runs");
759
+ });
760
+
761
+ test("a command naming a portal runs after a yes to a question with its host and its start, once", () => {
762
+ const command = "curl -X POST https://venue.hotcrp.com/api/paper/1/comment";
763
+ const start = command.slice(0, 60);
764
+ const decide = (lines: readonly string[]) =>
765
+ withTranscript(lines, command)?.hookSpecificOutput.permissionDecision ??
766
+ "runs";
767
+ const yes = question("q1", `Run ${start} against HOTCRP.COM?`, "Yes, run it");
768
+ expect(decide(yes)).toBe("runs");
769
+ // Used up by the run, or by any earlier command that names a portal.
770
+ expect(decide([...yes, ...bash("b1", command)])).toBe("deny");
771
+ expect(decide([...yes, ...bash("b1", "grep -rn hotcrp.com src/")])).toBe(
772
+ "deny",
773
+ );
774
+ // The host alone, or the start alone, is not enough; nor is a No.
775
+ expect(decide(question("q1", "Run it against hotcrp.com?", "Yes"))).toBe(
776
+ "deny",
777
+ );
778
+ expect(
779
+ decide(question("q1", `Run ${start.slice(0, 30)} on hotcrp.com?`, "Yes")),
780
+ ).toBe("deny");
781
+ expect(decide(question("q1", `Run ${start} on hotcrp.com?`, "No"))).toBe(
782
+ "deny",
783
+ );
784
+ // A broken transcript line that names a portal counts as a run of one.
785
+ expect(
786
+ decide([...yes, `{"type":"tool_use"${bash("b1", command)[0] ?? ""}`]),
787
+ ).toBe("deny");
788
+ });
789
+
790
+ test("a paperlint send that also names a portal needs both yeses: the PDF's sha256, and the host with the start", () => {
791
+ const command = `${SEND} && curl https://venue.hotcrp.com/api/paper/1`;
792
+ const start = command.slice(0, 60);
793
+ const decide = (lines: readonly string[]) =>
794
+ withTranscript(lines, command)?.hookSpecificOutput
795
+ .permissionDecisionReason ?? "runs";
796
+ const sha = question("q1", asks(SHA), "Yes");
797
+ const host = question("q2", `${start} on hotcrp.com`, "Yes");
798
+ expect(decide([...sha, ...host])).toBe("runs");
799
+ const reason = decide(sha).split("\n");
800
+ expect(reason[0]).toBe(HEAD);
801
+ expect(reason.slice(-4)).toStrictEqual([
802
+ "portal host: hotcrp.com",
803
+ `command start: ${start}`,
804
+ ...ASK_PORTAL.split("\n"),
805
+ ]);
806
+ expect(decide(host).split("\n")[0]).toBe(HEAD);
807
+ });
808
+
809
+ test("a paperlint send that also names a portal: one yes naming everything covers one of them, not both", () => {
810
+ const command = `${SEND} && curl https://venue.hotcrp.com/api/paper/1`;
811
+ const start = command.slice(0, 60);
812
+ const decide = (lines: readonly string[]) =>
813
+ withTranscript(lines, command)?.hookSpecificOutput.permissionDecision ??
814
+ "runs";
815
+ const all = question("q1", `${asks(SHA)} Also ${start} on hotcrp.com`, "Yes");
816
+ expect(decide(all)).toBe("deny");
817
+ // A second yes naming only the host and the start, before or after it, covers the other.
818
+ const host = question("q2", `${start} on hotcrp.com`, "Yes");
819
+ expect(decide([...all, ...host])).toBe("runs");
820
+ expect(decide([...host, ...all])).toBe("runs");
821
+ });
822
+
823
+ test("the approved command start is compared as written; the host in any case", () => {
824
+ const command = "curl -F f=@paper.pdf https://venue.hotcrp.com/api/paper/1";
825
+ const decide = (text: string) =>
826
+ withTranscript(question("q1", text, "Yes"), command)?.hookSpecificOutput
827
+ .permissionDecision ?? "runs";
828
+ expect(decide(`Run ${command} on VENUE.HOTCRP.COM?`)).toBe("runs");
829
+ expect(
830
+ decide(`Run ${command.replace("paper.pdf", "PAPER.PDF")} on hotcrp.com?`),
831
+ ).toBe("deny");
399
832
  });