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.
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +18 -8
- package/dist/build.js.map +1 -1
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +2 -2
- package/dist/cli.js.map +1 -1
- package/dist/latex-log.d.ts +61 -26
- package/dist/latex-log.d.ts.map +1 -1
- package/dist/latex-log.js +59 -6
- package/dist/latex-log.js.map +1 -1
- package/dist/latex-loop.d.ts +21 -6
- package/dist/latex-loop.d.ts.map +1 -1
- package/dist/latex-loop.js +32 -12
- package/dist/latex-loop.js.map +1 -1
- package/dist/submission-confirm-hook.d.ts +27 -19
- package/dist/submission-confirm-hook.d.ts.map +1 -1
- package/dist/submission-confirm-hook.js +302 -95
- package/dist/submission-confirm-hook.js.map +1 -1
- package/docs/configuration.md +30 -1
- package/docs/e2e.md +1 -0
- package/docs/layout.md +22 -22
- package/docs/submission.md +32 -8
- package/fixtures/build-e2e/lost-character/PIPELINE-STATUS.md +3 -0
- package/fixtures/build-e2e/lost-character/expect.json +15 -0
- package/fixtures/build-e2e/lost-character/paper.tex +8 -0
- package/package.json +1 -1
- package/skills/submission-portal/SKILL.md +18 -12
- package/skills/submission-portal/SKILL.md.spec.ts +17 -11
- package/src/build.test.ts +46 -3
- package/src/build.ts +20 -8
- package/src/cli.test.ts +3 -3
- package/src/cli.ts +2 -2
- package/src/latex-log.harness.ts +145 -14
- package/src/latex-log.ts +105 -16
- package/src/latex-loop.harness.ts +70 -34
- package/src/latex-loop.ts +51 -30
- package/src/submission-confirm-hook.test.ts +493 -60
- package/src/submission-confirm-hook.ts +448 -116
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The `submission-confirm` hook: which commands send a submission
|
|
3
|
-
*
|
|
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(
|
|
27
|
-
return reason.startsWith("
|
|
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
|
-
|
|
49
|
-
|
|
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
|
-
])("
|
|
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
|
-
])("
|
|
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("
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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 ?? "(
|
|
150
|
+
.permissionDecisionReason ?? "(let through)";
|
|
140
151
|
|
|
141
152
|
const SEND = "npx paperlint submission update papers/p --submit --save";
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
const
|
|
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
|
|
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: "
|
|
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
|
|
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
|
-
)
|
|
187
|
-
).toBe(
|
|
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
|
|
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
|
|
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(
|
|
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(
|
|
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
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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://
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
393
|
-
).
|
|
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("
|
|
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
|
});
|