second-read 0.0.0-stage → 0.1.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/shell.js ADDED
@@ -0,0 +1,396 @@
1
+ // A bash command line, split into words the way bash would split it, far enough to find what a command posts. Text
2
+ // the shell fills in only when it runs, such as $BODY or $(date), is kept as its source between UNKNOWN and
3
+ // UNKNOWN_END, so a check can refuse what it can't read.
4
+ export const UNKNOWN = "\u{E000}";
5
+ export const UNKNOWN_END = "\u{E001}";
6
+ // Commands in the order they'd run, with subshells, lists and pipes flattened, and the commands inside $(…) and
7
+ // backticks ahead of the one they're in. A keyword such as `if` or `do` in front of a command is dropped.
8
+ export function parseShell(source) {
9
+ return new Parser(source).list(false).map(({ words, stdin, writes }) => ({
10
+ words,
11
+ ...(stdin ? { stdin } : {}),
12
+ ...(writes ? { writes } : {}),
13
+ }));
14
+ }
15
+ // The text a command prints, when it's one that only prints what it's given: cat or tee of its stdin, echo, or
16
+ // printf with a plain format.
17
+ export function output(command) {
18
+ const [name, ...args] = command.words;
19
+ if ((name === "cat" && args.every((a) => a === "-")) || name === "tee") {
20
+ return command.stdin && "text" in command.stdin ? command.stdin.text : undefined;
21
+ }
22
+ if (name === "echo") {
23
+ let newline = true;
24
+ let escapes = false;
25
+ while (args.length > 0 && /^-[neE]+$/.test(args[0])) {
26
+ const flags = args.shift();
27
+ if (flags.includes("n"))
28
+ newline = false;
29
+ if (flags.includes("e"))
30
+ escapes = true;
31
+ if (flags.includes("E"))
32
+ escapes = false;
33
+ }
34
+ const text = args.join(" ");
35
+ return (escapes ? unescape(text) : text) + (newline ? "\n" : "");
36
+ }
37
+ if (name === "printf") {
38
+ const [format, ...rest] = args;
39
+ if (format === undefined)
40
+ return undefined;
41
+ if (rest.length === 0 && !format.includes("%"))
42
+ return unescape(format);
43
+ if (rest.length === 1 && (format === "%s" || format === "%s\\n"))
44
+ return rest[0] + (format === "%s" ? "" : "\n");
45
+ }
46
+ return undefined;
47
+ }
48
+ export function unknown(source) {
49
+ return UNKNOWN + source + UNKNOWN_END;
50
+ }
51
+ const META = new Set([" ", "\t", "\n", ";", "&", "|", "<", ">", "(", ")"]);
52
+ const LEADING_KEYWORDS = new Set(["if", "then", "else", "elif", "do", "while", "until", "!", "{", "time"]);
53
+ const CLOSING_KEYWORDS = new Set(["fi", "done", "esac", "}"]);
54
+ const REDIRECT = /^(\d*)(<<<|<<-|<<|<>|<&|<|>>|>&|>\||>|&>>|&>)/;
55
+ class Parser {
56
+ i = 0;
57
+ pending = [];
58
+ // The commands of substitutions read since the last command ended, which run ahead of the next.
59
+ substituted = [];
60
+ s;
61
+ constructor(source) {
62
+ this.s = source;
63
+ }
64
+ // Commands up to the end, or up to the ")" that closes a $( or ( when `nested`.
65
+ list(nested) {
66
+ const s = this.s;
67
+ const out = [];
68
+ let current = { words: [] };
69
+ const finish = () => {
70
+ out.push(...this.substituted.splice(0));
71
+ const words = current.words;
72
+ while (words.length > 0 && LEADING_KEYWORDS.has(words[0]))
73
+ words.shift();
74
+ if (words.every((w) => CLOSING_KEYWORDS.has(w)))
75
+ words.length = 0;
76
+ if (words.length > 0 || current.stdin)
77
+ out.push(current);
78
+ current = { words: [] };
79
+ };
80
+ while (this.i < s.length) {
81
+ const c = s[this.i];
82
+ if (c === " " || c === "\t") {
83
+ this.i++;
84
+ }
85
+ else if (c === "\\" && s[this.i + 1] === "\n") {
86
+ this.i += 2;
87
+ }
88
+ else if (c === "\n") {
89
+ this.i++;
90
+ finish();
91
+ this.heredocs();
92
+ }
93
+ else if (c === "#") {
94
+ while (this.i < s.length && s[this.i] !== "\n")
95
+ this.i++;
96
+ }
97
+ else if (c === ")") {
98
+ if (nested)
99
+ break;
100
+ this.i++;
101
+ }
102
+ else if (c === "(") {
103
+ if (s.startsWith("((", this.i)) {
104
+ this.skipParens();
105
+ }
106
+ else if (current.words.length === 0) {
107
+ this.i++;
108
+ const inner = this.list(true);
109
+ this.i++;
110
+ out.push(...inner);
111
+ }
112
+ else {
113
+ this.i++;
114
+ }
115
+ }
116
+ else if ((c === "<" || c === ">") && s[this.i + 1] === "(") {
117
+ current.words.push(this.processSubstitution());
118
+ }
119
+ else if (REDIRECT.test(s.slice(this.i, this.i + 8))) {
120
+ this.redirect(current);
121
+ }
122
+ else if (c === ";" || c === "&" || c === "|") {
123
+ const op = /^(&&|\|\||;;|\|&|[;&|])/.exec(s.slice(this.i, this.i + 2))[0];
124
+ this.i += op.length;
125
+ finish();
126
+ const last = out[out.length - 1];
127
+ if ((op === "|" || op === "|&") && last)
128
+ current.pipedFrom = last;
129
+ }
130
+ else {
131
+ current.words.push(this.word().text);
132
+ }
133
+ }
134
+ finish();
135
+ for (const draft of out) {
136
+ if (draft.pipedFrom === undefined || draft.stdin)
137
+ continue;
138
+ const from = draft.pipedFrom;
139
+ draft.stdin = { text: output(from) ?? unknown(`the output of ${from.words.join(" ")}`) };
140
+ }
141
+ return out;
142
+ }
143
+ // A redirection, with any file descriptor in front of it, as in 2>&1. A heredoc's body is read when its line ends.
144
+ redirect(current) {
145
+ const m = REDIRECT.exec(this.s.slice(this.i, this.i + 8));
146
+ const fd = m[1];
147
+ const op = m[2];
148
+ this.i += m[0].length;
149
+ while (this.s[this.i] === " " || this.s[this.i] === "\t")
150
+ this.i++;
151
+ const target = this.word();
152
+ if (op === "<<" || op === "<<-") {
153
+ this.pending.push({ delimiter: target.text, quoted: target.quoted, stripTabs: op === "<<-", draft: current });
154
+ }
155
+ else if (op === "<<<") {
156
+ current.stdin = { text: target.text + "\n" };
157
+ }
158
+ else if (op === "<" && (fd === "" || fd === "0")) {
159
+ current.stdin = { file: target.text };
160
+ }
161
+ else if ((op === ">" || op === ">|" || op === ">>") && (fd === "" || fd === "1")) {
162
+ (current.writes ??= []).push({ file: target.text, append: op === ">>" });
163
+ }
164
+ else if (op === "&>" || op === "&>>") {
165
+ (current.writes ??= []).push({ file: target.text, append: op === "&>>" });
166
+ }
167
+ }
168
+ // The bodies of the heredocs the line just ended opened, in order.
169
+ heredocs() {
170
+ const s = this.s;
171
+ for (const heredoc of this.pending) {
172
+ const lines = [];
173
+ while (this.i < s.length) {
174
+ const end = s.indexOf("\n", this.i);
175
+ const stop = end < 0 ? s.length : end;
176
+ let line = s.slice(this.i, stop);
177
+ this.i = stop + 1;
178
+ if (heredoc.stripTabs)
179
+ line = line.replace(/^\t+/, "");
180
+ if (line === heredoc.delimiter)
181
+ break;
182
+ lines.push(line + "\n");
183
+ }
184
+ const body = lines.join("");
185
+ if (heredoc.quoted) {
186
+ heredoc.draft.stdin = { text: body };
187
+ }
188
+ else {
189
+ const expanded = new Parser(body);
190
+ heredoc.draft.stdin = { text: expanded.doubleQuoted(true) };
191
+ this.substituted.push(...expanded.substituted);
192
+ }
193
+ }
194
+ this.pending = [];
195
+ }
196
+ word() {
197
+ const s = this.s;
198
+ let text = "";
199
+ let quoted = false;
200
+ while (this.i < s.length) {
201
+ const c = s[this.i];
202
+ if (META.has(c))
203
+ break;
204
+ if (c === "\\") {
205
+ const next = s[this.i + 1];
206
+ this.i += 2;
207
+ if (next !== undefined && next !== "\n")
208
+ text += next;
209
+ quoted = true;
210
+ }
211
+ else if (c === "'") {
212
+ const end = s.indexOf("'", this.i + 1);
213
+ const stop = end < 0 ? s.length : end;
214
+ text += s.slice(this.i + 1, stop);
215
+ this.i = stop + 1;
216
+ quoted = true;
217
+ }
218
+ else if (c === '"') {
219
+ this.i++;
220
+ text += this.doubleQuoted(false);
221
+ quoted = true;
222
+ }
223
+ else if (c === "$" && s[this.i + 1] === "'") {
224
+ text += this.ansiC();
225
+ quoted = true;
226
+ }
227
+ else if (c === "$" && s[this.i + 1] === '"') {
228
+ this.i++;
229
+ }
230
+ else if (c === "$") {
231
+ text += this.dollar();
232
+ }
233
+ else if (c === "`") {
234
+ text += this.backtick();
235
+ }
236
+ else {
237
+ text += c;
238
+ this.i++;
239
+ }
240
+ }
241
+ return { text, quoted };
242
+ }
243
+ // The inside of "…", up to and past its closing quote. A heredoc's body reads the same way, to its end, with
244
+ // quotes in it kept as they are.
245
+ doubleQuoted(heredoc) {
246
+ const s = this.s;
247
+ const escapable = heredoc ? "$`\\" : '$`"\\';
248
+ let text = "";
249
+ while (this.i < s.length) {
250
+ const c = s[this.i];
251
+ if (c === '"' && !heredoc) {
252
+ this.i++;
253
+ return text;
254
+ }
255
+ if (c === "\\" && s[this.i + 1] === "\n") {
256
+ this.i += 2;
257
+ }
258
+ else if (c === "\\" && escapable.includes(s[this.i + 1] ?? "")) {
259
+ text += s[this.i + 1];
260
+ this.i += 2;
261
+ }
262
+ else if (c === "$") {
263
+ text += this.dollar();
264
+ }
265
+ else if (c === "`") {
266
+ text += this.backtick();
267
+ }
268
+ else {
269
+ text += c;
270
+ this.i++;
271
+ }
272
+ }
273
+ return text;
274
+ }
275
+ // $name, ${…}, $((…)) and $(…). A command substitution is read when its command only prints what it's given, as
276
+ // in "$(cat <<'EOF' … EOF)", and shell strips the newlines it ends with.
277
+ dollar() {
278
+ const s = this.s;
279
+ const start = this.i;
280
+ const next = s[this.i + 1] ?? "";
281
+ if (s.startsWith("$((", this.i)) {
282
+ this.i++;
283
+ this.skipParens();
284
+ return unknown(s.slice(start, this.i));
285
+ }
286
+ if (next === "(") {
287
+ this.i += 2;
288
+ const inner = this.inner(() => this.list(true));
289
+ if (s[this.i] === ")")
290
+ this.i++;
291
+ const text = inner.length === 1 ? output(inner[0]) : undefined;
292
+ return text === undefined ? unknown(s.slice(start, this.i)) : text.replace(/\n+$/, "");
293
+ }
294
+ if (next === "{") {
295
+ let depth = 0;
296
+ for (this.i++; this.i < s.length; this.i++) {
297
+ if (s[this.i] === "{")
298
+ depth++;
299
+ if (s[this.i] === "}" && --depth === 0)
300
+ break;
301
+ }
302
+ this.i++;
303
+ return unknown(s.slice(start, this.i));
304
+ }
305
+ if (/[A-Za-z_]/.test(next)) {
306
+ this.i++;
307
+ while (/[A-Za-z0-9_]/.test(s[this.i] ?? ""))
308
+ this.i++;
309
+ return unknown(s.slice(start, this.i));
310
+ }
311
+ if (/[0-9@*#?$!-]/.test(next)) {
312
+ this.i += 2;
313
+ return unknown(s.slice(start, this.i));
314
+ }
315
+ this.i++;
316
+ return "$";
317
+ }
318
+ // `…`, whose command is read with the backslashes before $, ` and \ taken out.
319
+ backtick() {
320
+ const s = this.s;
321
+ const start = this.i;
322
+ let source = "";
323
+ for (this.i++; this.i < s.length && s[this.i] !== "`"; this.i++) {
324
+ if (s[this.i] === "\\" && "$`\\".includes(s[this.i + 1] ?? ""))
325
+ this.i++;
326
+ source += s[this.i] ?? "";
327
+ }
328
+ this.i++;
329
+ this.inner(() => new Parser(source).list(false));
330
+ return unknown(s.slice(start, this.i));
331
+ }
332
+ processSubstitution() {
333
+ const start = this.i;
334
+ this.i += 2;
335
+ this.inner(() => this.list(true));
336
+ this.i++;
337
+ return unknown(this.s.slice(start, this.i));
338
+ }
339
+ // The commands a substitution runs, kept to run ahead of the command it's in.
340
+ inner(read) {
341
+ const outer = this.substituted;
342
+ this.substituted = [];
343
+ const commands = read();
344
+ this.substituted = outer;
345
+ outer.push(...commands);
346
+ return commands;
347
+ }
348
+ // From an opening "(" past the ")" that closes it.
349
+ skipParens() {
350
+ let depth = 0;
351
+ for (; this.i < this.s.length; this.i++) {
352
+ if (this.s[this.i] === "(")
353
+ depth++;
354
+ if (this.s[this.i] === ")" && --depth === 0)
355
+ break;
356
+ }
357
+ this.i++;
358
+ }
359
+ // $'…', with its backslash escapes.
360
+ ansiC() {
361
+ const s = this.s;
362
+ let raw = "";
363
+ for (this.i += 2; this.i < s.length && s[this.i] !== "'"; this.i++) {
364
+ if (s[this.i] === "\\")
365
+ raw += s[this.i++];
366
+ raw += s[this.i] ?? "";
367
+ }
368
+ this.i++;
369
+ return unescape(raw);
370
+ }
371
+ }
372
+ const ESCAPES = {
373
+ n: "\n",
374
+ t: "\t",
375
+ r: "\r",
376
+ a: "\x07",
377
+ b: "\b",
378
+ e: "\x1b",
379
+ E: "\x1b",
380
+ f: "\f",
381
+ v: "\v",
382
+ "\\": "\\",
383
+ "'": "'",
384
+ '"': '"',
385
+ "?": "?",
386
+ };
387
+ // The backslash escapes of $'…', echo -e and printf.
388
+ function unescape(text) {
389
+ return text.replace(/\\(x[0-9A-Fa-f]{1,2}|u[0-9A-Fa-f]{1,4}|U[0-9A-Fa-f]{1,8}|0?[0-7]{1,3}|.)/gs, (all, code) => {
390
+ if (/^[xuU]/.test(code))
391
+ return String.fromCodePoint(parseInt(code.slice(1), 16));
392
+ if (/^[0-7]/.test(code))
393
+ return String.fromCodePoint(parseInt(code, 8));
394
+ return ESCAPES[code] ?? all;
395
+ });
396
+ }
package/dist/toml.js ADDED
@@ -0,0 +1,170 @@
1
+ // The small part of TOML the config files need: top-level keys set to strings, booleans or arrays of strings,
2
+ // with comments. Anything else is an error, so a file never means something other than what it says.
3
+ export class TomlError extends Error {
4
+ line;
5
+ constructor(message, line) {
6
+ super(`line ${line}: ${message}`);
7
+ this.line = line;
8
+ }
9
+ }
10
+ export function parseToml(source) {
11
+ return new Parser(source).document();
12
+ }
13
+ class Parser {
14
+ s;
15
+ i = 0;
16
+ line = 1;
17
+ constructor(source) {
18
+ this.s = source.replace(/^/, "");
19
+ }
20
+ document() {
21
+ const out = Object.create(null);
22
+ for (;;) {
23
+ this.skipBlankLines();
24
+ if (this.done())
25
+ return out;
26
+ if (this.peek() === "[")
27
+ throw this.error("tables aren't supported; put every key at the top level");
28
+ const keyLine = this.line;
29
+ const key = this.key();
30
+ this.skipSpace();
31
+ this.expect("=");
32
+ this.skipSpace();
33
+ const value = this.value();
34
+ if (Object.hasOwn(out, key))
35
+ throw new TomlError(`"${key}" is set more than once`, keyLine);
36
+ out[key] = value;
37
+ this.skipSpace();
38
+ this.skipComment();
39
+ if (!this.done() && !this.newline())
40
+ throw this.error("expected the end of the line");
41
+ }
42
+ }
43
+ key() {
44
+ if (this.peek() === '"' || this.peek() === "'")
45
+ return this.string();
46
+ const m = /^[A-Za-z0-9_-]+/.exec(this.s.slice(this.i));
47
+ if (!m)
48
+ throw this.error("expected a key");
49
+ this.i += m[0].length;
50
+ return m[0];
51
+ }
52
+ value() {
53
+ const c = this.peek();
54
+ if (c === '"' || c === "'")
55
+ return this.string();
56
+ if (c === "[")
57
+ return this.array();
58
+ for (const word of ["true", "false"]) {
59
+ if (this.s.startsWith(word, this.i) && !/[A-Za-z0-9_-]/.test(this.s[this.i + word.length] ?? "")) {
60
+ this.i += word.length;
61
+ return word === "true";
62
+ }
63
+ }
64
+ throw this.error("expected a string, a boolean or an array of strings");
65
+ }
66
+ array() {
67
+ this.expect("[");
68
+ const items = [];
69
+ for (;;) {
70
+ this.skipBlankLines();
71
+ if (this.peek() === "]")
72
+ break;
73
+ const c = this.peek();
74
+ if (c !== '"' && c !== "'")
75
+ throw this.error("arrays may hold only strings");
76
+ items.push(this.string());
77
+ this.skipBlankLines();
78
+ if (this.peek() === ",") {
79
+ this.i++;
80
+ continue;
81
+ }
82
+ if (this.peek() !== "]")
83
+ throw this.error('expected "," or "]"');
84
+ }
85
+ this.expect("]");
86
+ return items;
87
+ }
88
+ string() {
89
+ const quote = this.peek();
90
+ if (this.s.startsWith(quote.repeat(3), this.i))
91
+ throw this.error("multi-line strings aren't supported");
92
+ this.i++;
93
+ let out = "";
94
+ for (;;) {
95
+ if (this.done())
96
+ throw this.error("unterminated string");
97
+ const c = this.s[this.i];
98
+ if (c === "\n" || c === "\r")
99
+ throw this.error("unterminated string");
100
+ this.i++;
101
+ if (c === quote)
102
+ return out;
103
+ if (c === "\\" && quote === '"') {
104
+ out += this.escape();
105
+ continue;
106
+ }
107
+ out += c;
108
+ }
109
+ }
110
+ escape() {
111
+ const c = this.s[this.i++];
112
+ const simple = { '"': '"', "\\": "\\", b: "\b", f: "\f", n: "\n", r: "\r", t: "\t" };
113
+ if (c !== undefined && Object.hasOwn(simple, c))
114
+ return simple[c];
115
+ if (c === "u" || c === "U") {
116
+ const length = c === "u" ? 4 : 8;
117
+ const hex = this.s.slice(this.i, this.i + length);
118
+ if (!new RegExp(`^[0-9A-Fa-f]{${length}}$`).test(hex))
119
+ throw this.error("bad unicode escape");
120
+ this.i += length;
121
+ const code = parseInt(hex, 16);
122
+ if (code > 0x10ffff || (code >= 0xd800 && code <= 0xdfff))
123
+ throw this.error("bad unicode escape");
124
+ return String.fromCodePoint(code);
125
+ }
126
+ throw this.error("unknown escape");
127
+ }
128
+ skipSpace() {
129
+ while (this.peek() === " " || this.peek() === "\t")
130
+ this.i++;
131
+ }
132
+ skipComment() {
133
+ if (this.peek() !== "#")
134
+ return;
135
+ while (!this.done() && this.peek() !== "\n" && this.peek() !== "\r")
136
+ this.i++;
137
+ }
138
+ skipBlankLines() {
139
+ for (;;) {
140
+ this.skipSpace();
141
+ this.skipComment();
142
+ if (!this.newline())
143
+ return;
144
+ }
145
+ }
146
+ newline() {
147
+ if (this.s.startsWith("\r\n", this.i))
148
+ this.i += 2;
149
+ else if (this.peek() === "\n")
150
+ this.i++;
151
+ else
152
+ return false;
153
+ this.line++;
154
+ return true;
155
+ }
156
+ expect(c) {
157
+ if (this.peek() !== c)
158
+ throw this.error(`expected "${c}"`);
159
+ this.i++;
160
+ }
161
+ peek() {
162
+ return this.s[this.i] ?? "";
163
+ }
164
+ done() {
165
+ return this.i >= this.s.length;
166
+ }
167
+ error(message) {
168
+ return new TomlError(message, this.line);
169
+ }
170
+ }
package/package.json CHANGED
@@ -1,6 +1,34 @@
1
1
  {
2
2
  "name": "second-read",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "A privacy check on agent-written commits, issues, PRs and comments, for any coding agent.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": {
8
+ "second-read": "dist/cli.js"
9
+ },
10
+ "files": [
11
+ "dist",
12
+ "!dist/**/*.test.*"
13
+ ],
14
+ "engines": {
15
+ "node": ">=22"
16
+ },
17
+ "scripts": {
18
+ "build": "tsc",
19
+ "test": "tsc && node --test \"src/**/*.test.ts\"",
20
+ "prepack": "tsc"
21
+ },
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "git+https://github.com/deanbot/second-read.git"
25
+ },
26
+ "bugs": {
27
+ "url": "https://github.com/deanbot/second-read/issues"
28
+ },
29
+ "homepage": "https://github.com/deanbot/second-read#readme",
30
+ "devDependencies": {
31
+ "@types/node": "^22.20.5",
32
+ "typescript": "^7.0.2"
33
+ }
34
+ }