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,46 +1,65 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The `submission-confirm` hook — no send to a venue's portal without the person's own yes.
|
|
2
|
+
* The `submission-confirm` hook — no send to a venue's portal without the person's own yes to it.
|
|
3
3
|
* PreToolUse on `Bash` (`plugin/hooks/hooks.json`): a command that sends a submission — `paperlint
|
|
4
|
-
* submission new|update` with `--save` or `--submit
|
|
5
|
-
* `
|
|
6
|
-
*
|
|
4
|
+
* submission new|update` with `--save` or `--submit` — runs only when the person has said yes, in an
|
|
5
|
+
* `AskUserQuestion` asked since the last send, to a question naming the paper folder (its full path)
|
|
6
|
+
* and the sha256 of the PDF it sends.
|
|
7
|
+
* A command that names a portal's host runs only after a yes to a question naming that host and the
|
|
8
|
+
* command's first 60 characters. Without the yes the command is refused (`"permissionDecision":
|
|
9
|
+
* "deny"`), and the reason tells the agent exactly what to ask. Anything else: no output.
|
|
7
10
|
*
|
|
8
|
-
* ── WHY
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
11
|
+
* ── WHY A QUESTION, NOT CLAUDE CODE'S PERMISSION PROMPT ──────────────────────────
|
|
12
|
+
* It used to answer `ask` (paperlint#283). The Claude mobile app's permission card shows only the
|
|
13
|
+
* command line, not the hook's reason, so the person approved a shell line without seeing which
|
|
14
|
+
* paper or PDF it sends (paperlint#291). `AskUserQuestion` shows the whole question on every
|
|
15
|
+
* client, and a yes to a question that names the folder and the sha256 is a yes to those bytes in that
|
|
16
|
+
* folder: a stale build or a wrong PDF has another sha, and identical bytes in another folder (the
|
|
17
|
+
* same manuscript for another venue) have another path, so their yes does not count.
|
|
18
|
+
*
|
|
19
|
+
* ── WHERE THE YES IS READ ─────────────────────────────────────────────────────────
|
|
20
|
+
* The session's transcript (`transcript_path` in the hook's input, one JSON entry per line). A
|
|
21
|
+
* question is the assistant's `tool_use` named `AskUserQuestion`; its answer is the user entry that
|
|
22
|
+
* carries the matching `tool_result`, whose `toolUseResult.answers` maps each question's text to the
|
|
23
|
+
* option chosen. An answer is a yes when its first word is "yes". Every earlier `Bash` call that
|
|
24
|
+
* sends or names a portal — refused or not, but not this one (`tool_use_id`) — uses up the questions
|
|
25
|
+
* before it, so one yes lets one command through. Within one command each send takes an answer of
|
|
26
|
+
* its own: a yes that names two shas, or the same PDF sent twice, covers one send. When several
|
|
27
|
+
* questions name the same thing, the latest decides.
|
|
16
28
|
*
|
|
17
29
|
* ── WHAT IT SEES, AND WHAT IT IS NOT ──────────────────────────────────────────────
|
|
18
30
|
* A send is judged on the command as the shell reads it (`shellLeaves`: each simple command across
|
|
19
31
|
* `&&`, `;`, `|`, words unquoted, the commands inside a `bash -c "…"` string too), never on the raw
|
|
20
|
-
* text: `submission`, `new|update` and `--save|--submit` in one command's words
|
|
21
|
-
* in any word (a HotCRP-hosted site, or a host a venue preset declares), whatever program it runs
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
32
|
+
* text: `submission`, `new|update` and `--save|--submit` in one command's words; or a portal's host
|
|
33
|
+
* in any word (a HotCRP-hosted site, or a host a venue preset declares), whatever program it runs —
|
|
34
|
+
* such a command may send around paperlint's venue check, and has no PDF to name, so the question
|
|
35
|
+
* names the host and the command instead. When the presets cannot be read, only HotCRP-hosted sites
|
|
36
|
+
* and paperlint sends are recognised, and a refusal says so: one bad preset does not stop the shell.
|
|
37
|
+
* NOT SEEN: an upload from a script file that names the host only inside the file, or words built
|
|
38
|
+
* at run time (`$(…)`).
|
|
27
39
|
*
|
|
28
|
-
* The
|
|
29
|
-
*
|
|
40
|
+
* The agent writes the question, so a deliberately misleading question still passes. The hook
|
|
41
|
+
* catches an agent that sends without asking, or asks about other bytes; it is not a security
|
|
42
|
+
* boundary against an agent that deliberately disguises a send.
|
|
30
43
|
*
|
|
31
|
-
* ── IT FAILS
|
|
32
|
-
*
|
|
33
|
-
*
|
|
44
|
+
* ── IT FAILS CLOSED ───────────────────────────────────────────────────────────────
|
|
45
|
+
* A send whose PDF cannot be told for certain, a PDF that is not there, no transcript, a payload
|
|
46
|
+
* that is not JSON, a crash: refused, with what went wrong. A transcript line that is not JSON is
|
|
47
|
+
* not a reason to refuse every send for the rest of the session — real transcripts have them (an
|
|
48
|
+
* entry cut short and run into the next, measured in a live one) — so it counts as a send when its
|
|
49
|
+
* text may hold one, and a yes before it no longer counts. Only lines that may hold a question, an
|
|
50
|
+
* answer or a send are parsed, and the shell parser runs only on commands that say `submission` or
|
|
51
|
+
* a portal's host:
|
|
52
|
+
* a transcript of hundreds of megabytes is read in seconds.
|
|
34
53
|
*
|
|
35
54
|
* ── WHY IN-PROCESS, NOT A VIGILES PROGRAM ────────────────────────────────────────
|
|
36
55
|
* It reads the build's facts with this package's own parser and hashes the PDF; a shipped vigiles
|
|
37
56
|
* hook may import nothing but `vigiles/hook` (`src/folder-layout-hook.ts` says the rest). So
|
|
38
57
|
* `paperlint hook submission-confirm` runs it in-process (`runHook`, src/cli.ts).
|
|
39
58
|
*/
|
|
40
|
-
import {
|
|
59
|
+
import { isAbsolute, join, relative, resolve } from "node:path";
|
|
41
60
|
import { callerPath } from "./caller-path.ts";
|
|
42
61
|
import { bodyEnd } from "./domain/body-pages.ts";
|
|
43
|
-
import { fieldOf } from "./domain/record.ts";
|
|
62
|
+
import { fieldOf, isRecord } from "./domain/record.ts";
|
|
44
63
|
import { sha256Hex } from "./domain/sha256.ts";
|
|
45
64
|
import { messageOf } from "./domain/text.ts";
|
|
46
65
|
import { factsPath, parseFactsText, type ReadFacts } from "./facts-file.ts";
|
|
@@ -73,25 +92,23 @@ export type ConfirmDeps = PresetDeps & {
|
|
|
73
92
|
readonly readSend: (words: readonly string[]) => Send | null;
|
|
74
93
|
};
|
|
75
94
|
|
|
76
|
-
/** What Claude Code reads from a PreToolUse hook's stdout to
|
|
77
|
-
export interface
|
|
95
|
+
/** What Claude Code reads from a PreToolUse hook's stdout to refuse the command; the agent sees the reason. */
|
|
96
|
+
export interface DenyOutput {
|
|
78
97
|
readonly hookSpecificOutput: {
|
|
79
98
|
readonly hookEventName: "PreToolUse";
|
|
80
|
-
readonly permissionDecision: "
|
|
99
|
+
readonly permissionDecision: "deny";
|
|
81
100
|
readonly permissionDecisionReason: string;
|
|
82
101
|
};
|
|
83
102
|
}
|
|
84
103
|
|
|
85
|
-
const
|
|
104
|
+
const deny = (reason: string): DenyOutput => ({
|
|
86
105
|
hookSpecificOutput: {
|
|
87
106
|
hookEventName: "PreToolUse",
|
|
88
|
-
permissionDecision: "
|
|
107
|
+
permissionDecision: "deny",
|
|
89
108
|
permissionDecisionReason: reason,
|
|
90
109
|
},
|
|
91
110
|
});
|
|
92
111
|
|
|
93
|
-
const LAST = "Allow only if you have read the dry run's venue check.";
|
|
94
|
-
|
|
95
112
|
/** The domain of HotCRP's hosted sites; a self-hosted portal is known by the host its preset declares. */
|
|
96
113
|
const HOTCRP_HOSTED = "hotcrp.com";
|
|
97
114
|
|
|
@@ -153,26 +170,30 @@ function sendsIn(
|
|
|
153
170
|
});
|
|
154
171
|
}
|
|
155
172
|
|
|
156
|
-
/**
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
* whatever program it runs, as a plain case-insensitive substring of a word — or nothing. Judged on
|
|
160
|
-
* the shell's words only, never on the raw text.
|
|
161
|
-
*/
|
|
162
|
-
export function sendKind(
|
|
163
|
-
leaves: readonly ShellLeaf[],
|
|
164
|
-
hosts: () => readonly string[],
|
|
165
|
-
): "paperlint" | "direct" | null {
|
|
166
|
-
if (leaves.some(isSend)) return "paperlint";
|
|
167
|
-
const words = leaves
|
|
173
|
+
/** The words of the commands that may name a host: those with a dot, lowercased. */
|
|
174
|
+
const dottedWords = (leaves: readonly ShellLeaf[]): readonly string[] =>
|
|
175
|
+
leaves
|
|
168
176
|
.flatMap((l) => [l.head, ...l.args])
|
|
169
177
|
.map((w) => w.toLowerCase())
|
|
170
178
|
.filter((w) => w.includes("."));
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
179
|
+
|
|
180
|
+
/** The portal hosts known: HotCRP's hosted sites always, then those `hosts` names; lowercased. */
|
|
181
|
+
const knownHosts = (hosts: readonly string[]): readonly string[] =>
|
|
182
|
+
[HOTCRP_HOSTED, ...hosts].map((h) => h.toLowerCase()).filter((h) => h !== "");
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* The portal host a command names — a HotCRP-hosted site, or a host a venue preset declares
|
|
186
|
+
* (`hosts`), whatever program it runs, as a plain case-insensitive substring of a word — or null.
|
|
187
|
+
* Judged on the shell's words only, never on the raw text.
|
|
188
|
+
*/
|
|
189
|
+
function portalHost(
|
|
190
|
+
leaves: readonly ShellLeaf[],
|
|
191
|
+
hosts: readonly string[],
|
|
192
|
+
): string | null {
|
|
193
|
+
const words = dottedWords(leaves);
|
|
194
|
+
return (
|
|
195
|
+
knownHosts(hosts).find((h) => words.some((w) => w.includes(h))) ?? null
|
|
196
|
+
);
|
|
176
197
|
}
|
|
177
198
|
|
|
178
199
|
/** The body pages the build counted: up to the references or appendix, or banal's count. */
|
|
@@ -213,12 +234,20 @@ function factsLines(
|
|
|
213
234
|
return [...stale, bodyPages(f.value)];
|
|
214
235
|
}
|
|
215
236
|
|
|
237
|
+
/** What a yes to one send must name: the paper folder as the hook resolves it, and the PDF's sha256 prefix. */
|
|
238
|
+
interface Named {
|
|
239
|
+
readonly folder: string;
|
|
240
|
+
readonly sha: string;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** One send, described: what a yes must name (null when the PDF is not found), and the lines for the person. */
|
|
244
|
+
interface Described {
|
|
245
|
+
readonly named: Named | null;
|
|
246
|
+
readonly lines: readonly string[];
|
|
247
|
+
}
|
|
248
|
+
|
|
216
249
|
/** One send, described: the paper, the PDF as the command resolves it, what the build saw. */
|
|
217
|
-
function
|
|
218
|
-
send: Send,
|
|
219
|
-
cwd: string,
|
|
220
|
-
deps: PresetDeps,
|
|
221
|
-
): readonly string[] {
|
|
250
|
+
function describe(send: Send, cwd: string, deps: PresetDeps): Described {
|
|
222
251
|
const paperDir = resolve(cwd, send.paper ?? ".");
|
|
223
252
|
const p = paperPreset(paperDir, deps);
|
|
224
253
|
const own = paperPdf(
|
|
@@ -228,52 +257,356 @@ function sendLines(
|
|
|
228
257
|
const pdf = send.pdf === null ? own : resolve(cwd, send.pdf);
|
|
229
258
|
const bytes = deps.files.readBytes(callerPath(pdf));
|
|
230
259
|
const sha = bytes === null ? null : sha256Hex(bytes);
|
|
231
|
-
return
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
260
|
+
return {
|
|
261
|
+
named:
|
|
262
|
+
sha === null ? null : { folder: paperDir, sha: sha.slice(0, SHA_SHOWN) },
|
|
263
|
+
lines: [
|
|
264
|
+
`paper folder: ${paperDir}`,
|
|
265
|
+
`PDF: ${send.pdf ?? join(send.paper ?? ".", relative(paperDir, own))}${sha === null ? " — NOT FOUND" : `, sha256 ${sha.slice(0, SHA_SHOWN)}`}`,
|
|
266
|
+
...factsLines(paperDir, sha, deps.files),
|
|
267
|
+
],
|
|
268
|
+
};
|
|
236
269
|
}
|
|
237
270
|
|
|
271
|
+
/** How much of the sha256 the reason shows, and the question must contain. */
|
|
272
|
+
const SHA_SHOWN = 12;
|
|
273
|
+
|
|
238
274
|
/** A send whose PDF cannot be told for certain. */
|
|
239
275
|
const UNRESOLVED =
|
|
240
276
|
"could not resolve which PDF this send uploads — check the dry run";
|
|
241
277
|
|
|
278
|
+
const NOT_SENT = "Not run: this sends the paper to the venue's portal";
|
|
279
|
+
const NAMES_PORTAL = "Not run: this command names a venue's submission portal";
|
|
280
|
+
|
|
281
|
+
/** What to ask, once every send's PDF is known. */
|
|
282
|
+
const HOW_TO_ASK = [
|
|
283
|
+
"Ask the person with AskUserQuestion first, then run this command again. In the question, give:",
|
|
284
|
+
"- 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),",
|
|
285
|
+
"- what the lines above say about the build (the body pages, or that it is stale or missing),",
|
|
286
|
+
"- the dry run's venue check, quoted word for word (its `venue rules at their default options` line and every error under it).",
|
|
287
|
+
'Options: "Yes, send it" and "No". One yes lets one send through: ask one question per send, each naming its own folder and sha256.',
|
|
288
|
+
].join("\n");
|
|
289
|
+
|
|
290
|
+
/** How much of a command naming a portal the question must quote. */
|
|
291
|
+
const COMMAND_SHOWN = 60;
|
|
292
|
+
|
|
293
|
+
/** What to ask before a command that names a portal. */
|
|
294
|
+
const HOW_TO_ASK_PORTAL = [
|
|
295
|
+
"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.",
|
|
296
|
+
'Options: "Yes, run it" and "No". One yes lets one command through. To send a paper, use `paperlint submission new|update --save` instead.',
|
|
297
|
+
].join("\n");
|
|
298
|
+
|
|
299
|
+
/** Each send's block: alone, or numbered when the command has several. */
|
|
300
|
+
function blocks(described: readonly Described[]): readonly string[] {
|
|
301
|
+
return described.length === 1
|
|
302
|
+
? described.flatMap((d) => d.lines)
|
|
303
|
+
: described.flatMap((d, n) => [
|
|
304
|
+
`send ${String(n + 1)} of ${String(described.length)}:`,
|
|
305
|
+
...d.lines,
|
|
306
|
+
]);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** One question the person answered: its text, and the option they chose. */
|
|
310
|
+
interface Answer {
|
|
311
|
+
readonly question: string;
|
|
312
|
+
readonly answer: string;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** The questions asked and answered since the last send, while reading the transcript. */
|
|
316
|
+
interface Since {
|
|
317
|
+
readonly asked: ReadonlySet<string>;
|
|
318
|
+
readonly answers: readonly Answer[];
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** A transcript line that may hold a question, an answer or a send; no other line is parsed. */
|
|
322
|
+
const MAY_MATTER = /"tool_use"|"toolUseResult"/u;
|
|
323
|
+
|
|
324
|
+
/** What tells an earlier send in the transcript. */
|
|
325
|
+
interface EarlierSend {
|
|
326
|
+
/** This call's own `tool_use_id`: no earlier send. */
|
|
327
|
+
readonly current: string | null;
|
|
328
|
+
/** Whether text may hold a send — cheap, so the shell parser runs only where it may matter. */
|
|
329
|
+
readonly mayHold: (text: string) => boolean;
|
|
330
|
+
/** Whether a command sends: a paperlint send, or a portal named. */
|
|
331
|
+
readonly sends: (command: string) => boolean;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** Nothing asked or answered: the start, and the state right after a send. */
|
|
335
|
+
const CLEARED: Since = { asked: new Set(), answers: [] };
|
|
336
|
+
|
|
337
|
+
/** A transcript line that is not JSON. */
|
|
338
|
+
const BROKEN = Symbol("broken");
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* The person's answers to `AskUserQuestion` since the last send in the transcript, oldest first.
|
|
342
|
+
* A line that may matter and is not JSON (real transcripts have them: an entry cut short, run into
|
|
343
|
+
* the next) counts as a send when it may hold one, so a yes before it is used up — never wrongly
|
|
344
|
+
* kept; otherwise it is skipped, and a yes lost in it only means asking again.
|
|
345
|
+
*/
|
|
346
|
+
export function answersSinceLastSend(
|
|
347
|
+
transcript: string,
|
|
348
|
+
earlier: EarlierSend,
|
|
349
|
+
): readonly Answer[] {
|
|
350
|
+
return transcript.split("\n").reduce((since: Since, line): Since => {
|
|
351
|
+
if (!MAY_MATTER.test(line)) return since;
|
|
352
|
+
const entry = parsedLine(line);
|
|
353
|
+
if (entry === BROKEN) return earlier.mayHold(line) ? CLEARED : since;
|
|
354
|
+
const content = fieldOf(fieldOf(entry, "message"), "content");
|
|
355
|
+
return (Array.isArray(content) ? content : []).reduce(
|
|
356
|
+
(s: Since, block: unknown): Since =>
|
|
357
|
+
fieldOf(block, "type") === "tool_result"
|
|
358
|
+
? toolResult(s, block, entry)
|
|
359
|
+
: toolUse(s, block, earlier),
|
|
360
|
+
since,
|
|
361
|
+
);
|
|
362
|
+
}, CLEARED).answers;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** One transcript line, parsed; BROKEN when it is not JSON. */
|
|
366
|
+
function parsedLine(line: string): unknown {
|
|
367
|
+
try {
|
|
368
|
+
return JSON.parse(line);
|
|
369
|
+
} catch {
|
|
370
|
+
return BROKEN;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** A block the assistant wrote: a question is noted; an earlier send clears what was asked before it. */
|
|
375
|
+
function toolUse(s: Since, block: unknown, earlier: EarlierSend): Since {
|
|
376
|
+
const id = fieldOf(block, "id");
|
|
377
|
+
if (fieldOf(block, "type") !== "tool_use" || typeof id !== "string") return s;
|
|
378
|
+
const name = fieldOf(block, "name");
|
|
379
|
+
if (name === "AskUserQuestion")
|
|
380
|
+
return { ...s, asked: new Set([...s.asked, id]) };
|
|
381
|
+
const command = fieldOf(fieldOf(block, "input"), "command");
|
|
382
|
+
const send =
|
|
383
|
+
name === "Bash" &&
|
|
384
|
+
id !== earlier.current &&
|
|
385
|
+
typeof command === "string" &&
|
|
386
|
+
earlier.mayHold(command) &&
|
|
387
|
+
earlier.sends(command);
|
|
388
|
+
return send ? CLEARED : s;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** The person's reply to a question asked since the last send: its answers, kept in order. */
|
|
392
|
+
function toolResult(s: Since, block: unknown, entry: unknown): Since {
|
|
393
|
+
const id = fieldOf(block, "tool_use_id");
|
|
394
|
+
if (typeof id !== "string" || !s.asked.has(id)) return s;
|
|
395
|
+
const answers = fieldOf(fieldOf(entry, "toolUseResult"), "answers");
|
|
396
|
+
const given = Object.entries(isRecord(answers) ? answers : {}).flatMap(
|
|
397
|
+
([question, answer]): readonly Answer[] =>
|
|
398
|
+
typeof answer === "string" ? [{ question, answer }] : [],
|
|
399
|
+
);
|
|
400
|
+
return { ...s, answers: [...s.answers, ...given] };
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** Whether an answer is a yes: its first word, in any case. */
|
|
404
|
+
const isYes = (a: Answer): boolean => /^yes\b/iu.test(a.answer.trim());
|
|
405
|
+
|
|
406
|
+
/** Whether `text` contains every one of `keys`, in any case. */
|
|
407
|
+
const hasAll = (text: string, keys: readonly string[]): boolean =>
|
|
408
|
+
keys.every((k) => text.toLowerCase().includes(k.toLowerCase()));
|
|
409
|
+
|
|
410
|
+
/** `text` as a literal inside a regular expression. */
|
|
411
|
+
const literal = (text: string): string =>
|
|
412
|
+
text.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Whether a question names the folder `folder` and not a longer path that starts with it
|
|
416
|
+
* (`/w/paper` is not `/w/paper2` or `/w/paper.old`), nor one it ends: the whole path, case-sensitive.
|
|
417
|
+
*/
|
|
418
|
+
const namesFolder = (question: string, folder: string): boolean =>
|
|
419
|
+
new RegExp(`(?<![\\w.-])${literal(folder)}(?![\\w.-])`, "u").test(question);
|
|
420
|
+
|
|
421
|
+
/** A question a yes must have been given to: what a send or a portal command needs it to name. */
|
|
422
|
+
type Needs = (question: string) => boolean;
|
|
423
|
+
|
|
424
|
+
/** The answers already used up by an earlier need of the same command, by index; null once one lacked its yes. */
|
|
425
|
+
type Taken = ReadonlySet<number> | null;
|
|
426
|
+
|
|
242
427
|
/**
|
|
243
|
-
*
|
|
244
|
-
*
|
|
245
|
-
* guessed at: such a command is described as unresolved.
|
|
428
|
+
* The answers used up after one more need: it takes the latest answer not yet taken whose question
|
|
429
|
+
* `names` it, and that answer must be a yes; null when there is none, or it is not.
|
|
246
430
|
*/
|
|
247
|
-
|
|
431
|
+
function takeYes(
|
|
432
|
+
answers: readonly Answer[],
|
|
433
|
+
taken: Taken,
|
|
434
|
+
names: Needs,
|
|
435
|
+
): Taken {
|
|
436
|
+
if (taken === null) return null;
|
|
437
|
+
const at = answers.findLastIndex(
|
|
438
|
+
(a, n) => !taken.has(n) && names(a.question),
|
|
439
|
+
);
|
|
440
|
+
const found = answers[at];
|
|
441
|
+
return found !== undefined && isYes(found) ? new Set([...taken, at]) : null;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/** What a yes to one send must name: its sha256 (any case) and its folder (the whole path, as written). */
|
|
445
|
+
const namesSend =
|
|
446
|
+
(s: Named): Needs =>
|
|
447
|
+
(question) =>
|
|
448
|
+
hasAll(question, [s.sha]) && namesFolder(question, s.folder);
|
|
449
|
+
|
|
450
|
+
/** What a yes to a command that names a portal must name: the host (any case) and the command's start, literally. */
|
|
451
|
+
const namesPortal =
|
|
452
|
+
(host: string, start: string): Needs =>
|
|
453
|
+
(question) =>
|
|
454
|
+
hasAll(question, [host]) && question.includes(start);
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Whether every send and the portal command (null for none) has a yes of its own. Each takes the
|
|
458
|
+
* latest answer not yet taken, in turn, from one shared set: a yes used up by a send cannot also
|
|
459
|
+
* approve the portal, one that names two shas covers one send, and the same PDF sent twice needs
|
|
460
|
+
* two. (A yes that two needs could share is given to the first; the second is refused and the
|
|
461
|
+
* person is asked again: it fails closed.)
|
|
462
|
+
*/
|
|
463
|
+
const everyNeedHasItsYes = (
|
|
464
|
+
answers: readonly Answer[],
|
|
465
|
+
sends: readonly Named[],
|
|
466
|
+
portal: Needs | null,
|
|
467
|
+
): boolean =>
|
|
468
|
+
[...sends.map(namesSend), ...(portal === null ? [] : [portal])].reduce(
|
|
469
|
+
(taken: Taken, names) => takeYes(answers, taken, names),
|
|
470
|
+
new Set<number>(),
|
|
471
|
+
) !== null;
|
|
472
|
+
|
|
473
|
+
/** Every send in the command, described; an unresolved one has no sha. */
|
|
474
|
+
function describedSends(
|
|
248
475
|
command: string,
|
|
249
|
-
leaves: readonly ShellLeaf[],
|
|
250
476
|
cwd: string,
|
|
251
477
|
deps: ConfirmDeps,
|
|
252
|
-
):
|
|
253
|
-
const sends = /[`$]/u.test(command)
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
/** The
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
478
|
+
): readonly Described[] {
|
|
479
|
+
const sends = /[`$]/u.test(command)
|
|
480
|
+
? []
|
|
481
|
+
: sendsIn(deps.shellLeaves(command), deps.readSend);
|
|
482
|
+
return (sends.length === 0 ? [null] : sends).map((x): Described =>
|
|
483
|
+
x === null ? { named: null, lines: [UNRESOLVED] } : describe(x, cwd, deps),
|
|
484
|
+
);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/** The refusal of a send whose PDF is not known or not there: there is no sha256 to ask about. */
|
|
488
|
+
const noPdf = (described: readonly Described[]): readonly string[] => [
|
|
489
|
+
`${NOT_SENT}, and paperlint cannot find the PDF it uploads, so there is no sha256 to ask the person about.`,
|
|
490
|
+
...blocks(described),
|
|
491
|
+
"Name the paper folder and the PDF plainly (no cd, no $…), build the PDF, run the dry run, and ask then.",
|
|
492
|
+
];
|
|
493
|
+
|
|
494
|
+
/** The portal hosts the venue presets declare, or — when they cannot be read — none, and why. */
|
|
495
|
+
interface Hosts {
|
|
496
|
+
readonly hosts: readonly string[];
|
|
497
|
+
readonly failed: string | null;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/** The presets' hosts; when they cannot be read, none, and why. */
|
|
501
|
+
function readHosts(cwd: string, deps: ConfirmDeps): Hosts {
|
|
502
|
+
try {
|
|
503
|
+
return { hosts: deps.portalHosts(cwd), failed: null };
|
|
504
|
+
} catch (e) {
|
|
505
|
+
return { hosts: [], failed: messageOf(e) };
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/** What one command is: whether it sends through paperlint, the portal host it names, the hosts known. */
|
|
510
|
+
interface Command {
|
|
511
|
+
readonly text: string;
|
|
512
|
+
readonly cwd: string;
|
|
513
|
+
readonly paperlint: boolean;
|
|
514
|
+
readonly host: string | null;
|
|
515
|
+
readonly hosts: Hosts;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/** The line a refusal ends with when the presets could not be read; none when they could. */
|
|
519
|
+
const fallbackLines = (h: Hosts): readonly string[] =>
|
|
520
|
+
h.failed === null
|
|
521
|
+
? []
|
|
522
|
+
: [
|
|
523
|
+
`The venue presets could not be read (${h.failed}), so only HotCRP-hosted sites and paperlint sends are recognised until they are fixed.`,
|
|
524
|
+
];
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* A command that sends: let through (null) when the person said yes to it since the last send —
|
|
528
|
+
* each paperlint send by its paper folder and its PDF's sha256, a yes of its own for each, a portal
|
|
529
|
+
* named by its host and the command's start —
|
|
530
|
+
* else the lines of the refusal, with what to ask.
|
|
531
|
+
*/
|
|
532
|
+
function decide(
|
|
533
|
+
c: Command,
|
|
534
|
+
payload: unknown,
|
|
535
|
+
deps: ConfirmDeps,
|
|
536
|
+
): readonly string[] | null {
|
|
537
|
+
const described = c.paperlint ? describedSends(c.text, c.cwd, deps) : [];
|
|
538
|
+
const named = described.flatMap((d) => (d.named === null ? [] : [d.named]));
|
|
539
|
+
if (named.length < described.length) return noPdf(described);
|
|
540
|
+
const base = c.paperlint ? NOT_SENT : NAMES_PORTAL;
|
|
541
|
+
const read = transcriptOf(payload, deps.files);
|
|
542
|
+
if (typeof read !== "string")
|
|
543
|
+
return [
|
|
544
|
+
`${base}, and ${read.failed}, so paperlint cannot see the person's yes.`,
|
|
545
|
+
];
|
|
546
|
+
const start = c.text.trim().slice(0, COMMAND_SHOWN);
|
|
547
|
+
const answers = answersSinceLastSend(read, earlierSend(c, payload, deps));
|
|
548
|
+
const portal = c.host === null ? null : namesPortal(c.host, start);
|
|
549
|
+
if (everyNeedHasItsYes(answers, named, portal)) return null;
|
|
550
|
+
return [
|
|
551
|
+
`${base}, and the person has not said yes to it since the last send.`,
|
|
552
|
+
...blocks(described),
|
|
553
|
+
...(c.paperlint ? [HOW_TO_ASK] : []),
|
|
554
|
+
...portalLines(c.host, start),
|
|
555
|
+
];
|
|
556
|
+
}
|
|
272
557
|
|
|
273
|
-
|
|
274
|
-
|
|
558
|
+
/** What a refusal says about a portal the command names: the host, the start, what to ask; none for none. */
|
|
559
|
+
const portalLines = (host: string | null, start: string): readonly string[] =>
|
|
560
|
+
host === null
|
|
561
|
+
? []
|
|
562
|
+
: [`portal host: ${host}`, `command start: ${start}`, HOW_TO_ASK_PORTAL];
|
|
275
563
|
|
|
276
|
-
|
|
564
|
+
/** How an earlier send is told in the transcript: by the same matching as this command. */
|
|
565
|
+
function earlierSend(
|
|
566
|
+
c: Command,
|
|
567
|
+
payload: unknown,
|
|
568
|
+
deps: ConfirmDeps,
|
|
569
|
+
): EarlierSend {
|
|
570
|
+
const known = knownHosts(c.hosts.hosts);
|
|
571
|
+
return {
|
|
572
|
+
current: stringField(payload, "tool_use_id"),
|
|
573
|
+
mayHold: (text) => {
|
|
574
|
+
const plain = text.replace(/["'\\]/gu, "").toLowerCase();
|
|
575
|
+
return (
|
|
576
|
+
plain.includes("submission") || known.some((h) => plain.includes(h))
|
|
577
|
+
);
|
|
578
|
+
},
|
|
579
|
+
sends: (command) => {
|
|
580
|
+
const leaves = allLeaves(command, deps.shellLeaves);
|
|
581
|
+
return leaves.some(isSend) || portalHost(leaves, known) !== null;
|
|
582
|
+
},
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/** A string field of the payload; null for none. */
|
|
587
|
+
function stringField(payload: unknown, key: string): string | null {
|
|
588
|
+
const v = fieldOf(payload, key);
|
|
589
|
+
return typeof v === "string" ? v : null;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/** The session's transcript as text, or why it could not be read. */
|
|
593
|
+
function transcriptOf(
|
|
594
|
+
payload: unknown,
|
|
595
|
+
files: Files,
|
|
596
|
+
): string | { readonly failed: string } {
|
|
597
|
+
const path = stringField(payload, "transcript_path");
|
|
598
|
+
if (path === null || !isAbsolute(path))
|
|
599
|
+
return { failed: "the hook was given no transcript_path" };
|
|
600
|
+
const bytes = files.readBytes(callerPath(path));
|
|
601
|
+
return bytes === null
|
|
602
|
+
? { failed: `the transcript ${path} could not be read` }
|
|
603
|
+
: new TextDecoder().decode(bytes);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/** The refusal when the hook's input cannot be read at all: it cannot tell, so it does not let it run. */
|
|
607
|
+
export const UNREADABLE_INPUT: DenyOutput = deny(
|
|
608
|
+
"Not run: paperlint could not read this command, so it cannot tell whether it sends a paper to a venue's portal.",
|
|
609
|
+
);
|
|
277
610
|
|
|
278
611
|
/** The payload, parsed; null when it is not JSON. */
|
|
279
612
|
function parsed(stdin: string): unknown {
|
|
@@ -284,47 +617,46 @@ function parsed(stdin: string): unknown {
|
|
|
284
617
|
}
|
|
285
618
|
}
|
|
286
619
|
|
|
620
|
+
/** One command, judged: null to let it run, or the refusal. */
|
|
621
|
+
function judge(
|
|
622
|
+
text: string,
|
|
623
|
+
cwd: string,
|
|
624
|
+
payload: unknown,
|
|
625
|
+
deps: ConfirmDeps,
|
|
626
|
+
): DenyOutput | null {
|
|
627
|
+
const leaves = allLeaves(text, deps.shellLeaves);
|
|
628
|
+
const paperlint = leaves.some(isSend);
|
|
629
|
+
// The presets are read only for a command that may send: anything else pays nothing.
|
|
630
|
+
if (!paperlint && dottedWords(leaves).length === 0) return null;
|
|
631
|
+
const hosts = readHosts(cwd, deps);
|
|
632
|
+
const host = portalHost(leaves, hosts.hosts);
|
|
633
|
+
if (!paperlint && host === null) return null;
|
|
634
|
+
const lines = decide({ text, cwd, paperlint, host, hosts }, payload, deps);
|
|
635
|
+
return lines === null
|
|
636
|
+
? null
|
|
637
|
+
: deny([...lines, ...fallbackLines(hosts)].join("\n"));
|
|
638
|
+
}
|
|
639
|
+
|
|
287
640
|
/**
|
|
288
|
-
* The hook on one payload, as Claude Code sends it on stdin:
|
|
289
|
-
* sends nothing
|
|
641
|
+
* The hook on one payload, as Claude Code sends it on stdin: a refusal, or null to let the command
|
|
642
|
+
* run (it sends nothing, or every send in it has the person's yes). Never throws: a crash refuses.
|
|
290
643
|
*/
|
|
291
644
|
export function submissionConfirm(
|
|
292
645
|
stdin: string,
|
|
293
646
|
deps: ConfirmDeps,
|
|
294
|
-
):
|
|
647
|
+
): DenyOutput | null {
|
|
295
648
|
const payload = parsed(stdin);
|
|
296
649
|
if (payload === null) return UNREADABLE_INPUT;
|
|
297
650
|
const raw = fieldOf(fieldOf(payload, "tool_input"), "command");
|
|
298
|
-
const
|
|
651
|
+
const text = typeof raw === "string" ? raw : "";
|
|
299
652
|
const given = fieldOf(payload, "cwd");
|
|
300
653
|
const cwd =
|
|
301
654
|
typeof given === "string" && isAbsolute(given) ? given : resolve(".");
|
|
302
|
-
const kind = kindOf(command, cwd, deps);
|
|
303
|
-
if (kind === null) return null;
|
|
304
|
-
if (kind !== "paperlint" && kind !== "direct") return ask(kind.failed);
|
|
305
|
-
if (kind === "direct") return ask(DIRECT);
|
|
306
655
|
try {
|
|
307
|
-
return
|
|
656
|
+
return judge(text, cwd, payload, deps);
|
|
308
657
|
} catch (e) {
|
|
309
|
-
return
|
|
310
|
-
|
|
311
|
-
);
|
|
312
|
-
}
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
/** What the command sends, or — when reading it failed — the reason to ask anyway. */
|
|
316
|
-
function kindOf(
|
|
317
|
-
command: string,
|
|
318
|
-
cwd: string,
|
|
319
|
-
deps: ConfirmDeps,
|
|
320
|
-
): ReturnType<typeof sendKind> | { readonly failed: string } {
|
|
321
|
-
try {
|
|
322
|
-
return sendKind(allLeaves(command, deps.shellLeaves), () =>
|
|
323
|
-
deps.portalHosts(cwd),
|
|
658
|
+
return deny(
|
|
659
|
+
`Not run: paperlint could not check whether this command sends a paper: ${messageOf(e)}.`,
|
|
324
660
|
);
|
|
325
|
-
} catch (e) {
|
|
326
|
-
return {
|
|
327
|
-
failed: `paperlint could not tell what this command sends (${messageOf(e)}). ${LAST}`,
|
|
328
|
-
};
|
|
329
661
|
}
|
|
330
662
|
}
|