@kaynooo/guard-tools 0.4.1 → 0.5.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 (2) hide show
  1. package/dist/cli.js +951 -146
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -2,10 +2,10 @@
2
2
  // @bun
3
3
 
4
4
  // src/cli.ts
5
- import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
5
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "fs";
6
6
  import { homedir } from "os";
7
7
  import { dirname } from "path";
8
- import process6 from "process";
8
+ import process7 from "process";
9
9
 
10
10
  // ../kit/src/args.ts
11
11
  import process3 from "process";
@@ -504,8 +504,103 @@ async function readInput(path, { noStdin = "nothing is piped on stdin" } = {}) {
504
504
  fail(`no file ${path}`);
505
505
  return file.text();
506
506
  }
507
- // ../kit/src/cli.ts
507
+ // ../kit/src/process.ts
508
+ import { constants } from "os";
508
509
  import process4 from "process";
510
+ var KILL_GRACE_MS = 2000;
511
+ function exitCodeOf(code, signal) {
512
+ if (code !== null)
513
+ return code;
514
+ if (typeof signal === "number")
515
+ return 128 + signal;
516
+ return signal === null ? 129 : 128 + constants.signals[signal];
517
+ }
518
+ async function runCommand(argv, { cwd, env, stdin = "ignore", timeoutMs, signal, maxBytes = Infinity, onChunk, onStart } = {}) {
519
+ const chunks = [];
520
+ const grouped = timeoutMs !== undefined || signal !== undefined || onStart !== undefined || onChunk !== undefined;
521
+ const proc = Bun.spawn(argv, { ...cwd === undefined ? {} : { cwd }, ...env === undefined ? {} : { env }, stdin, stdout: "pipe", stderr: "pipe", ...grouped ? { detached: true } : {} });
522
+ const state = { kept: 0, truncated: false, timedOut: false, failure: undefined, done: false };
523
+ const timers = [];
524
+ const killGroup = (killing) => {
525
+ if (state.done)
526
+ return;
527
+ try {
528
+ if (grouped)
529
+ process4.kill(-proc.pid, killing);
530
+ else
531
+ proc.kill(killing);
532
+ } catch {}
533
+ };
534
+ const stop = () => {
535
+ killGroup("SIGTERM");
536
+ timers.push(setTimeout(killGroup, KILL_GRACE_MS, "SIGKILL"));
537
+ };
538
+ const onAbort = () => stop();
539
+ if (timeoutMs !== undefined) {
540
+ timers.push(setTimeout(() => {
541
+ state.timedOut = true;
542
+ stop();
543
+ }, timeoutMs));
544
+ }
545
+ if (signal?.aborted === true)
546
+ onAbort();
547
+ else
548
+ signal?.addEventListener("abort", onAbort, { once: true });
549
+ onStart?.({ kill: (killing) => killGroup(killing) });
550
+ const drain = async (stream, name) => {
551
+ for await (const data of stream) {
552
+ if (state.failure !== undefined)
553
+ continue;
554
+ try {
555
+ onChunk?.({ stream: name, data });
556
+ } catch (error) {
557
+ state.failure = error;
558
+ stop();
559
+ continue;
560
+ }
561
+ if (state.kept + data.length > maxBytes) {
562
+ state.truncated = true;
563
+ continue;
564
+ }
565
+ state.kept += data.length;
566
+ chunks.push({ stream: name, data });
567
+ }
568
+ };
569
+ try {
570
+ await Promise.all([drain(proc.stdout, "stdout"), drain(proc.stderr, "stderr")]);
571
+ await proc.exited;
572
+ } finally {
573
+ state.done = true;
574
+ for (const timer of timers)
575
+ clearTimeout(timer);
576
+ signal?.removeEventListener("abort", onAbort);
577
+ }
578
+ if (state.failure !== undefined)
579
+ throw state.failure;
580
+ const joined = () => Buffer.concat(chunks.map(({ data }) => data));
581
+ let output;
582
+ let text;
583
+ return {
584
+ code: exitCodeOf(proc.exitCode, proc.signalCode),
585
+ chunks,
586
+ timedOut: state.timedOut,
587
+ truncated: state.truncated,
588
+ get output() {
589
+ return output ??= joined();
590
+ },
591
+ get text() {
592
+ return text ??= (output ??= joined()).toString();
593
+ }
594
+ };
595
+ }
596
+
597
+ // ../kit/src/progress.ts
598
+ var ESC = String.fromCharCode(27);
599
+ var ESCAPE_CODE = new RegExp(`${ESC}(?:\\[[\\x30-\\x3F]*[\\x20-\\x2F]*[\\x40-\\x7E]|\\][^\\u0007${ESC}]*(?:\\u0007|${ESC}\\\\))`, "y");
600
+ var RESET_STYLE = `${ESC}[0m`;
601
+ var CLOSE_LINK = `${ESC}]8;;${ESC}\\`;
602
+ // ../kit/src/cli.ts
603
+ import process5 from "process";
509
604
  var LEVEL_OPTIONS = {
510
605
  ...OUTPUT_OPTIONS,
511
606
  help: { type: "boolean", short: "h", description: "show this help" }
@@ -581,8 +676,8 @@ async function help({ path, header, summary: lazySummary, commands, defaultComma
581
676
  `);
582
677
  }
583
678
  function printError(prefix, message, hint, cause) {
584
- const colors = paint(process4.stderr);
585
- const styled = takesControlCodes(process4.stderr);
679
+ const colors = paint(process5.stderr);
680
+ const styled = takesControlCodes(process5.stderr);
586
681
  const head = styled ? `${colors.red.bold("error")} ${colors.dim(`${prefix}:`)}` : `error: ${prefix}:`;
587
682
  const under = (label, text, tint) => text === undefined ? "" : `
588
683
  ${styled ? tint(label) : `${label}:`} ${text}`;
@@ -615,11 +710,11 @@ async function dispatch(level, typed, { version, redact }) {
615
710
  }
616
711
  const [name, ...args] = argv;
617
712
  if (name === undefined && level.path.length > 1) {
618
- console.error(await help(await expand(level, [...level.path.slice(1), ...argv]), process4.stderr));
713
+ console.error(await help(await expand(level, [...level.path.slice(1), ...argv]), process5.stderr));
619
714
  return 2;
620
715
  }
621
716
  if (name === undefined || name === "help" || name === "--help" || name === "-h") {
622
- console.log(await help(await expand(level, [...level.path.slice(1), ...argv]), process4.stdout));
717
+ console.log(await help(await expand(level, [...level.path.slice(1), ...argv]), process5.stdout));
623
718
  return 0;
624
719
  }
625
720
  if (level.path.length === 1 && (name === "--version" || name === "-v")) {
@@ -692,49 +787,80 @@ async function printCandidates(found) {
692
787
  console.log(line);
693
788
  return 0;
694
789
  }
695
- async function runCli(cli, argv = process4.argv.slice(2)) {
790
+ async function runCli(cli, argv = process5.argv.slice(2)) {
696
791
  if (argv[0] === COMPLETE)
697
792
  return printCandidates(candidates(scopeOf(cli.commands, cli.extraCommands, cli.aliases), cli.defaultCommand, argv.slice(1)));
698
793
  return dispatch({ path: [cli.name], header: (colors, about) => `${colors.bold(cli.name)} ${colors.dim(cli.version)} ${about}`, summary: cli.about, ...scopeOf(cli.commands, cli.extraCommands, cli.aliases), defaultCommand: cli.defaultCommand, examples: cli.examples, replaces: cli.replaces }, argv, { version: cli.version, redact: cli.redact });
699
794
  }
700
- // ../kit/src/process.ts
701
- import { constants } from "os";
702
- async function collect(stream, name, chunks) {
703
- for await (const data of stream)
704
- chunks.push({ stream: name, data });
705
- }
706
- function exitCodeOf(code, signal) {
707
- if (code !== null)
708
- return code;
709
- if (typeof signal === "number")
710
- return 128 + signal;
711
- return signal === null ? 129 : 128 + constants.signals[signal];
712
- }
713
- async function runCommand(argv, { cwd, env, stdin = "ignore" } = {}) {
714
- const chunks = [];
715
- const proc = Bun.spawn(argv, { ...cwd === undefined ? {} : { cwd }, ...env === undefined ? {} : { env }, stdin, stdout: "pipe", stderr: "pipe" });
716
- await Promise.all([collect(proc.stdout, "stdout", chunks), collect(proc.stderr, "stderr", chunks)]);
717
- await proc.exited;
718
- const output = Buffer.concat(chunks.map(({ data }) => data));
719
- return { code: exitCodeOf(proc.exitCode, proc.signalCode), chunks, output, text: output.toString() };
720
- }
795
+ // ../kit/src/logs.ts
796
+ var DAY_MS = 24 * 60 * 60 * 1000;
797
+ // ../kit/src/secrets.ts
798
+ var SECRET_WORDS = new Set(["key", "pass", "pwd", "auth", "salt", "cookie", "session", "jwt", "ssn", "iban", "card", "cvv", "otp"]);
721
799
  // package.json
722
- var version = "0.4.1";
800
+ var version = "0.5.0";
723
801
 
724
802
  // ../kit/src/shell.ts
725
803
  var SEPARATORS = new Set([`
726
804
  `, ";", "|"]);
727
805
  var PLAIN_BREAKERS = new Set(["<", ">", "(", ")", "&", "|"]);
806
+ var GLOB_CHARS = new Set(["*", "?", "["]);
807
+ var COMMENT_PREFIXES = new Set([";", "&", "|", "(", ")"]);
728
808
  var WHITESPACE = /\s/;
729
809
  var ASSIGNMENT = /^\w+=/;
730
810
  var TRAILING_SLASHES = /\/+$/;
811
+ var TRAILING_CR = /\r$/;
731
812
  var LEADING_TILDE = /^~(?=\/|$)/;
732
813
  var LEADING_TABS = /^\t+/;
733
814
  var OPENERS = /^[({]+/;
734
815
  var CLOSERS = /[)}]+$/;
816
+ var CASE_LABEL = /^\(?[^\s()]*\)$/;
817
+ var REDIRECT_ONLY = /^\d*(?:<|>|>>|<&|>&|&>|>\||<>)$/;
818
+ var REDIRECT_START = /^\d*[<>]|^&>/;
819
+ var SAFE_WORD = /^[\w@%+=:,./-]+$/;
820
+ var LEADING_HEX = /^[\da-f]*/i;
735
821
  var DOUBLE_QUOTED_ESCAPES = new Set(["$", "`", '"', "\\"]);
736
822
  var HEREDOC = /^<<(-?)[ \t]*(?:'([^']*)'|"([^"]*)"|([^\s;&|<>()-][^\s;&|<>()]*))/;
737
- var WRAPPERS = new Set(["command", "env", "sudo", "exec", "nohup", "time"]);
823
+ var ANSI_QUOTE = "$'";
824
+ var ANSI_ESCAPES = { n: `
825
+ `, t: "\t", r: "\r", a: "\x07", b: "\b", f: "\f", v: "\v", e: "\x1B", E: "\x1B", "\\": "\\", "'": "'", '"': '"', "?": "?" };
826
+ var ANSI_DIGITS = { x: 2, u: 4, U: 8 };
827
+ var OCTAL = /^[0-7]{1,3}/;
828
+ var KEYWORDS = new Set(["then", "do", "else", "if", "elif", "while", "until", "!", "{", "}"]);
829
+ var MAX_DEPTH = 8;
830
+ var MAX_BRACES = 256;
831
+ var SHELLS = new Set(["sh", "bash", "zsh", "dash", "ash", "ksh"]);
832
+ var COMMAND_STRING_PROGRAMS = new Set([...SHELLS, "su", "runuser"]);
833
+ var SHELL_VALUE_OPTIONS = new Set(["-o", "-O", "--rcfile", "--init-file"]);
834
+ var PRINTERS = new Set(["echo", "printf"]);
835
+ var FIND_EXEC = new Set(["-exec", "-execdir", "-ok", "-okdir"]);
836
+ function wrapper(short = "", long = [], operands = 0) {
837
+ return { short, long, operands };
838
+ }
839
+ var WRAPPERS = new Map([
840
+ ["command", wrapper()],
841
+ ["builtin", wrapper()],
842
+ ["nohup", wrapper()],
843
+ ["setsid", wrapper()],
844
+ ["busybox", wrapper()],
845
+ ["exec", wrapper("a")],
846
+ ["time", wrapper("fo", ["--format", "--output"])],
847
+ ["env", wrapper("uCS", ["--unset", "--chdir", "--split-string", "--argv0"])],
848
+ ["sudo", wrapper("ughpCTrtUDR", ["--user", "--group", "--host", "--prompt", "--close-from", "--command-timeout", "--role", "--type", "--other-user", "--chdir", "--chroot"])],
849
+ ["doas", wrapper("uC")],
850
+ ["timeout", wrapper("sk", ["--signal", "--kill-after"], 1)],
851
+ ["nice", wrapper("n", ["--adjustment"])],
852
+ ["ionice", wrapper("cnpPu", ["--class", "--classdata", "--pid", "--pgid", "--uid"])],
853
+ ["stdbuf", wrapper("ioe", ["--input", "--output", "--error"])],
854
+ ["chroot", wrapper("", ["--userspec", "--groups"], 1)],
855
+ ["xargs", wrapper("IndsEaPL", ["--max-args", "--max-procs", "--max-chars", "--delimiter", "--arg-file", "--eof", "--max-lines", "--process-slot-var"])]
856
+ ]);
857
+ function closerOf(quote) {
858
+ return quote === ANSI_QUOTE ? "'" : quote;
859
+ }
860
+ function startsComment(command, i) {
861
+ const before = command[i - 1] ?? "";
862
+ return i === 0 || WHITESPACE.test(before) || COMMENT_PREFIXES.has(before);
863
+ }
738
864
  function separatorWidth(command, i) {
739
865
  const pair = command.slice(i, i + 2);
740
866
  if (pair === "&&" || pair === "||")
@@ -751,7 +877,7 @@ function heredocEnd(command, newline, words) {
751
877
  const next = command.indexOf(`
752
878
  `, lineStart);
753
879
  const lineEnd = next === -1 ? command.length : next;
754
- const line = command.slice(lineStart, lineEnd);
880
+ const line = command.slice(lineStart, lineEnd).replace(TRAILING_CR, "");
755
881
  lineStart = lineEnd + 1;
756
882
  if ((stripsTabs ? line.replace(LEADING_TABS, "") : line) === word)
757
883
  break;
@@ -773,6 +899,7 @@ function withoutRanges(command, start, end, ranges) {
773
899
  function splitCommand(command) {
774
900
  const bounds = [];
775
901
  const bodies = [];
902
+ const comments = [];
776
903
  let heredocs = [];
777
904
  let start = 0;
778
905
  let quote = null;
@@ -783,14 +910,27 @@ function splitCommand(command) {
783
910
  continue;
784
911
  }
785
912
  if (quote !== null) {
786
- if (char === quote)
913
+ if (char === closerOf(quote))
787
914
  quote = null;
788
915
  continue;
789
916
  }
917
+ if (char === "$" && command[i + 1] === "'") {
918
+ quote = ANSI_QUOTE;
919
+ i++;
920
+ continue;
921
+ }
790
922
  if (char === "'" || char === '"') {
791
923
  quote = char;
792
924
  continue;
793
925
  }
926
+ if (char === "#" && startsComment(command, i)) {
927
+ const newline = command.indexOf(`
928
+ `, i);
929
+ const end = newline === -1 ? command.length : newline;
930
+ comments.push([i, end]);
931
+ i = end - 1;
932
+ continue;
933
+ }
794
934
  const heredoc = char === "<" && command[i + 1] === "<" && command[i + 2] !== "<" ? HEREDOC.exec(command.slice(i)) : null;
795
935
  if (heredoc) {
796
936
  heredocs.push({ word: heredoc[2] ?? heredoc[3] ?? heredoc[4] ?? "", stripsTabs: heredoc[1] === "-", isQuoted: heredoc[4] === undefined });
@@ -813,32 +953,78 @@ function splitCommand(command) {
813
953
  start = i + 1;
814
954
  }
815
955
  bounds.push([start, command.length, ""]);
816
- const ranges = bodies.map(([from, to]) => [from, to]);
817
- return bounds.map(([from, to, separator]) => ({
818
- text: command.slice(from, to),
819
- start: from,
820
- end: to,
821
- command: withoutRanges(command, from, to, ranges),
822
- separator,
823
- expanded: bodies.filter(([body, , expands]) => expands && body >= from && body < to).map(([body, bodyEnd]) => command.slice(body, bodyEnd)).join("")
824
- }));
956
+ const ranges = [...bodies, ...comments].map(([from, to]) => [from, to]).sort(([a], [b]) => a - b);
957
+ return bounds.map(([from, to, separator]) => {
958
+ const inside = bodies.filter(([body]) => body >= from && body < to);
959
+ return {
960
+ text: command.slice(from, to),
961
+ start: from,
962
+ end: to,
963
+ command: withoutRanges(command, from, to, ranges),
964
+ separator,
965
+ expanded: inside.filter(([, , expands]) => expands).map(([body, bodyEnd]) => command.slice(body, bodyEnd)).join(""),
966
+ heredocs: inside.map(([body, bodyEnd]) => command.slice(body, bodyEnd))
967
+ };
968
+ });
825
969
  }
826
970
  function isPlain(text) {
827
971
  let quote = null;
828
972
  for (let i = 0;i < text.length; i++) {
829
973
  const char = text[i] ?? "";
830
974
  const isSubstitution = char === "`" || char === "$" && text[i + 1] === "(";
975
+ if (quote === null && char === "$" && text[i + 1] === "'") {
976
+ quote = ANSI_QUOTE;
977
+ i++;
978
+ continue;
979
+ }
831
980
  if (char === "\\" && quote !== "'")
832
981
  i++;
833
- else if (quote !== null && char === quote)
982
+ else if (quote !== null && char === closerOf(quote))
834
983
  quote = null;
835
984
  else if (quote === null && (char === '"' || char === "'"))
836
985
  quote = char;
837
- else if (quote !== "'" && (isSubstitution || quote === null && PLAIN_BREAKERS.has(char)))
986
+ else if (quote !== "'" && quote !== ANSI_QUOTE && (isSubstitution || quote === null && PLAIN_BREAKERS.has(char)))
838
987
  return false;
839
988
  }
840
989
  return quote === null;
841
990
  }
991
+ function unmatchedClosers(text) {
992
+ let depth = 0;
993
+ let unmatched = 0;
994
+ let quote = null;
995
+ for (let i = 0;i < text.length; i++) {
996
+ const char = text[i] ?? "";
997
+ if (quote === null && char === "$" && text[i + 1] === "'") {
998
+ quote = ANSI_QUOTE;
999
+ i++;
1000
+ continue;
1001
+ }
1002
+ if (char === "\\" && quote !== "'")
1003
+ i++;
1004
+ else if (quote !== null && char === closerOf(quote))
1005
+ quote = null;
1006
+ else if (quote === null && (char === '"' || char === "'"))
1007
+ quote = char;
1008
+ else if (quote === null && char === "(")
1009
+ depth++;
1010
+ else if (quote === null && char === ")" && depth > 0)
1011
+ depth--;
1012
+ else if (quote === null && char === ")")
1013
+ unmatched++;
1014
+ }
1015
+ return unmatched;
1016
+ }
1017
+ function withoutSubshell(text) {
1018
+ let result = text.trim();
1019
+ let closers = unmatchedClosers(result);
1020
+ while (result.startsWith("("))
1021
+ result = result.slice(1).trimStart();
1022
+ while (closers > 0 && result.endsWith(")")) {
1023
+ result = result.slice(0, -1).trimEnd();
1024
+ closers--;
1025
+ }
1026
+ return result;
1027
+ }
842
1028
  function closingParen(text, open) {
843
1029
  let depth = 0;
844
1030
  let quote = null;
@@ -847,8 +1033,11 @@ function closingParen(text, open) {
847
1033
  if (char === "\\" && quote !== "'") {
848
1034
  i++;
849
1035
  } else if (quote !== null) {
850
- if (char === quote)
1036
+ if (char === closerOf(quote))
851
1037
  quote = null;
1038
+ } else if (char === "$" && text[i + 1] === "'") {
1039
+ quote = ANSI_QUOTE;
1040
+ i++;
852
1041
  } else if (char === "'" || char === '"') {
853
1042
  quote = char;
854
1043
  } else if (char === "(") {
@@ -873,12 +1062,18 @@ function bodies(text, isQuoting) {
873
1062
  let quote = null;
874
1063
  for (let i = 0;i < text.length; i++) {
875
1064
  const char = text[i] ?? "";
1065
+ const next = text[i + 1];
876
1066
  if (quote === "'") {
877
1067
  if (char === "'")
878
1068
  quote = null;
1069
+ } else if (quote === ANSI_QUOTE) {
1070
+ if (char === "\\")
1071
+ i++;
1072
+ else if (char === "'")
1073
+ quote = null;
879
1074
  } else if (char === "\\") {
880
1075
  i++;
881
- } else if (char === "$" && text[i + 1] === "(") {
1076
+ } else if (char === "$" && next === "(" || isQuoting && quote === null && (char === "<" || char === ">") && next === "(") {
882
1077
  const end = closingParen(text, i + 1);
883
1078
  found.push(text.slice(i + 2, end));
884
1079
  i = end;
@@ -888,6 +1083,9 @@ function bodies(text, isQuoting) {
888
1083
  i = end;
889
1084
  } else if (isQuoting && char === '"') {
890
1085
  quote = quote === null ? char : null;
1086
+ } else if (isQuoting && char === "$" && next === "'" && quote === null) {
1087
+ quote = ANSI_QUOTE;
1088
+ i++;
891
1089
  } else if (isQuoting && char === "'" && quote === null) {
892
1090
  quote = char;
893
1091
  }
@@ -897,11 +1095,26 @@ function bodies(text, isQuoting) {
897
1095
  function substitutions(command) {
898
1096
  return splitCommand(command).flatMap((piece) => [...bodies(piece.command, true), ...bodies(piece.expanded, false)]);
899
1097
  }
1098
+ function ansiEscape(text, at) {
1099
+ const letter = text[at + 1] ?? "";
1100
+ const simple = ANSI_ESCAPES[letter];
1101
+ if (simple !== undefined)
1102
+ return [simple, at + 1];
1103
+ const hex = LEADING_HEX.exec(text.slice(at + 2, at + 2 + (ANSI_DIGITS[letter] ?? 0)))?.[0] ?? "";
1104
+ if (hex !== "")
1105
+ return [String.fromCodePoint(Number.parseInt(hex, 16)), at + 1 + hex.length];
1106
+ const octal = OCTAL.exec(text.slice(at + 1))?.[0];
1107
+ if (octal !== undefined)
1108
+ return [String.fromCodePoint(Number.parseInt(octal, 8)), at + octal.length];
1109
+ return [`\\${letter}`, at + 1];
1110
+ }
900
1111
  function words(text) {
901
1112
  const found = [];
902
1113
  let current = "";
903
1114
  let isStarted = false;
904
1115
  let isProse = false;
1116
+ let hasGlob = false;
1117
+ let hasBrace = false;
905
1118
  let quote = null;
906
1119
  for (let i = 0;i < text.length; i++) {
907
1120
  const char = text[i] ?? "";
@@ -911,40 +1124,79 @@ function words(text) {
911
1124
  i++;
912
1125
  if (quote === null && (isContinuation || WHITESPACE.test(char))) {
913
1126
  if (isStarted)
914
- found.push({ text: current, isProse });
1127
+ found.push({ text: current, isProse, hasGlob, hasBrace });
915
1128
  current = "";
916
1129
  isStarted = false;
917
1130
  isProse = false;
1131
+ hasGlob = false;
1132
+ hasBrace = false;
918
1133
  continue;
919
1134
  }
920
1135
  if (isContinuation)
921
1136
  continue;
922
1137
  isStarted = true;
923
- if (char === "\\" && quote === '"' && !DOUBLE_QUOTED_ESCAPES.has(text[i + 1] ?? ""))
1138
+ if (char === "\\" && quote === ANSI_QUOTE) {
1139
+ const [decoded, last] = ansiEscape(text, i);
1140
+ current += decoded;
1141
+ i = last;
1142
+ } else if (char === "\\" && quote === '"' && !DOUBLE_QUOTED_ESCAPES.has(text[i + 1] ?? "")) {
924
1143
  current += char;
925
- else if (char === "\\" && quote !== "'")
1144
+ } else if (char === "\\" && quote !== "'") {
926
1145
  current += text[++i] ?? "";
927
- else if (quote !== null && char === quote)
1146
+ } else if (quote !== null && char === closerOf(quote)) {
928
1147
  quote = null;
929
- else if (quote === null && (char === '"' || char === "'"))
1148
+ } else if (quote === null && char === "$" && text[i + 1] === "'") {
1149
+ quote = ANSI_QUOTE;
1150
+ i++;
1151
+ } else if (quote === null && (char === '"' || char === "'")) {
930
1152
  quote = char;
931
- else
1153
+ } else {
932
1154
  current += char;
1155
+ hasGlob ||= quote === null && GLOB_CHARS.has(char);
1156
+ hasBrace ||= quote === null && char === "{";
1157
+ }
933
1158
  isProse ||= quote !== null && WHITESPACE.test(char);
934
1159
  }
935
1160
  if (isStarted)
936
- found.push({ text: current, isProse });
1161
+ found.push({ text: current, isProse, hasGlob, hasBrace });
937
1162
  return found;
938
1163
  }
939
1164
  function tokenize(text) {
940
1165
  return words(text).map((word) => word.text);
941
1166
  }
1167
+ function skipOptions(tokens, spec) {
1168
+ const take = (name, attached) => {
1169
+ const value = attached === "" ? tokens.shift() ?? "" : attached;
1170
+ if (name === "S" || name === "--split-string")
1171
+ tokens.unshift(...tokenize(value));
1172
+ };
1173
+ while (tokens[0] !== undefined && tokens[0].startsWith("-") && tokens[0] !== "-") {
1174
+ const option = tokens.shift() ?? "";
1175
+ if (option === "--")
1176
+ break;
1177
+ if (option.startsWith("--")) {
1178
+ const [name = "", ...value] = option.split("=");
1179
+ if (spec.long.includes(name))
1180
+ take(name, value.join("="));
1181
+ continue;
1182
+ }
1183
+ for (const [index, letter] of [...option.slice(1)].entries()) {
1184
+ if (!spec.short.includes(letter))
1185
+ continue;
1186
+ take(letter, option.slice(index + 2));
1187
+ break;
1188
+ }
1189
+ }
1190
+ for (let operand = 0;operand < spec.operands; operand++)
1191
+ tokens.shift();
1192
+ }
942
1193
  function parseCommand(text) {
943
1194
  const tokens = tokenize(text);
944
1195
  const assignments = [];
945
1196
  let isWrapped = false;
946
1197
  for (;; ) {
947
1198
  const first = tokens[0] ?? "";
1199
+ const spec = WRAPPERS.get(first.split("/").pop() ?? "");
948
1200
  if (OPENERS.test(first)) {
949
1201
  isWrapped = true;
950
1202
  const rest = first.replace(OPENERS, "");
@@ -954,11 +1206,20 @@ function parseCommand(text) {
954
1206
  tokens[0] = rest;
955
1207
  } else if (ASSIGNMENT.test(first)) {
956
1208
  assignments.push(tokens.shift() ?? "");
957
- } else if (WRAPPERS.has(first)) {
1209
+ } else if (KEYWORDS.has(first)) {
958
1210
  isWrapped = true;
959
1211
  tokens.shift();
960
- while (tokens[0]?.startsWith("-"))
961
- tokens.shift();
1212
+ } else if (first === "case") {
1213
+ isWrapped = true;
1214
+ const at = tokens.indexOf("in");
1215
+ tokens.splice(0, at === -1 ? tokens.length : at + 1);
1216
+ } else if (tokens.length > 1 && CASE_LABEL.test(first)) {
1217
+ isWrapped = true;
1218
+ tokens.shift();
1219
+ } else if (spec !== undefined) {
1220
+ isWrapped = true;
1221
+ tokens.shift();
1222
+ skipOptions(tokens, spec);
962
1223
  } else {
963
1224
  break;
964
1225
  }
@@ -972,6 +1233,131 @@ function parseCommand(text) {
972
1233
  const [program = "", ...args] = tokens;
973
1234
  return { program: program.split("/").pop() ?? "", args, assignments, isWrapped };
974
1235
  }
1236
+ function quoted(word) {
1237
+ return SAFE_WORD.test(word) || word === "{}" ? word : `'${word.replaceAll("'", "'\\''")}'`;
1238
+ }
1239
+ function commandStrings(tokens) {
1240
+ const found = [];
1241
+ for (const [i, token] of tokens.entries()) {
1242
+ const name = token.split("/").pop() ?? "";
1243
+ if (!COMMAND_STRING_PROGRAMS.has(name))
1244
+ continue;
1245
+ const isUser = name === "su" || name === "runuser";
1246
+ let isValue = false;
1247
+ for (let j = i + 1;j < tokens.length; j++) {
1248
+ const option = tokens[j] ?? "";
1249
+ if (isValue) {
1250
+ isValue = false;
1251
+ continue;
1252
+ }
1253
+ if (SHELL_VALUE_OPTIONS.has(option)) {
1254
+ isValue = true;
1255
+ continue;
1256
+ }
1257
+ if (option.startsWith("--command=")) {
1258
+ found.push(option.slice("--command=".length));
1259
+ break;
1260
+ }
1261
+ if (option === "--command" || !option.startsWith("--") && option.startsWith("-") && option.slice(1).includes("c")) {
1262
+ found.push(tokens[j + 1] ?? "");
1263
+ break;
1264
+ }
1265
+ if (!option.startsWith("-") && !isUser)
1266
+ break;
1267
+ }
1268
+ }
1269
+ return found;
1270
+ }
1271
+ function redirections(args) {
1272
+ const operands = [];
1273
+ const hereStrings = [];
1274
+ for (let i = 0;i < args.length; i++) {
1275
+ const arg = args[i] ?? "";
1276
+ if (arg.startsWith("<<<"))
1277
+ hereStrings.push(arg.length > 3 ? arg.slice(3) : args[++i] ?? "");
1278
+ else if (REDIRECT_ONLY.test(arg))
1279
+ i++;
1280
+ else if (!REDIRECT_START.test(arg))
1281
+ operands.push(arg);
1282
+ }
1283
+ return { operands, hereStrings };
1284
+ }
1285
+ function findExecs(args) {
1286
+ const found = [];
1287
+ for (const [i, arg] of args.entries()) {
1288
+ if (!FIND_EXEC.has(arg))
1289
+ continue;
1290
+ const rest = args.slice(i + 1);
1291
+ const end = rest.findIndex((word) => word === ";" || word === "+");
1292
+ found.push((end === -1 ? rest : rest.slice(0, end)).map(quoted).join(" "));
1293
+ }
1294
+ return found;
1295
+ }
1296
+ function nestedCommands(piece, previous) {
1297
+ const tokens = tokenize(piece.command);
1298
+ const { program, args } = parseCommand(piece.command);
1299
+ const found = commandStrings(tokens);
1300
+ if (program === "eval")
1301
+ found.push(args.join(" "));
1302
+ if (program === "find")
1303
+ found.push(...findExecs(args));
1304
+ if (!SHELLS.has(program))
1305
+ return found;
1306
+ const { operands, hereStrings } = redirections(args);
1307
+ if (operands.length > 0)
1308
+ return found;
1309
+ found.push(...piece.heredocs, ...hereStrings);
1310
+ if (previous?.separator === "|") {
1311
+ const before = parseCommand(previous.command);
1312
+ if (PRINTERS.has(before.program))
1313
+ found.push(...before.args, before.args.join(" "));
1314
+ }
1315
+ return found;
1316
+ }
1317
+ function commandsRun(command, depth = 0) {
1318
+ const found = [command];
1319
+ if (depth >= MAX_DEPTH)
1320
+ return found;
1321
+ const pieces = splitCommand(command);
1322
+ for (const [index, piece] of pieces.entries()) {
1323
+ const inner = withoutSubshell(piece.command);
1324
+ const nested = [...bodies(piece.command, true), ...bodies(piece.expanded, false), ...nestedCommands(piece, pieces[index - 1])];
1325
+ if (inner !== piece.command.trim())
1326
+ nested.push(inner);
1327
+ for (const each of nested)
1328
+ found.push(...commandsRun(each, depth + 1));
1329
+ }
1330
+ return found;
1331
+ }
1332
+ function expandBraces(word) {
1333
+ const open = word.indexOf("{");
1334
+ if (open === -1)
1335
+ return [word];
1336
+ const alternatives = [];
1337
+ let depth = 0;
1338
+ let cut = open;
1339
+ for (let i = open;i < word.length; i++) {
1340
+ const char = word[i];
1341
+ if (char === "{") {
1342
+ depth++;
1343
+ } else if (char === "," && depth === 1) {
1344
+ alternatives.push(word.slice(cut + 1, i));
1345
+ cut = i;
1346
+ } else if (char === "}" && --depth === 0) {
1347
+ if (alternatives.length === 0)
1348
+ break;
1349
+ alternatives.push(word.slice(cut + 1, i));
1350
+ const prefix = word.slice(0, open);
1351
+ const suffix = word.slice(i + 1);
1352
+ return alternatives.flatMap((alternative) => expandBraces(prefix + alternative + suffix)).slice(0, MAX_BRACES + 1);
1353
+ }
1354
+ }
1355
+ return expandBraces(word.slice(open + 1)).map((rest) => `${word.slice(0, open + 1)}${rest}`);
1356
+ }
1357
+ function braceExpand(word) {
1358
+ const found = expandBraces(word);
1359
+ return found.length > MAX_BRACES ? [word] : found;
1360
+ }
975
1361
  function replacePieces(command, replacements) {
976
1362
  let result = command;
977
1363
  for (const [piece, text] of [...replacements].sort(([a], [b]) => b.start - a.start)) {
@@ -1005,10 +1391,14 @@ var LOCK_KIND = /^(?:update|share|no|key)$/;
1005
1391
  var DIGITS = /\d/;
1006
1392
  var COMMAND_ENDS = ";";
1007
1393
  var UNCLOSED = "unclosed";
1394
+ var NUL = "\x00";
1395
+ var UNICODE_IDENTIFIER = 'U&"';
1396
+ var UNICODE_PREFIX = /u&$/i;
1397
+ var LINE_END = /[\r\n]/;
1008
1398
  var PRIVILEGED_SET = new Set(["role", "session", "transaction"]);
1009
1399
  var READ_STARTS = new Set(["select", "with", "values", "table", "show", "explain"]);
1010
1400
  var WRITE_WORDS = new Set(["insert", "update", "delete", "merge", "truncate", "drop", "alter", "create", "grant", "revoke", "into", "call", "vacuum", "lock", "refresh", "reindex", "cluster", "do"]);
1011
- var SIDE_EFFECT_FUNCTIONS = /^(?:nextval|setval|set_config|pg_terminate_backend|pg_cancel_backend|pg_reload_conf|pg_rotate_logfile|pg_switch_wal|pg_create_restore_point|pg_advisory_lock\w*|pg_advisory_xact_lock\w*|pg_try_advisory\w*|pg_notify|lo_\w+|dblink\w*|pg_read_file|pg_read_binary_file|pg_ls_dir|query_to_xml\w*|pg_logical_\w+|pg_replication_\w+|pg_drop_replication_slot|pg_stat_reset\w*|pg_promote|pg_file_\w+)$/;
1401
+ var SIDE_EFFECT_FUNCTIONS = /^(?:nextval|setval|set_config|pg_terminate_backend|pg_cancel_backend|pg_reload_conf|pg_rotate_logfile|pg_switch_wal|pg_create_restore_point|pg_advisory_lock\w*|pg_advisory_xact_lock\w*|pg_try_advisory\w*|pg_notify|lo_\w+|dblink\w*|pg_read_file|pg_read_binary_file|pg_ls_\w+|pg_stat_file|pg_sleep\w*|pg_export_snapshot|pg_backup_start|pg_backup_stop|pg_start_backup|pg_stop_backup|txid_current|pg_current_xact_id|pg_current_logfile|pg_wal_replay_\w+|\w+_to_xml\w*|pg_logical_\w+|pg_replication_\w+|pg_drop_replication_slot|pg_stat_reset\w*|pg_promote|pg_file_\w+)$/;
1012
1402
  function tokenize2(sql) {
1013
1403
  const tokens = [];
1014
1404
  let at = 0;
@@ -1017,21 +1407,29 @@ function tokenize2(sql) {
1017
1407
  const next = sql.charAt(at + 1);
1018
1408
  if (SPACE.test(char)) {
1019
1409
  at++;
1410
+ } else if (char === NUL) {
1411
+ tokens.push({ kind: "punct", text: NUL });
1412
+ at++;
1020
1413
  } else if (char === "-" && next === "-") {
1021
- const end = sql.indexOf(`
1022
- `, at);
1023
- at = end === -1 ? sql.length : end + 1;
1414
+ const end = sql.slice(at).search(LINE_END);
1415
+ at = end === -1 ? sql.length : at + end + 1;
1024
1416
  } else if (char === "/" && next === "*" && sql.charAt(at + 2) === "!") {
1025
1417
  at += 3;
1026
1418
  while (DIGITS.test(sql.charAt(at)))
1027
1419
  at++;
1028
1420
  } else if (char === "/" && next === "*") {
1029
1421
  at = skipComment(sql, at);
1030
- } else if (char === "'" || char === '"' || char === "`") {
1031
- const backslashes = char === "'" && ESCAPE_PREFIX.test(sql.charAt(at - 1)) && !WORD_PART.test(sql.charAt(at - 2));
1422
+ } else if (char === "'") {
1423
+ const backslashes = ESCAPE_PREFIX.test(sql.charAt(at - 1)) && !WORD_PART.test(sql.charAt(at - 2));
1032
1424
  const end = skipQuoted(sql, at, char, backslashes);
1033
1425
  at = end ?? sql.length;
1034
1426
  tokens.push({ kind: "punct", text: end === undefined ? UNCLOSED : char });
1427
+ } else if (char === '"' || char === "`") {
1428
+ const end = skipQuoted(sql, at, char, false);
1429
+ if (char === '"' && UNICODE_PREFIX.test(sql.slice(Math.max(0, at - 2), at)) && !WORD_PART.test(sql.charAt(at - 3)))
1430
+ tokens.push({ kind: "punct", text: UNICODE_IDENTIFIER });
1431
+ tokens.push(end === undefined ? { kind: "punct", text: UNCLOSED } : { kind: "quoted", text: sql.slice(at + 1, end - 1).replaceAll(char + char, char) });
1432
+ at = end ?? sql.length;
1035
1433
  } else if (char === "$" && DOLLAR_TAG.test(sql.slice(at))) {
1036
1434
  const tag = DOLLAR_TAG.exec(sql.slice(at))?.[0] ?? "$$";
1037
1435
  const end = sql.indexOf(tag, at + tag.length);
@@ -1116,6 +1514,12 @@ function statementWrites(statement) {
1116
1514
  for (const [index, token] of statement.entries()) {
1117
1515
  if (isPunct(token, UNCLOSED))
1118
1516
  reasons.push("has a quote that never closes");
1517
+ else if (isPunct(token, NUL))
1518
+ reasons.push("contains a NUL byte");
1519
+ else if (isPunct(token, UNICODE_IDENTIFIER))
1520
+ reasons.push("uses a U& escaped identifier");
1521
+ if (token.kind === "quoted" && SIDE_EFFECT_FUNCTIONS.test(token.text))
1522
+ reasons.push(`calls ${token.text}`);
1119
1523
  if (token.kind !== "word")
1120
1524
  continue;
1121
1525
  if (SIDE_EFFECT_FUNCTIONS.test(token.text)) {
@@ -1148,23 +1552,104 @@ function writes(sql) {
1148
1552
  var SQL_CLIENTS = new Set(["psql", "mysql", "mariadb", "pgcli", "mycli", "sqlite3"]);
1149
1553
  var WRITING_TOOLS = new Set(["dropdb", "createdb", "pg_restore"]);
1150
1554
  var CONSOLE_PROGRAMS = new Set(["php", "docker", "symfony", "console", "artisan"]);
1151
- var STATEMENT_OPTIONS = new Set(["-c", "--command", "-e", "--execute"]);
1152
- var FILE_OPTIONS = new Set(["-f", "--file", "<"]);
1153
- var PRINTERS = new Set(["echo", "printf"]);
1555
+ var VALUE_SHORTS = { psql: "cdfhLopPRTUvF", pgcli: "dhUpW", mysql: "eDhPSuTA", mariadb: "eDhPSuTA", mycli: "eDhPSuTA", sqlite3: "" };
1556
+ var STATEMENT_SHORTS = { psql: "c", pgcli: "c", mysql: "e", mariadb: "e", mycli: "e", sqlite3: "" };
1557
+ var FILE_SHORTS = { psql: "f", pgcli: "", mysql: "", mariadb: "", mycli: "", sqlite3: "" };
1558
+ var IS_MYSQL = new Set(["mysql", "mariadb", "mycli"]);
1559
+ var DATABASE_SHORTS = new Set(["d", "D"]);
1560
+ var STATEMENT_LONGS = new Set(["--command", "--execute"]);
1561
+ var DATABASE_LONGS = new Set(["--dbname", "--database"]);
1562
+ var VALUE_LONGS = new Set(["--host", "--port", "--username", "--user", "--output", "--set", "--variable", "--field-separator", "--record-separator", "--pset", "--log-file", "--socket"]);
1563
+ var PRINTERS2 = new Set(["echo", "printf"]);
1154
1564
  var LIST_OPTIONS = new Set(["-l", "--list"]);
1565
+ var REDIRECT_ONLY2 = /^\d*(?:>|>>|>&|&>|>\||<&|<>)$/;
1566
+ var REDIRECT_START2 = /^\d*>|^&>/;
1567
+ var URL_DATABASE = /^[a-z][a-z\d+.-]*:\/\/[^/]*\/([^?#]*)/i;
1155
1568
  var WRITING_CONSOLE = /^(?:doctrine:(?:migrations:(?:migrate|execute|rollup)|schema:(?:update|create|drop)|database:(?:create|drop)|fixtures:load)|migrate(?::\w+)?|db:(?:seed|wipe))$/;
1156
1569
  var TEST_SCOPED = /(?:^|[^a-z])test/i;
1157
1570
  var SHOT_STACK = /\.worktrees\/[^/\s]+\/shot(?:\/|$)|--shot(?:-|$)/;
1571
+ var DEVICE_PATH = /^\/(?:dev|proc|sys)\//;
1572
+ var STDIN_PATHS = new Set(["-", "/dev/stdin"]);
1158
1573
  var SQL_WRITE = "Statements that write (INSERT, UPDATE, DELETE, DDL, migrations, fixtures) are handed over: list the exact command for the user to run. Only a clearly test-only database runs them directly.";
1574
+ var SQL_UNREADABLE = "A file this SQL client is fed cannot be read here (missing, a device, or too large), so what it runs is unknown. Hand over the exact command for the user to run.";
1159
1575
  function hasWrite(statement) {
1160
1576
  return writes(statement).length > 0;
1161
1577
  }
1162
1578
  function programOf(word) {
1163
1579
  return word.split("/").pop() ?? "";
1164
1580
  }
1165
- function isTestScoped(words) {
1166
- const client = words.findIndex((word) => SQL_CLIENTS.has(programOf(word)));
1167
- return words.some((word, i) => (TEST_SCOPED.test(word) || SHOT_STACK.test(word)) && (i <= client || client === -1 || !STATEMENT_OPTIONS.has(words[i - 1] ?? "") && !FILE_OPTIONS.has(words[i - 1] ?? "")));
1581
+ function databaseOf(operand) {
1582
+ return URL_DATABASE.exec(operand)?.[1] ?? operand;
1583
+ }
1584
+ function clientInput(name, args) {
1585
+ const input = { statements: [], files: [], databases: [], operands: [] };
1586
+ const valueShorts = VALUE_SHORTS[name] ?? "";
1587
+ let isOperands = false;
1588
+ for (let i = 0;i < args.length; i++) {
1589
+ const arg = args[i] ?? "";
1590
+ if (isOperands) {
1591
+ input.operands.push(arg);
1592
+ } else if (arg === "--") {
1593
+ isOperands = true;
1594
+ } else if (arg.startsWith("<<<")) {
1595
+ input.statements.push(arg.length > 3 ? arg.slice(3) : args[++i] ?? "");
1596
+ } else if (arg.startsWith("<<")) {
1597
+ continue;
1598
+ } else if (arg === "<") {
1599
+ input.files.push(args[++i] ?? "");
1600
+ } else if (arg.startsWith("<")) {
1601
+ input.files.push(arg.slice(1));
1602
+ } else if (REDIRECT_ONLY2.test(arg)) {
1603
+ i++;
1604
+ } else if (REDIRECT_START2.test(arg)) {
1605
+ continue;
1606
+ } else if (arg.startsWith("--")) {
1607
+ const [option = "", ...rest] = arg.split("=");
1608
+ const attached = rest.length > 0 ? rest.join("=") : undefined;
1609
+ const value = attached ?? args[i + 1] ?? "";
1610
+ if (STATEMENT_LONGS.has(option))
1611
+ input.statements.push(value);
1612
+ else if (option === "--file")
1613
+ input.files.push(value);
1614
+ else if (DATABASE_LONGS.has(option))
1615
+ input.databases.push(databaseOf(value));
1616
+ if (attached === undefined && (STATEMENT_LONGS.has(option) || option === "--file" || DATABASE_LONGS.has(option) || VALUE_LONGS.has(option)))
1617
+ i++;
1618
+ } else if (arg === "-cmd" && name === "sqlite3") {
1619
+ input.statements.push(args[++i] ?? "");
1620
+ } else if (arg.startsWith("-p") && IS_MYSQL.has(name)) {
1621
+ continue;
1622
+ } else if (arg.startsWith("-") && arg.length > 1) {
1623
+ for (const [index, letter] of [...arg.slice(1)].entries()) {
1624
+ if (!valueShorts.includes(letter))
1625
+ continue;
1626
+ const attached = arg.slice(index + 2);
1627
+ const value = attached === "" ? args[++i] ?? "" : attached;
1628
+ if (STATEMENT_SHORTS[name]?.includes(letter) === true)
1629
+ input.statements.push(value);
1630
+ else if (FILE_SHORTS[name]?.includes(letter) === true)
1631
+ input.files.push(value);
1632
+ else if (DATABASE_SHORTS.has(letter))
1633
+ input.databases.push(databaseOf(value));
1634
+ break;
1635
+ }
1636
+ } else {
1637
+ input.operands.push(arg);
1638
+ }
1639
+ }
1640
+ if (name === "sqlite3")
1641
+ input.statements.push(...input.operands.slice(1));
1642
+ else if (input.operands[0] !== undefined)
1643
+ input.databases.push(databaseOf(input.operands[0]));
1644
+ return input;
1645
+ }
1646
+ function isTestScoped(words, client, input) {
1647
+ if (client === -1)
1648
+ return words.some((word) => TEST_SCOPED.test(word) || SHOT_STACK.test(word));
1649
+ return words.slice(0, client).some((word) => TEST_SCOPED.test(word) || SHOT_STACK.test(word)) || (input?.databases ?? []).some((name) => TEST_SCOPED.test(name));
1650
+ }
1651
+ async function fileText(path, read) {
1652
+ return DEVICE_PATH.test(path) ? null : read(path);
1168
1653
  }
1169
1654
  async function sqlViolation(command, read) {
1170
1655
  const pieces = splitCommand(command);
@@ -1174,23 +1659,27 @@ async function sqlViolation(command, read) {
1174
1659
  const { program, args } = parseCommand(piece.command);
1175
1660
  if (program === "cd")
1176
1661
  inShotStack = SHOT_STACK.test(args[0] ?? "");
1177
- if (inShotStack || isTestScoped(words))
1662
+ const client = words.findIndex((word) => SQL_CLIENTS.has(programOf(word)));
1663
+ const input = client === -1 ? null : clientInput(programOf(words[client] ?? ""), words.slice(client + 1));
1664
+ if (inShotStack || isTestScoped(words, client, input))
1178
1665
  continue;
1179
1666
  if (WRITING_TOOLS.has(program) && !words.some((word) => LIST_OPTIONS.has(word)))
1180
1667
  return SQL_WRITE;
1181
1668
  if (CONSOLE_PROGRAMS.has(program) && words.some((word) => WRITING_CONSOLE.test(word)))
1182
1669
  return SQL_WRITE;
1183
- const client = words.findIndex((word) => SQL_CLIENTS.has(programOf(word)));
1184
- if (client === -1)
1670
+ if (input === null)
1185
1671
  continue;
1186
- const files = words.filter((_, i) => i > client && FILE_OPTIONS.has(words[i - 1] ?? ""));
1187
1672
  const before = index === 0 ? null : parseCommand(pieces[index - 1]?.command ?? "");
1673
+ const isPiped = pieces[index - 1]?.separator === "|";
1674
+ const paths = input.files.filter((path) => !STDIN_PATHS.has(path));
1675
+ const texts = await Promise.all([...paths, ...isPiped && before?.program === "cat" ? before.args.filter((arg) => !arg.startsWith("-")) : []].map(async (path) => fileText(path, read)));
1676
+ if (texts.includes(null))
1677
+ return SQL_UNREADABLE;
1188
1678
  const statements = [
1189
- ...words.filter((_, i) => i > client && STATEMENT_OPTIONS.has(words[i - 1] ?? "")),
1190
- piece.text === piece.command ? "" : piece.text.slice(piece.text.indexOf(`
1191
- `)),
1192
- before !== null && PRINTERS.has(before.program) ? before.args.join(" ") : "",
1193
- ...(await Promise.all(files.map(read))).map((text) => text ?? "")
1679
+ ...input.statements,
1680
+ ...piece.heredocs,
1681
+ isPiped && before !== null && PRINTERS2.has(before.program) ? before.args.join(" ") : "",
1682
+ ...texts.map((text) => text ?? "")
1194
1683
  ];
1195
1684
  if (statements.some(hasWrite))
1196
1685
  return SQL_WRITE;
@@ -1199,17 +1688,49 @@ async function sqlViolation(command, read) {
1199
1688
  }
1200
1689
 
1201
1690
  // ../env/src/files.ts
1202
- var ENV_FILE = /^\.env(?:\..+)?$/;
1691
+ var ENV_FILE = /^(?:\.env(?:\..+)?|.+\.env|\.envrc)$/;
1203
1692
  var TEMPLATE = /\.(?:example|sample|template|dist|defaults)$/;
1693
+ var SKIPPED = new Set(["node_modules", ".git", "vendor"]);
1204
1694
 
1205
1695
  // ../env/src/guard.ts
1206
1696
  var REDIRECT = /^\d*[<>]+&?/;
1697
+ var ENVRC = ".envrc";
1698
+ var PROC_ENVIRON = /^\/proc\/[^/]+\/environ$/;
1699
+ var ENV_NAME_IN_CODE = /(?:^|[^\w.-])(\.env(?:\.[\w.-]*\w)?|\.envrc)(?![\w-])/g;
1700
+ var PYTHON_ENVIRON = /\bos\.environ\b(?!\s*(?:\[|\.get\b))/;
1701
+ var VARIABLE = /\$(?:(\w+)|\{(\w+)\})/;
1702
+ var ASSIGNMENT2 = /^\w+=/;
1703
+ var SHORT_FLAGS = /^-[a-z]+$/i;
1704
+ var OPENER = /^[({]+/;
1705
+ var ENV_SAMPLES = [".env", ".env.local", ".env.production", ".envrc"];
1706
+ var MAX_VARIANTS = 64;
1707
+ var INTERPRETERS = new Set(["bun", "deno", "node", "python", "python3", "ruby", "perl", "php", "lua", "awk", "gawk"]);
1708
+ var CODE_OPTIONS = new Set(["-e", "-c", "-p", "-E", "-pe", "-r", "--eval", "--print", "--command"]);
1709
+ var GREPS = new Set(["grep", "egrep", "fgrep"]);
1710
+ var GREP_VALUE_OPTIONS = new Set(["-e", "-f", "-m", "-A", "-B", "-C", "-d", "-D", "--regexp", "--file", "--max-count", "--include", "--exclude", "--exclude-dir", "--exclude-from", "--context", "--after-context", "--before-context", "--label", "--binary-files", "--directories", "--devices"]);
1711
+ var RG_VALUE_OPTIONS = new Set(["-e", "-f", "-g", "-t", "-T", "-m", "-A", "-B", "-C", "-j", "-d", "--regexp", "--file", "--glob", "--iglob", "--type", "--type-not", "--max-count", "--context", "--after-context", "--before-context", "--threads", "--max-depth", "--max-filesize"]);
1712
+ var PATTERN_OPTIONS = new Set(["-e", "-f", "--regexp", "--file"]);
1713
+ var HIDDEN_FLAGS = /^(?:-u{2,}|--hidden|-\.)$/i;
1714
+ var REGEX_SPECIALS = /[.+^${}()|\\[\]]/g;
1715
+ var NEGATED_CLASS = /^!/;
1716
+ var GLOB_CHAR = /[*?[]/;
1717
+ var HAS_R = /r/i;
1718
+ var DUMP_FLAGS = /^-[a-z]*[px]/;
1719
+ var DECLARERS = new Set(["export", "declare", "local", "readonly", "typeset"]);
1720
+ var WHOLE_TREE_OPERAND = /^(?:(?:\.\.?\/?)+|~\/?|\$HOME\/?|\/)$/;
1721
+ var GIT_PATCHES = new Set(["diff", "log", "show", "whatchanged", "format-patch", "grep"]);
1722
+ var GIT_NO_PATCH = new Set(["--stat", "--numstat", "--shortstat", "--name-only", "--name-status", "--summary", "-s", "--no-patch", "--check", "--quiet"]);
1723
+ var GIT_VALUE_OPTIONS = new Set(["-C", "-c", "--git-dir", "--work-tree", "--namespace", "--exec-path"]);
1724
+ var GIT_PATCH_FLAGS = new Set(["-p", "-u", "--patch", "-c", "--cc", "--patch-with-stat", "--patch-with-raw"]);
1725
+ var GIT_EXCLUDE = /^:(?:!|\^|\(exclude\))/;
1726
+ var EXCLUDES_ENV = /^(?:--exclude(?:-dir)?=.*env|(?:--glob=|--iglob=|-g)?!.*env)/;
1727
+ var EXCLUDE_OPTIONS = new Set(["--exclude", "--exclude-dir", "-g", "--glob", "--iglob"]);
1207
1728
  var ENV_DUMP = /(?:console\.\w+|JSON\.stringify|Object\.(?:entries|keys|values)|print)\(\s*process\.env\s*[,)]/;
1208
1729
  var HARMLESS = new Set(["ev", "redact", "ls", "stat", "test", "[", "[[", "touch"]);
1209
1730
  var GIT_HARMLESS = new Set(["check-ignore", "ls-files", "status"]);
1210
1731
  var ENV_PRINTERS = new Set(["env", "printenv"]);
1211
1732
  var DOCKER = new Set(["docker", "docker-compose"]);
1212
- var SHELLS = new Set(["sh", "bash", "zsh", "ash"]);
1733
+ var SHELLS2 = new Set(["sh", "bash", "zsh", "ash"]);
1213
1734
  var DOCKER_VALUE_OPTIONS = new Set(["-f", "--file", "-p", "--project-name", "--profile", "--env-file", "--project-directory", "--ansi", "--progress", "--parallel", "-H", "--host", "-c", "--context", "--config", "-l", "--log-level"]);
1214
1735
  var EXEC_VALUE_OPTIONS = new Set(["-e", "--env", "--env-file", "-u", "--user", "-w", "--workdir", "--index", "--detach-keys"]);
1215
1736
  var CONFIG_LISTS = new Set(["--services", "--volumes", "--profiles", "--images", "--networks", "--hash", "-q", "--quiet"]);
@@ -1218,13 +1739,79 @@ var WHOLE_FORMAT = /Env|\{\{\s*json\s+\.\s*\}\}|^json$/;
1218
1739
  var USE_EV = '.env files hold secrets that must never reach the transcript, so they are not read, edited or printed directly. Use `ev` instead: `ev list`, `ev get KEY` (masked), `ev grep <regex>` for the keys matching it, `ev show <rev>:<file>` for one at a git revision, `printf %s "$value" | ev set KEY [file]` (`--empty` for an empty value), `ev unset KEY`, `ev cp KEY --from .env --to .env.local`, `ev mv KEY --from .env --to .env.local`, `ev diff a b`, `ev missing`; and pipe output that may carry a secret through `redact`, as `docker compose exec <svc> printenv | redact` or `docker compose config | redact`. If `ev` is missing: `bun install -g @kaynooo/env-tools`.';
1219
1740
  function isEnvFile(path) {
1220
1741
  const name = path.slice(Math.max(path.lastIndexOf("/"), path.lastIndexOf(":")) + 1);
1221
- return (ENV_FILE.test(name) || name === ".env*") && !TEMPLATE.test(name);
1742
+ return (ENV_FILE.test(name) || name === ".env*" || name === ENVRC) && !TEMPLATE.test(name);
1743
+ }
1744
+ function globRegex(glob) {
1745
+ let source = "";
1746
+ for (let i = 0;i < glob.length; i++) {
1747
+ const char = glob[i] ?? "";
1748
+ const close = char === "[" ? glob.indexOf("]", i + 2) : -1;
1749
+ if (char === "*") {
1750
+ source += ".*";
1751
+ } else if (char === "?") {
1752
+ source += ".";
1753
+ } else if (close === -1) {
1754
+ source += char.replace(REGEX_SPECIALS, String.raw`\$&`);
1755
+ } else {
1756
+ source += `[${glob.slice(i + 1, close).replace(NEGATED_CLASS, "^")}]`;
1757
+ i = close;
1758
+ }
1759
+ }
1760
+ return new RegExp(`^${source}$`);
1222
1761
  }
1223
- function namesEnvFile(word, previous) {
1224
- if (previous === "--env-file" || word.startsWith("--env-file="))
1762
+ function globReachesEnv(path) {
1763
+ const name = path.slice(path.lastIndexOf("/") + 1);
1764
+ if (!name.startsWith(".") || !GLOB_CHAR.test(name))
1225
1765
  return false;
1766
+ const regex = globRegex(name);
1767
+ return ENV_SAMPLES.some((sample) => regex.test(sample));
1768
+ }
1769
+ function reachesEnvFile(path) {
1770
+ return braceExpand(path).some((each) => isEnvFile(each) || globReachesEnv(each));
1771
+ }
1772
+ function targetOf(word) {
1226
1773
  const bare = word.replace(REDIRECT, "");
1227
- return isEnvFile(bare.includes("=") ? bare.slice(bare.indexOf("=") + 1) : bare);
1774
+ return bare.includes("=") ? bare.slice(bare.indexOf("=") + 1) : bare;
1775
+ }
1776
+ function namesEnvFile(word, previous, isGlob) {
1777
+ if (previous === "--env-file" || word.startsWith("--env-file=") || word.startsWith("--exclude") || EXCLUDE_OPTIONS.has(previous ?? ""))
1778
+ return false;
1779
+ const path = targetOf(word);
1780
+ return isEnvFile(path) || PROC_ENVIRON.test(path) || isGlob && globReachesEnv(path);
1781
+ }
1782
+ function variablesOf(command) {
1783
+ const variables = new Map;
1784
+ const add = (assignment) => {
1785
+ const at = assignment.indexOf("=");
1786
+ const name = assignment.slice(0, at);
1787
+ variables.set(name, [...variables.get(name) ?? [], assignment.slice(at + 1)]);
1788
+ };
1789
+ for (const piece of splitCommand(command)) {
1790
+ const { program, args, assignments } = parseCommand(piece.command);
1791
+ const tokens = tokenize(piece.command);
1792
+ const declared = DECLARERS.has(program) ? args.filter((arg) => ASSIGNMENT2.test(arg)) : [];
1793
+ for (const assignment of [...assignments, ...declared])
1794
+ add(assignment);
1795
+ if (tokens[0] === "for" && tokens[2] === "in" && tokens[1] !== undefined)
1796
+ variables.set(tokens[1], [...variables.get(tokens[1]) ?? [], ...tokens.slice(3)]);
1797
+ }
1798
+ return variables;
1799
+ }
1800
+ function variantsOf(word, variables) {
1801
+ let found = [word.text];
1802
+ for (let round = 0;round < 4 && found.some((each) => VARIABLE.test(each)); round++) {
1803
+ found = found.flatMap((each) => {
1804
+ const match = VARIABLE.exec(each);
1805
+ const values = variables.get(match?.[1] ?? match?.[2] ?? "");
1806
+ if (match === null || values === undefined)
1807
+ return [each.replace(VARIABLE, "")];
1808
+ return values.map((value) => each.slice(0, match.index) + value + each.slice(match.index + match[0].length));
1809
+ }).slice(0, MAX_VARIANTS);
1810
+ }
1811
+ return found.flatMap((each) => word.hasBrace ? braceExpand(each) : [each]);
1812
+ }
1813
+ function codeReadsEnv(found) {
1814
+ return found.some((word, i) => (word.isProse || CODE_OPTIONS.has(found[i - 1]?.text ?? "")) && (PYTHON_ENVIRON.test(word.text) || [...word.text.matchAll(ENV_NAME_IN_CODE)].some((match) => isEnvFile(match[1] ?? ""))));
1228
1815
  }
1229
1816
  function firstOperand(words, valueOptions) {
1230
1817
  for (let i = 0;i < words.length; i++) {
@@ -1237,7 +1824,7 @@ function firstOperand(words, valueOptions) {
1237
1824
  return words.length;
1238
1825
  }
1239
1826
  function printsEnv([program = "", ...args]) {
1240
- if (SHELLS.has(program) && args[0] === "-c")
1827
+ if (SHELLS2.has(program) && args[0] === "-c")
1241
1828
  return splitCommand(args[1] ?? "").some((piece) => ENV_PRINTERS.has(tokenize(piece.command)[0] ?? ""));
1242
1829
  return ENV_PRINTERS.has(program);
1243
1830
  }
@@ -1259,24 +1846,79 @@ function dumpsContainerEnv(program, args) {
1259
1846
  const format = after.find((_, i) => FORMAT_OPTIONS.has(after[i - 1] ?? "")) ?? after.find((word) => word.startsWith("--format="))?.slice("--format=".length);
1260
1847
  return format === undefined || WHOLE_FORMAT.test(format);
1261
1848
  }
1262
- function readsEnv(piece) {
1849
+ function readsEnv(piece, variables) {
1263
1850
  const { program, args } = parseCommand(piece.command);
1264
1851
  const found = words(piece.command);
1265
1852
  if (program === "" || HARMLESS.has(program) || program === "git" && GIT_HARMLESS.has(args[0] ?? ""))
1266
1853
  return false;
1267
- return found.some((word, index) => !word.isProse && namesEnvFile(word.text, found[index - 1]?.text));
1854
+ if (INTERPRETERS.has(program) && codeReadsEnv(found))
1855
+ return true;
1856
+ const isExclusion = (word) => program === "git" && GIT_EXCLUDE.test(word.text);
1857
+ return found.some((word, index) => !word.isProse && !isExclusion(word) && variantsOf(word, variables).some((variant) => namesEnvFile(variant, found[index - 1]?.text, word.hasGlob)));
1268
1858
  }
1269
- function touchesEnv(command) {
1859
+ function operandsOf(args, valueOptions) {
1860
+ const operands = [];
1861
+ let hasPattern = false;
1862
+ for (let i = 0;i < args.length; i++) {
1863
+ const arg = args[i] ?? "";
1864
+ if (PATTERN_OPTIONS.has(arg) || arg.startsWith("--regexp=") || arg.startsWith("--file="))
1865
+ hasPattern = true;
1866
+ if (valueOptions.has(arg))
1867
+ i++;
1868
+ else if (!arg.startsWith("-"))
1869
+ operands.push(arg);
1870
+ }
1871
+ return hasPattern ? operands : operands.slice(1);
1872
+ }
1873
+ function searchesEnv(program, args) {
1874
+ const isGrep = GREPS.has(program);
1875
+ if (!isGrep && program !== "rg" && program !== "ag")
1876
+ return false;
1877
+ const isRecursive = args.some((arg) => SHORT_FLAGS.test(arg) && HAS_R.test(arg) || arg === "--recursive" || arg === "--dereference-recursive");
1878
+ const isHidden = args.some((arg) => HIDDEN_FLAGS.test(arg) || program === "ag" && (arg === "-u" || arg === "--unrestricted"));
1879
+ const isLimited = args.some((arg, i) => arg.startsWith("--include") || EXCLUDES_ENV.test(arg) || EXCLUDE_OPTIONS.has(args[i - 1] ?? "") && arg.includes("env"));
1880
+ if (isLimited || (isGrep ? !isRecursive : !isHidden))
1881
+ return false;
1882
+ const operands = operandsOf(args, isGrep ? GREP_VALUE_OPTIONS : RG_VALUE_OPTIONS);
1883
+ return operands.length === 0 || operands.some((operand) => WHOLE_TREE_OPERAND.test(operand));
1884
+ }
1885
+ function dumpsEnvironment(program, args, tokens) {
1886
+ const isBare = args.every((arg) => arg.startsWith("-"));
1887
+ if (program === "printenv" || program === "export")
1888
+ return isBare;
1889
+ if (program === "declare" || program === "typeset")
1890
+ return isBare && args.some((arg) => DUMP_FLAGS.test(arg));
1891
+ if (program === "set")
1892
+ return args.length === 0;
1893
+ return program === "" && tokens.some((token) => token.replace(OPENER, "") === "env");
1894
+ }
1895
+ function printsTrackedEnv(program, args) {
1896
+ if (program !== "git")
1897
+ return false;
1898
+ const at = args.findIndex((arg, i) => !arg.startsWith("-") && !GIT_VALUE_OPTIONS.has(args[i - 1] ?? ""));
1899
+ const name = args[at] ?? "";
1900
+ const rest = args.slice(at + 1);
1901
+ if (!GIT_PATCHES.has(name) || rest.some((arg) => GIT_EXCLUDE.test(arg)))
1902
+ return false;
1903
+ if (name === "grep")
1904
+ return true;
1905
+ if (name === "log" || name === "whatchanged")
1906
+ return rest.some((arg) => GIT_PATCH_FLAGS.has(arg));
1907
+ return !rest.some((arg) => GIT_NO_PATCH.has(arg));
1908
+ }
1909
+ function touchesEnv(command, options = {}) {
1270
1910
  if (ENV_DUMP.test(command))
1271
1911
  return true;
1272
- if (substitutions(command).some(touchesEnv))
1912
+ if (substitutions(command).some((each) => touchesEnv(each, options)))
1273
1913
  return true;
1274
1914
  const pieces = splitCommand(command);
1915
+ const variables = variablesOf(command);
1275
1916
  return pieces.some((piece, index) => {
1276
- if (readsEnv(piece))
1277
- return true;
1278
1917
  const { program, args } = parseCommand(piece.command);
1279
- if (!dumpsContainerEnv(program, args))
1918
+ if (readsEnv(piece, variables) || searchesEnv(program, args))
1919
+ return true;
1920
+ const isDump = dumpsContainerEnv(program, args) || dumpsEnvironment(program, args, tokenize(piece.command)) || options.hasTrackedEnv === true && printsTrackedEnv(program, args);
1921
+ if (!isDump)
1280
1922
  return false;
1281
1923
  for (let at = index;pieces[at]?.separator === "|"; at++) {
1282
1924
  if (parseCommand(pieces[at + 1]?.command ?? "").program === "redact")
@@ -1287,17 +1929,19 @@ function touchesEnv(command) {
1287
1929
  }
1288
1930
 
1289
1931
  // ../git/src/message.ts
1290
- var CONVENTIONAL = /^[a-z]+(?:\([^()\n]+\))?!?: \S/;
1932
+ var CONVENTIONAL = /^(?:feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(?:\([^()\n]+\))?!?: \S/;
1933
+ var MAX_TITLE = 100;
1291
1934
  var GIT_MADE = /^(?:Merge |Revert "|fixup! |squash! |amend! )/;
1292
1935
  var TICKET = /^(?:[\w.-]+\/[\w.-]+)?#\d+ - /;
1293
1936
  var BREAKING = /^BREAKING[ -]CHANGE: /;
1294
1937
  var BREAKING_TITLE = /^[a-z]+(?:\([^()\n]+\))?!: /;
1295
- var TRAILER = /^(?:co-authored-by|signed-off-by|reviewed-by|acked-by|tested-by|reported-by|suggested-by|helped-by|change-id):|^\W*generated with\b/i;
1938
+ var TRAILER = /^(?:co-authored-by|signed-off-by|reviewed-by|acked-by|tested-by|reported-by|suggested-by|helped-by|change-id):|^\W*generated with \W*claude/i;
1296
1939
  var BULLET = /^\s*(?:[-*+]|\d+[.)])\s/;
1297
1940
  var SENTENCE_END = /[.!?:;)\]`'"]$/;
1298
1941
  var LOWER_START = /^\p{Ll}/u;
1299
1942
  var RULE = 'The rule: a `type(scope): title` line, `!` after the scope for a breaking change, type and scope taken from the repository\'s history; then optionally a blank line and one or two lines on what changed, never a bullet list or a hard-wrapped sentence; then a blank line and one line per ticket, its title copied verbatim, `#<n> - <title>` (`owner/repo#<n> - <title>` for another repository). The message ends there: no Co-Authored-By, Signed-off-by or "Generated with" trailer. Only a breaking change gives full details.';
1300
1943
  var TITLE = `The title is no Conventional Commit. ${RULE}`;
1944
+ var TITLE_TOO_LONG = `The title runs past ${MAX_TITLE} characters: shorten it and move the detail to the body. ${RULE}`;
1301
1945
  var TITLE_LINE = `The title takes one line and a blank line follows it. ${RULE}`;
1302
1946
  var TRAILERS = `No trailers, ever: no Co-Authored-By, Signed-off-by or "Generated with" line, no --signoff or --trailer. ${RULE}`;
1303
1947
  var BULLETS = `The body names what changed in a line or two, never a bullet list narrating the diff. ${RULE}`;
@@ -1323,20 +1967,26 @@ function commitMessageViolation(message) {
1323
1967
  return TITLE;
1324
1968
  if (second !== undefined && second !== "")
1325
1969
  return TITLE_LINE;
1326
- if (BREAKING_TITLE.test(title) || body.some((line) => BREAKING.test(line)))
1970
+ if (!GIT_MADE.test(title) && title.length > MAX_TITLE)
1971
+ return TITLE_TOO_LONG;
1972
+ if (BREAKING_TITLE.test(title))
1327
1973
  return null;
1328
- const descriptions = body.filter(isDescription);
1974
+ const footer = body.findIndex((line) => BREAKING.test(line));
1975
+ const described = footer === -1 ? body : body.slice(0, footer);
1976
+ const descriptions = described.filter(isDescription);
1329
1977
  if (descriptions.some((line) => BULLET.test(line)))
1330
1978
  return BULLETS;
1331
1979
  if (descriptions.length > 2)
1332
1980
  return TOO_LONG;
1333
- const isWrapped = body.some((line, i) => isDescription(line) && isDescription(body[i + 1]) && !SENTENCE_END.test(line) && LOWER_START.test(body[i + 1] ?? ""));
1981
+ const isWrapped = described.some((line, i) => isDescription(line) && isDescription(described[i + 1]) && !SENTENCE_END.test(line) && LOWER_START.test(described[i + 1] ?? ""));
1334
1982
  return isWrapped ? WRAPPED : null;
1335
1983
  }
1336
1984
 
1337
1985
  // ../out/src/wrap.ts
1338
- var ASSIGNMENT2 = /^\w+=/;
1339
- var EXCLUDED = /(?:^|\s)(?:--json|--help|--version|-h)(?:\s|$)/;
1986
+ var ASSIGNMENT3 = /^\w+=/;
1987
+ var EXCLUDED = /(?:^|\s)(?:--json|--help|--version|-h|--watch(?:-?all|=\S*)?|--watchAll)(?:\s|$)/;
1988
+ var SHORT_WATCH = /(?:^|\s)(?:\S*\/)?(?:tsc|tsgo|vue-tsc|vitest|jest)\s(?:.*\s)?-w(?:\s|$)/;
1989
+ var LONG_RUNNING = /^(?:dev|watch|serve|start|up|run|preview|storybook)(?:[:-]\S*)?$/;
1340
1990
  var BUN_SCRIPT = /^(?:build|check|lint|typecheck|types|test|ci|verify|compile)(?::|$)/;
1341
1991
  var BUN_TOOLS = new Set(["tsc", "tsgo", "eslint", "keslint", "vitest", "jest", "prettier", "playwright", "biome", "vue-tsc"]);
1342
1992
  var BUN_SUBCOMMANDS = new Set(["install", "i", "add", "remove", "rm", "update", "test", "check", "lint", "build"]);
@@ -1372,9 +2022,9 @@ var RULES = {
1372
2022
  "php-cs-fixer": always,
1373
2023
  rector: always,
1374
2024
  php: (args) => listed(afterOptions(args, PHP_VALUE_OPTIONS)),
1375
- make: always,
2025
+ make: ([first]) => first === undefined || !LONG_RUNNING.test(first),
1376
2026
  check: always,
1377
- just: ([first]) => first !== undefined && !first.startsWith("-"),
2027
+ just: ([first]) => first !== undefined && !first.startsWith("-") && !LONG_RUNNING.test(first),
1378
2028
  composer: ([sub]) => sub === "install" || sub === "update" || sub === "require",
1379
2029
  pip: ([sub]) => sub === "install",
1380
2030
  cargo: ([sub]) => ["build", "test", "check", "clippy"].includes(sub ?? ""),
@@ -1432,14 +2082,14 @@ function withoutFilters(command) {
1432
2082
  return rest;
1433
2083
  }
1434
2084
  function listed(words) {
1435
- const start = words.findIndex((word) => !ASSIGNMENT2.test(word));
2085
+ const start = words.findIndex((word) => !ASSIGNMENT3.test(word));
1436
2086
  const path = words[start];
1437
2087
  if (path === undefined)
1438
2088
  return false;
1439
2089
  return RULES[path.slice(path.lastIndexOf("/") + 1)]?.(words.slice(start + 1)) ?? false;
1440
2090
  }
1441
2091
  function listedSegment(segment) {
1442
- return !EXCLUDED.test(segment) && listed(tokenize(segment));
2092
+ return !EXCLUDED.test(segment) && !SHORT_WATCH.test(segment) && listed(tokenize(segment));
1443
2093
  }
1444
2094
  function isCd(segment) {
1445
2095
  return segment === "cd" || segment.startsWith("cd ");
@@ -1450,14 +2100,14 @@ function shouldWrap(command) {
1450
2100
  return false;
1451
2101
  return segments.every((segment) => isCd(segment) || listedSegment(segment));
1452
2102
  }
1453
- function quoted(value) {
2103
+ function quoted2(value) {
1454
2104
  return `'${value.replaceAll("'", "'\\''")}'`;
1455
2105
  }
1456
2106
  function wrap(command) {
1457
2107
  const trimmed = withoutFilters(command.trim());
1458
- if (scan(trimmed).segments.length <= 1 && !ASSIGNMENT2.test(trimmed))
2108
+ if (scan(trimmed).segments.length <= 1 && !ASSIGNMENT3.test(trimmed))
1459
2109
  return `out -- ${trimmed}`;
1460
- return `out -- sh -c ${quoted(trimmed)}`;
2110
+ return `out -- sh -c ${quoted2(trimmed)}`;
1461
2111
  }
1462
2112
 
1463
2113
  // ../pkg/src/added.ts
@@ -1466,10 +2116,18 @@ var BARE_REDIRECT = /^\d*(?:[<>]|>>)$/;
1466
2116
  var ADDS = {
1467
2117
  bun: new Set(["add", "a", "install", "i"]),
1468
2118
  npm: new Set(["install", "i", "add", "in", "isntall"]),
1469
- pnpm: new Set(["add"]),
2119
+ pnpm: new Set(["add", "i", "install"]),
1470
2120
  yarn: new Set(["add"])
1471
2121
  };
1472
- var VALUE_FLAGS = new Set(["--cwd", "--filter", "-F", "--workspace", "--registry", "--prefix", "-C", "--dir", "--config", "--cache-dir"]);
2122
+ var RUNS = {
2123
+ bun: new Set(["x"]),
2124
+ npm: new Set(["exec", "x"]),
2125
+ pnpm: new Set(["dlx"]),
2126
+ yarn: new Set(["dlx"])
2127
+ };
2128
+ var RUNNERS = new Set(["bunx", "npx"]);
2129
+ var PACKAGE_FLAGS = new Set(["-p", "--package"]);
2130
+ var VALUE_FLAGS = new Set(["--cwd", "--filter", "-F", "--workspace", "--registry", "--prefix", "-C", "--dir", "--config", "--cache-dir", "--loglevel", "--tag", "--save-prefix", "--userconfig", "--omit", "--include", "--fetch-timeout", "--fetch-retries", "--install-strategy", "--cache", ...PACKAGE_FLAGS]);
1473
2131
  var NOT_REGISTRY = /^(?:[./~]|file:|link:|portal:|workspace:|git[+:]|github:|gitlab:|bitbucket:|https?:)|\.tgz$|^[\w.-]+\/[\w.-]+(?:#.*)?$/;
1474
2132
  var NPM_ALIAS = /^(?:@[^/]+\/)?[^@]+@npm:(.+)$/;
1475
2133
  var PUBLISH = /\b(?:ft|cli\.ts)\s+release\b|\b(?:bun|npm|pnpm|yarn)\s+publish\b|\bjust\s+(?:patch|minor|major)\b/;
@@ -1479,16 +2137,38 @@ function addedPackages(command) {
1479
2137
  published ||= PUBLISH.test(piece.command);
1480
2138
  const { program, args } = parseCommand(piece.command);
1481
2139
  const [sub = "", ...rest] = args;
1482
- if (published || !ADDS[program]?.has(sub))
2140
+ if (published)
2141
+ return [];
2142
+ if (RUNNERS.has(program))
2143
+ return runnerPackages(args);
2144
+ if (RUNS[program]?.has(sub))
2145
+ return runnerPackages(rest);
2146
+ if (!ADDS[program]?.has(sub))
1483
2147
  return [];
1484
- return rest.flatMap((word, index) => {
1485
- const previous = rest[index - 1] ?? "";
1486
- if (word.startsWith("-") || REDIRECT2.test(word) || VALUE_FLAGS.has(previous) || BARE_REDIRECT.test(previous) || NOT_REGISTRY.test(word))
1487
- return [];
1488
- return [NPM_ALIAS.exec(word)?.[1] ?? word];
1489
- });
2148
+ return rest.flatMap((word, index) => isSkipped(word, rest[index - 1] ?? "") ? [] : registryName(word));
1490
2149
  });
1491
2150
  }
2151
+ function isSkipped(word, previous) {
2152
+ return word.startsWith("-") || REDIRECT2.test(word) || VALUE_FLAGS.has(previous) || BARE_REDIRECT.test(previous);
2153
+ }
2154
+ function registryName(word) {
2155
+ return NOT_REGISTRY.test(word) ? [] : [NPM_ALIAS.exec(word)?.[1] ?? word];
2156
+ }
2157
+ function runnerPackages(words) {
2158
+ const named = [];
2159
+ for (const [index, word] of words.entries()) {
2160
+ const previous = words[index - 1] ?? "";
2161
+ if (PACKAGE_FLAGS.has(previous))
2162
+ named.push(word);
2163
+ else if (word.startsWith("--package="))
2164
+ named.push(word.slice("--package=".length));
2165
+ else if (!isSkipped(word, previous) && named.length === 0)
2166
+ return registryName(word);
2167
+ else if (!isSkipped(word, previous))
2168
+ break;
2169
+ }
2170
+ return named.flatMap(registryName);
2171
+ }
1492
2172
 
1493
2173
  // ../pkg/src/pin.ts
1494
2174
  var LOCKFILES = [
@@ -1617,11 +2297,16 @@ var COMMIT_VALUE_LONGS = new Set(["--message", "--file", "--reuse-message", "--r
1617
2297
  var SHORT_CLUSTER = /^-[A-Z]+$/i;
1618
2298
  var STAGES_ALL = /[Au]/;
1619
2299
  var ALL_TAGS = new Set(["--tags", "--follow-tags"]);
2300
+ var TOP_MAGIC = /^:(?:\/|\([^)]*\))/;
2301
+ var RISKY_CONFIG = /^(?:alias\.|core\.(?:sshcommand|hookspath|pager|editor|fsmonitor|askpass|gitproxy)|credential\.|diff\.external|sequence\.editor|pager\.|filter\.|gpg\..*program)/i;
2302
+ var RISKY_ENV = /^GIT_(?:CONFIG_(?:COUNT|PARAMETERS)|EXTERNAL_DIFF|ASKPASS|PROXY_COMMAND|PAGER)=/;
2303
+ var READS_CONFIG = new Set(["--get", "--get-all", "--get-regexp", "--list", "-l", "--unset", "--unset-all", "--show-origin", "--show-scope"]);
1620
2304
  var STAGE_BY_PATH = `Stage by path: the tree carries unrelated work and peer sessions edit it live. ${USE_GC}`;
1621
2305
  var STASH = "Never `git stash`: a pop with nothing saved applies someone else's old stash. To prove a test fails without your change, `proof --keep <test> -- <cmd>` runs it at HEAD in a temporary worktree and again here; `base -- <cmd>` runs any command at HEAD (`--ref <rev>` another commit), the working tree untouched. To set edits aside before a cherry-pick, a rebase or a checkout, commit them with `gc` first, or do the other work in `git worktree add`.";
1622
2306
  var ADD_ALL = `\`git add\` over the whole tree is refused. ${STAGE_BY_PATH}`;
1623
2307
  var COMMIT_ALL = `\`git commit -a\` stages the whole tree and is refused. ${STAGE_BY_PATH}`;
1624
2308
  var PUSH = "`git push` runs only when the user asked for a push in their latest prompt: say the commit is ready and let them ask.";
2309
+ var CONFIG_OVERRIDE = "Never set git config that runs a program or defines a command (`-c alias.*`, `--config-env`, `core.sshCommand`, `core.hooksPath`, `core.pager`, credential helpers, `git config alias.*`): it sidesteps every rule here. Run the git command itself.";
1625
2310
  var TAGS_APART = "A tag goes out with its branch in one `git push --atomic origin <branch> <tag>`, never a separate `git push --tags`.";
1626
2311
  function subcommandOf(args) {
1627
2312
  for (let i = 0;i < args.length; i++) {
@@ -1633,6 +2318,35 @@ function subcommandOf(args) {
1633
2318
  }
1634
2319
  return { name: "", rest: [] };
1635
2320
  }
2321
+ function overridesConfig(args) {
2322
+ for (let i = 0;i < args.length; i++) {
2323
+ const arg = args[i] ?? "";
2324
+ if (arg === "--config-env" || arg.startsWith("--config-env="))
2325
+ return true;
2326
+ if (arg === "-c" && RISKY_CONFIG.test(args[i + 1] ?? ""))
2327
+ return true;
2328
+ if (GLOBAL_VALUE_OPTIONS.has(arg))
2329
+ i++;
2330
+ else if (!arg.startsWith("-"))
2331
+ return false;
2332
+ }
2333
+ return false;
2334
+ }
2335
+ function isWholeTree(arg) {
2336
+ if (WHOLE_TREE.has(arg))
2337
+ return true;
2338
+ if (arg.startsWith("-"))
2339
+ return SHORT_CLUSTER.test(arg) && STAGES_ALL.test(arg);
2340
+ const parts = [];
2341
+ for (const part of arg.replace(TOP_MAGIC, "").split("/")) {
2342
+ if (part === ".." && parts.length > 0 && parts.at(-1) !== "..")
2343
+ parts.pop();
2344
+ else if (part !== "" && part !== ".")
2345
+ parts.push(part);
2346
+ }
2347
+ const path = parts.join("/");
2348
+ return path === "" || path === ".." || WHOLE_TREE.has(path);
2349
+ }
1636
2350
  function commitsAll(args) {
1637
2351
  for (let i = 0;i < args.length; i++) {
1638
2352
  const arg = args[i] ?? "";
@@ -1660,13 +2374,19 @@ function commitsAll(args) {
1660
2374
  }
1661
2375
  function gitViolation(command, isPushAsked = false) {
1662
2376
  for (const piece of splitCommand(command)) {
1663
- const { program, args } = parseCommand(piece.command);
2377
+ const { program, args, assignments } = parseCommand(piece.command);
2378
+ if (assignments.some((assignment) => RISKY_ENV.test(assignment)))
2379
+ return CONFIG_OVERRIDE;
1664
2380
  if (program !== "git")
1665
2381
  continue;
2382
+ if (overridesConfig(args))
2383
+ return CONFIG_OVERRIDE;
1666
2384
  const { name, rest } = subcommandOf(args);
2385
+ if (name === "config" && rest.length > 1 && rest.some((arg) => RISKY_CONFIG.test(arg)) && !rest.some((arg) => READS_CONFIG.has(arg)))
2386
+ return CONFIG_OVERRIDE;
1667
2387
  if (name === "stash" && !READ_ONLY_STASH.has(rest[0] ?? ""))
1668
2388
  return STASH;
1669
- if (name === "add" && rest.some((arg) => WHOLE_TREE.has(arg) || SHORT_CLUSTER.test(arg) && STAGES_ALL.test(arg)))
2389
+ if (name === "add" && rest.some(isWholeTree))
1670
2390
  return ADD_ALL;
1671
2391
  if (name === "commit" && commitsAll(rest))
1672
2392
  return COMMIT_ALL;
@@ -1767,19 +2487,63 @@ function inlineMessage(parts) {
1767
2487
  }
1768
2488
 
1769
2489
  // src/remote.ts
1770
- var SHELL_PROGRAMS = new Set(["ssh", "scp", "sftp", "mosh", "autossh"]);
2490
+ var SHELL_PROGRAMS = new Set(["ssh", "scp", "sftp", "mosh", "mosh-client", "autossh"]);
2491
+ var SOCKET_PROGRAMS = new Set(["nc", "ncat", "netcat", "telnet", "socat"]);
2492
+ var URL_PROGRAMS = new Set(["git", "docker", "docker-compose"]);
2493
+ var DECLARERS2 = new Set(["export", "declare", "typeset"]);
1771
2494
  var RSYNC_REMOTE = /^(?:[\w.-]+@)?[\w.-]{2,}:/;
1772
2495
  var SSH_URL = /^ssh:\/\//;
2496
+ var SSH_URL_WORD = /^(?:[\w-]+=)?ssh:\/\//;
2497
+ var LOOPBACK = /(?:^|[:=@[])(?:localhost|127(?:\.\d+){3}|::1)(?:[:\]/]|$)/;
2498
+ var LISTENS = /^-[A-KM-Z]*L[A-Z]*$/i;
2499
+ var DOCKER_VALUE_OPTIONS2 = new Set(["-c", "--context", "-H", "--host", "-l", "--log-level", "--config", "--tlscacert", "--tlscert", "--tlskey"]);
2500
+ var CONTEXT_CHANGES = new Set(["use", "create", "update", "import"]);
2501
+ var KUBECTL_SERVER = /^(?:--server(?:=|$)|-s$)/;
2502
+ var REMOTE_VARIABLE = /^GIT_SSH(?:_COMMAND)?=/;
1773
2503
  var REMOTE = "Remote servers are off limits (no SSH, no deploys, no remote commands). Hand the user the exact command to run themselves.";
2504
+ function dockerContext(args) {
2505
+ let context = null;
2506
+ for (let i = 0;i < args.length; i++) {
2507
+ const arg = args[i] ?? "";
2508
+ if (arg.startsWith("--context="))
2509
+ context = arg.slice("--context=".length);
2510
+ else if (arg === "-c" || arg === "--context")
2511
+ context = args[++i] ?? null;
2512
+ else if (DOCKER_VALUE_OPTIONS2.has(arg))
2513
+ i++;
2514
+ else if (!arg.startsWith("-"))
2515
+ return { context, name: arg, rest: args.slice(i + 1) };
2516
+ }
2517
+ return { context, name: "", rest: [] };
2518
+ }
2519
+ function isDockerRemote(args, assignments) {
2520
+ const { context, name, rest } = dockerContext(args);
2521
+ const fromEnv = assignments.find((assignment) => assignment.startsWith("DOCKER_CONTEXT="))?.slice("DOCKER_CONTEXT=".length);
2522
+ if ([context, fromEnv].some((each) => each !== null && each !== undefined && each !== "default"))
2523
+ return true;
2524
+ return name === "context" && CONTEXT_CHANGES.has(rest[0] ?? "");
2525
+ }
2526
+ function isSocketRemote(args) {
2527
+ return !args.some((arg) => LISTENS.test(arg) || LOOPBACK.test(arg));
2528
+ }
1774
2529
  function isRemote(text) {
1775
2530
  const { program, args, assignments } = parseCommand(text);
2531
+ const declared = DECLARERS2.has(program) ? args : [];
1776
2532
  if (SHELL_PROGRAMS.has(program))
1777
2533
  return true;
2534
+ if (SOCKET_PROGRAMS.has(program))
2535
+ return isSocketRemote(args);
1778
2536
  if (program === "rsync")
1779
2537
  return args.some((arg) => !arg.startsWith("-") && RSYNC_REMOTE.test(arg));
1780
- if (assignments.some((assignment) => assignment.startsWith("DOCKER_HOST=") && SSH_URL.test(assignment.slice("DOCKER_HOST=".length))))
2538
+ if (program === "kubectl")
2539
+ return args.some((arg) => KUBECTL_SERVER.test(arg));
2540
+ if ([...assignments, ...declared].some((assignment) => REMOTE_VARIABLE.test(assignment)))
2541
+ return true;
2542
+ if ([...assignments, ...declared].some((assignment) => assignment.startsWith("DOCKER_HOST=") && SSH_URL.test(assignment.slice("DOCKER_HOST=".length))))
1781
2543
  return true;
1782
- return program === "docker" && tokenize(text).some((word) => SSH_URL.test(word));
2544
+ if (program === "docker" && isDockerRemote(args, assignments))
2545
+ return true;
2546
+ return URL_PROGRAMS.has(program) && tokenize(text).some((word) => SSH_URL_WORD.test(word));
1783
2547
  }
1784
2548
  function remoteViolation(command) {
1785
2549
  return splitCommand(command).some((piece) => isRemote(piece.command)) ? REMOTE : null;
@@ -2110,6 +2874,7 @@ function replaceViolation(command) {
2110
2874
  }
2111
2875
 
2112
2876
  // src/check.ts
2877
+ var UNREADABLE_MESSAGE = "The commit message cannot be read here (a variable, a substitution, a missing file or no heredoc on stdin), so it cannot be checked. Write it literally with `-m` or a `$(cat <<'EOF' ... EOF)` heredoc.";
2113
2878
  function absolute(path, cwd) {
2114
2879
  return path.startsWith("/") ? path : `${cwd}/${path}`;
2115
2880
  }
@@ -2125,6 +2890,8 @@ function commitViolation(command, { cwd, read }) {
2125
2890
  message = inlineMessage(source.parts);
2126
2891
  else if (file !== "-")
2127
2892
  message = read(absolute(file, cwd));
2893
+ if (message === null && (source.parts.length > 0 || file !== null))
2894
+ return UNREADABLE_MESSAGE;
2128
2895
  const violation = message === null ? null : commitMessageViolation(message);
2129
2896
  if (violation !== null)
2130
2897
  return violation;
@@ -2140,9 +2907,6 @@ function pinnedManagers(start, exists) {
2140
2907
  return null;
2141
2908
  }
2142
2909
  }
2143
- function commandsRun(command) {
2144
- return [command, ...substitutions(command).flatMap(commandsRun)];
2145
- }
2146
2910
  function mismatches(command, { cwd, home, exists }) {
2147
2911
  return installRequests(command, cwd, home).flatMap((request) => {
2148
2912
  const mismatch = mismatchFor(request, pinnedManagers(request.dir, exists));
@@ -2158,13 +2922,22 @@ async function checkCommand(command, context) {
2158
2922
  };
2159
2923
  const run = commandsRun(command);
2160
2924
  const first = (messages) => messages.find((message) => message !== null) ?? null;
2161
- add("remote", first(run.map(remoteViolation)));
2162
- add("git", first(run.map((each) => gitViolation(each, context.isPushAsked))));
2163
- const commit = commitViolation(command, context);
2925
+ const guarded = async (rule, check) => {
2926
+ try {
2927
+ return await check();
2928
+ } catch (error) {
2929
+ context.onError?.(rule, error, command);
2930
+ return `The ${rule} rule crashed on this command (${error instanceof Error ? error.message : String(error)}), so it is refused. Simplify the command or run it yourself.`;
2931
+ }
2932
+ };
2933
+ const readSql = async (path) => Promise.resolve(context.read(absolute(path, context.cwd)));
2934
+ add("remote", await guarded("remote", () => first(run.map(remoteViolation))));
2935
+ add("git", await guarded("git", () => first(run.map((each) => gitViolation(each, context.isPushAsked)))));
2936
+ const commit = await guarded("commit", () => first(run.map((each) => commitViolation(each, context))));
2164
2937
  add("commit", commit === null ? null : `${commit} ${USE_GC}`);
2165
- add("sql", first(await Promise.all(run.map(async (each) => sqlViolation(each, async (path) => Promise.resolve(context.read(absolute(path, context.cwd))))))));
2166
- add("env", touchesEnv(command) ? USE_EV : null);
2167
- add("edit", context.canSub ? first(run.map(replaceViolation)) : null);
2938
+ add("sql", await guarded("sql", async () => first(await Promise.all(run.map(async (each) => sqlViolation(each, readSql))))));
2939
+ add("env", await guarded("env", () => run.some((each) => touchesEnv(each, { hasTrackedEnv: context.hasTrackedEnv === true })) ? USE_EV : null));
2940
+ add("edit", context.canSub ? await guarded("edit", () => first(run.map(replaceViolation))) : null);
2168
2941
  const found = mismatches(command, context);
2169
2942
  const refused = found.find((mismatch) => mismatch.rewrite === null || !context.canRewrite);
2170
2943
  const rewrite = refused?.rewrite ?? null;
@@ -2177,10 +2950,10 @@ async function checkCommand(command, context) {
2177
2950
  }
2178
2951
  if (context.canRewrite && context.canWrap && shouldWrap(rewritten))
2179
2952
  rewritten = wrap(rewritten);
2180
- return { violations, command: violations.length > 0 ? command : rewritten, notes: violations.length > 0 ? [] : notes, packages: addedPackages(command) };
2953
+ return { violations, command: violations.length > 0 ? command : rewritten, notes: violations.length > 0 ? [] : notes, packages: [...new Set(run.flatMap(addedPackages))] };
2181
2954
  }
2182
2955
  function checkPath(path) {
2183
- return isEnvFile(path) ? { rule: "env", message: USE_EV } : null;
2956
+ return reachesEnvFile(path) ? { rule: "env", message: USE_EV } : null;
2184
2957
  }
2185
2958
 
2186
2959
  // src/bins.ts
@@ -2189,7 +2962,7 @@ var BINS = {
2189
2962
  fix: "`fix`: runs the repo's fixers and formatters (its fix, lint:fix and format scripts, just fmt, go fmt, cargo fmt, ruff) and lists the files they changed.",
2190
2963
  errs: "`errs [log]`, `errs -- <cmd>`: turns tsc, tsgo, eslint, bun check and bun test output into one `file:line:col: message (rule)` line per error, counting the lines it did not recognise (`--json`); use it instead of reading raw type, lint or test logs.",
2191
2964
  db: '`db "<sql>"`: one read-only statement against the Postgres in DATABASE_URL (`-e KEY` for another) or, without one, the compose project\'s running Postgres service (`-s <service>`, `-d <database>`), credentials never shown, a write printed for the user to run; `db tables`, `db describe <table>`, `db dbs`.',
2192
- ev: "`ev`: .env files read and edited with values masked (`ev list`, `ev get KEY`, `ev grep <regex>`, `ev show <rev>:<file>`, `ev set`, `ev diff`, `ev missing`); `ev audit [dir]` finds env files others can read or git tracks, and compose files writing secrets out.",
2965
+ ev: "`ev`: .env files read and edited with values masked (`ev list`, `ev get KEY`, `ev grep <regex>`, `ev show <rev>:<file>`, `ev set`, `ev diff`, `ev missing`); `ev audit [dir]` finds env files others can read or git tracks, and compose files writing secrets out; `ev pins [dir]` lists the env vars reaching outside that the tests of an app leave unpinned.",
2193
2966
  redact: "`redact`: masks secrets in piped output, `<cmd> | redact`.",
2194
2967
  pkg: "`pkg <name>[@version]`: checks an npm package before it is added: whether the name exists or is a near miss of a popular one, the newest version at least two weeks old, licence, install scripts; `bun add`, `npm install`, `pnpm add` and `yarn add` run it first and are refused on what it flags. `pkg bump <@scope/lib> [version]` waits until the registry serves a release, then `bun add --exact`s it in every consumer under ~/Workspace (`--dry-run`), `pkg consumers <name>` lists them; use it instead of a `bun info` and sleep loop and a `bun add` per repo.",
2195
2968
  stack: "`stack port <name>`: the port a service name always hashes to, for every host port a compose file publishes; `stack audit [dir]` cross-checks release.config.ts against its compose files.",
@@ -2219,9 +2992,21 @@ ${lines.join(`
2219
2992
 
2220
2993
  // src/hook.ts
2221
2994
  var EVENTS = ["pre-tool-use", "user-prompt-submit", "session-start"];
2222
- var PUSH_WORDS = /\b(?:push|pousse)/i;
2995
+ var PUSH_WORD = /\b(?:push|pousser?|poussez)\b/i;
2996
+ var PUSH_NOUN = /\bpush[\s-]+(?:notifications?|messages?|tokens?|events?|services?|apis?|subscriptions?|workers?|servers?)\b/gi;
2997
+ var NOT_A_REQUEST = /\b(?:don'?t|do not|never|not|no|without|avoid|stop|can'?t|cannot|won'?t|shouldn'?t|did|didn'?t|does|doesn'?t|already|ne|pas|jamais|sans|surtout)\b/i;
2998
+ var CLAUSE_BREAKS = /[.!?;,\n]+/;
2223
2999
  var SESSION_ID = /^[\w-]+$/;
2224
- var FILE_TOOLS = new Set(["Read", "Edit", "Write"]);
3000
+ var FILE_TOOLS = new Map([["Read", "file_path"], ["Edit", "file_path"], ["MultiEdit", "file_path"], ["Write", "file_path"], ["NotebookEdit", "notebook_path"], ["Grep", "path"], ["Glob", "path"]]);
3001
+ var GIT_WORD = /\bgit\b/;
3002
+ var GIT_PRINT_WORD = /\b(?:diff|log|show|whatchanged|format-patch|grep)\b/;
3003
+ var TRACKED_ENV = [":(glob)**/.env", ":(glob)**/.env.*", ":(glob)**/.envrc"];
3004
+ function asksForPush(prompt) {
3005
+ return prompt.split(CLAUSE_BREAKS).some((clause) => {
3006
+ const asked = clause.replace(PUSH_NOUN, "");
3007
+ return PUSH_WORD.test(asked) && !NOT_A_REQUEST.test(asked);
3008
+ });
3009
+ }
2225
3010
  function pushFile(sessionId, { stateDir }) {
2226
3011
  return sessionId !== undefined && SESSION_ID.test(sessionId) ? `${stateDir}/push-asked/${sessionId}` : null;
2227
3012
  }
@@ -2243,25 +3028,36 @@ async function bashCall(command, input, machine) {
2243
3028
  canWrap: false,
2244
3029
  canSub: machine.has("sub"),
2245
3030
  read: machine.read,
2246
- exists: machine.exists
3031
+ exists: machine.exists,
3032
+ hasTrackedEnv: GIT_WORD.test(command) && GIT_PRINT_WORD.test(command) && await hasTrackedEnv(cwd, machine),
3033
+ onError: (rule, error, each) => machine.log?.(`${new Date().toISOString()} ${rule} crashed: ${error instanceof Error ? error.stack ?? error.message : String(error)}
3034
+ command: ${each}
3035
+ `)
2247
3036
  });
2248
3037
  if (verdict.violations.length > 0 || verdict.packages.length === 0 || !machine.has("pkg"))
2249
3038
  return deny(verdict.violations);
2250
3039
  const checked = await machine.run(["pkg", "check", ...verdict.packages], cwd);
2251
- return checked.code === 1 ? deny([{ rule: "pkg", message: `pkg refused what this adds, so it was not run:
2252
- ${checked.text.trim()}` }]) : null;
3040
+ if (checked.code === 0)
3041
+ return null;
3042
+ const message = checked.code === 1 ? "pkg refused what this adds, so it was not run" : `pkg check failed (exit ${checked.code}), so what this adds was not checked and it was not run`;
3043
+ return deny([{ rule: "pkg", message: `${message}:
3044
+ ${checked.text.trim()}` }]);
3045
+ }
3046
+ async function hasTrackedEnv(cwd, machine) {
3047
+ const listed = await machine.run(["git", "ls-files", "-z", "--", ...TRACKED_ENV], cwd);
3048
+ return listed.code === 0 && listed.text.split("\x00").some(isEnvFile);
2253
3049
  }
2254
3050
  function preToolUse(input, machine) {
2255
3051
  const { tool_name: tool, tool_input: given = {} } = input;
2256
3052
  if (tool === "Bash" && typeof given.command === "string")
2257
3053
  return bashCall(given.command, input, machine);
2258
- if (tool !== undefined && FILE_TOOLS.has(tool) && typeof given.file_path === "string")
2259
- return deny([checkPath(given.file_path)].filter((each) => each !== null));
2260
- return null;
3054
+ const field = tool === undefined ? undefined : FILE_TOOLS.get(tool);
3055
+ const paths = [field === undefined ? undefined : given[field], tool === "Grep" ? given.glob : undefined];
3056
+ return deny(paths.flatMap((path) => typeof path === "string" ? [checkPath(path)] : []).filter((each) => each !== null));
2261
3057
  }
2262
3058
  function userPromptSubmit(input, machine) {
2263
3059
  const file = pushFile(input.session_id, machine);
2264
- if (file !== null && PUSH_WORDS.test(input.prompt ?? ""))
3060
+ if (file !== null && asksForPush(input.prompt ?? ""))
2265
3061
  machine.write(file, "");
2266
3062
  else if (file !== null)
2267
3063
  machine.remove(file);
@@ -2302,13 +3098,15 @@ var pathOptions = {
2302
3098
  var hookUsage = `guard hook <${EVENTS.join("|")}>
2303
3099
 
2304
3100
  Answers a Claude Code hook: reads the event JSON on stdin and prints the reply JSON, nothing when the call goes ahead untouched.
2305
- pre-tool-use refuses a Bash call breaking a rule, an install pkg refuses, and a Read, Edit or Write of an env file; user-prompt-submit remembers whether the prompt asked for a push; session-start lists the installed agent-tools bins.
3101
+ pre-tool-use refuses a Bash call breaking a rule, an install pkg refuses (or fails to check), and a file tool (Read, Edit, MultiEdit, Write, NotebookEdit, Grep, Glob) reaching an env file; user-prompt-submit remembers whether the prompt asked for a push; session-start lists the installed agent-tools bins.
2306
3102
  A failure exits 1, which Claude Code reports without blocking the call.
2307
3103
 
2308
3104
  Exits 0 when it answered, 1 on a failure or an unknown event (never 2, which would block the call).`;
3105
+ var MAX_READ = 1e6;
2309
3106
  function readText(path) {
2310
3107
  try {
2311
- return readFileSync(path, "utf8");
3108
+ const stats = statSync(path, { throwIfNoEntry: false });
3109
+ return stats?.isFile() === true && stats.size <= MAX_READ ? readFileSync(path, "utf8") : null;
2312
3110
  } catch {
2313
3111
  return null;
2314
3112
  }
@@ -2327,7 +3125,7 @@ ${checkUsage}`);
2327
3125
  const given = positionals[0] ?? "-";
2328
3126
  const command = given === "-" ? await readInput("-", { noStdin: "give the command, or pipe it on stdin" }) : given;
2329
3127
  const context = {
2330
- cwd: values.cwd ?? process6.cwd(),
3128
+ cwd: values.cwd ?? process7.cwd(),
2331
3129
  home: homedir(),
2332
3130
  isPushAsked: values["push-asked"] === true,
2333
3131
  canRewrite: true,
@@ -2361,9 +3159,10 @@ ${pathUsage}`);
2361
3159
  }
2362
3160
  function machine() {
2363
3161
  const home = homedir();
3162
+ const stateDir = `${process7.env.XDG_STATE_HOME ?? `${home}/.local/state`}/guard`;
2364
3163
  return {
2365
3164
  home,
2366
- stateDir: `${process6.env.XDG_STATE_HOME ?? `${home}/.local/state`}/guard`,
3165
+ stateDir,
2367
3166
  has: (bin) => Bun.which(bin) !== null,
2368
3167
  run: async (argv, cwd) => runCommand(argv, { cwd }),
2369
3168
  read: readText,
@@ -2372,7 +3171,13 @@ function machine() {
2372
3171
  mkdirSync(dirname(path), { recursive: true });
2373
3172
  writeFileSync(path, text);
2374
3173
  },
2375
- remove: (path) => rmSync(path, { force: true })
3174
+ remove: (path) => rmSync(path, { force: true }),
3175
+ log: (text) => {
3176
+ try {
3177
+ mkdirSync(stateDir, { recursive: true });
3178
+ appendFileSync(`${stateDir}/errors.log`, text);
3179
+ } catch {}
3180
+ }
2376
3181
  };
2377
3182
  }
2378
3183
  async function hookCommand(args) {
@@ -2387,7 +3192,7 @@ ${hookUsage}`, 1);
2387
3192
  console.log(JSON.stringify(reply));
2388
3193
  return 0;
2389
3194
  }
2390
- process6.exitCode = await runCli({
3195
+ process7.exitCode = await runCli({
2391
3196
  name: "guard",
2392
3197
  version,
2393
3198
  about: "the safety rules a Claude Code Bash call is held to, each refusal naming the route",