tollgate-cli 0.2.1 → 0.2.4

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/README.md CHANGED
@@ -11,6 +11,13 @@ Your team approves before your coding agent does something you can't undo.
11
11
 
12
12
  Every other command runs untouched; the hook adds about 50 ms.
13
13
 
14
+ It also gates what an agent could do instead of running the command:
15
+
16
+ - **File edits** to Tollgate's own setup (`.claude/settings*.json`, `.tollgate.json`, the token), deploy and CI files (`.github/workflows/`, `.gitlab-ci.yml`, `vercel`/`netlify`/`fly`/`railway` config) and `.env` files. The card shows the change; `.env` values are hidden.
17
+ - **MCP tools** whose names delete, deploy, publish, merge, refund, charge, transfer or run SQL (`mcp__github__merge_pull_request`, `mcp__supabase__execute_sql`).
18
+
19
+ An approval covers that exact edit or call only. Custom presets take `"tool": "edit"` or `"tool": "mcp"`; edit patterns match `edit <path>` (`write <path>` for a new file), MCP patterns match `mcp__server__tool {json arguments}`.
20
+
14
21
  ## Set up
15
22
 
16
23
  1. In your team's Tollgate room, open **Settings › Repos › Link a repo** and copy the command it shows.
@@ -34,9 +41,19 @@ Every other command runs untouched; the hook adds about 50 ms.
34
41
  | `.claude/settings.local.json` | No (added to `.gitignore`): your personal hook registration |
35
42
  | `~/.config/tollgate/` | Never in a repo: your token (file mode 0600) and the hook bundle |
36
43
 
44
+ ## What the hook can see through
45
+
46
+ Agents rarely type the risky command itself. The hook looks at what a command really runs:
47
+
48
+ - **Package scripts:** `pnpm db:deploy`, `npm run release`, `yarn deploy`, `bun run x` are looked up in the nearest `package.json` (with `pre`/`post` scripts and scripts that call other scripts), so the card shows _pnpm db:deploy → prisma migrate deploy_ with the migration SQL.
49
+ - **Lists and wrappers:** every part of `a && b; c | d` is checked on its own, so `npm publish && echo --dry-run` is still a publish. Prefixes like `VAR=x`, `env`, `sudo`, `npx`, `pnpm exec`, `dotenv --` and literal `bash -c '…'`, `eval '…'`, `( … )` and `$( … )` are looked inside.
50
+ - **Code nobody can read first:** `curl … | sh`, `base64 -d | sh`, `bash <(curl …)` and commands built from a variable (`eval "$CMD"`) are gated as _opaque_.
51
+
52
+ Tollgate is not a shell parser and not a sandbox. It does **not** read Makefiles (`make deploy`), shell files (`./deploy.sh`), npm lifecycle scripts like `prepublishOnly`, or workspace scripts selected with `--filter`. It guards against an agent's mistakes, not against someone determined to get around it on their own machine.
53
+
37
54
  ## What leaves your machine
38
55
 
39
- Only for commands that match a preset: the command and working directory after **local redaction** (known token formats, `KEY=value` secrets, passwords in URLs, secret flags, and every value from your `.env*` files), Claude Code's one-line description of the command (shown to approvers as the agent's reason), your git branch, the last commit subject, and for migrations the newest migration's SQL (capped at 2 KB). The card shows how many values were hidden. The server redacts everything again.
56
+ Only for commands that match a preset: the command and working directory after **local redaction** (known token formats, `KEY=value` secrets, passwords in URLs, secret flags, and every value from your `.env*` files), Claude Code's one-line description of the command (shown to approvers as the agent's reason), your git branch, the last commit subject, and for migrations the newest migration's SQL (capped at 2 KB). For a gated edit, the path and the change (capped at 80 lines); for a gated MCP call, the tool name and its arguments, redacted the same way. The card shows how many values were hidden. The server redacts everything again.
40
57
 
41
58
  ## Commands
42
59
 
@@ -48,6 +65,8 @@ Only for commands that match a preset: the command and working directory after *
48
65
  | `unlink` | Forget this repo's token on this machine (revoke it in the web app) |
49
66
  | `hook` | The hook itself; Claude Code calls it with JSON on stdin |
50
67
 
68
+ After upgrading from 0.2.3 or earlier, run `init` again: it re-registers the hook for file edits and MCP tools.
69
+
51
70
  ## Safety notes
52
71
 
53
72
  - **Fails closed.** If the server can't be reached, a risky command is blocked, with a message saying why.
package/dist/tollgate.mjs CHANGED
@@ -7,7 +7,8 @@ var __export = (target, all) => {
7
7
 
8
8
  // src/main.ts
9
9
  import { appendFileSync, mkdirSync as mkdirSync3 } from "node:fs";
10
- import { join as join3, resolve } from "node:path";
10
+ import { homedir as homedir2 } from "node:os";
11
+ import { join as join3, resolve as resolve2 } from "node:path";
11
12
  import { parseArgs } from "node:util";
12
13
 
13
14
  // ../../node_modules/.pnpm/zod@4.6.7/node_modules/zod/v4/classic/external.js
@@ -845,14 +846,14 @@ function promiseAllObject(promisesObj) {
845
846
  }
846
847
  function randomString(length = 10) {
847
848
  const chars = "abcdefghijklmnopqrstuvwxyz";
848
- let str = "";
849
+ let str2 = "";
849
850
  for (let i = 0; i < length; i++) {
850
- str += chars[Math.floor(Math.random() * chars.length)];
851
+ str2 += chars[Math.floor(Math.random() * chars.length)];
851
852
  }
852
- return str;
853
+ return str2;
853
854
  }
854
- function esc(str) {
855
- return JSON.stringify(str);
855
+ function esc(str2) {
856
+ return JSON.stringify(str2);
856
857
  }
857
858
  function slugify(input2) {
858
859
  return input2.toLowerCase().trim().replace(/[^\w\s-]/g, "").replace(/[\s_-]+/g, "-").replace(/^-+|-+$/g, "");
@@ -966,8 +967,8 @@ var primitiveTypes = /* @__PURE__ */ new Set([
966
967
  "symbol",
967
968
  "undefined"
968
969
  ]);
969
- function escapeRegex(str) {
970
- return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
970
+ function escapeRegex(str2) {
971
+ return str2.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
971
972
  }
972
973
  function clone(inst, def, params) {
973
974
  const cl = new inst._zod.constr(def ?? inst._zod.def);
@@ -1233,13 +1234,13 @@ function getSizableOrigin(input2) {
1233
1234
  return "unknown";
1234
1235
  }
1235
1236
  var highSurrogate = /[\uD800-\uDBFF]/;
1236
- function codePointLength(str) {
1237
- const units = str.length;
1238
- if (!highSurrogate.test(str))
1237
+ function codePointLength(str2) {
1238
+ const units = str2.length;
1239
+ if (!highSurrogate.test(str2))
1239
1240
  return units;
1240
1241
  let count = units;
1241
1242
  for (let i = 0; i < units - 1; i++) {
1242
- if ((str.charCodeAt(i) & 64512) === 55296 && (str.charCodeAt(i + 1) & 64512) === 56320) {
1243
+ if ((str2.charCodeAt(i) & 64512) === 55296 && (str2.charCodeAt(i + 1) & 64512) === 56320) {
1243
1244
  count--;
1244
1245
  i++;
1245
1246
  }
@@ -1750,14 +1751,14 @@ function toDotPath(_path) {
1750
1751
  return segs.join("");
1751
1752
  }
1752
1753
  function prettifyError(error62) {
1753
- const lines = [];
1754
+ const lines2 = [];
1754
1755
  const issues = [...error62.issues].sort((a, b) => (a.path ?? []).length - (b.path ?? []).length);
1755
1756
  for (const issue2 of issues) {
1756
- lines.push(`\u2716 ${issue2.message}`);
1757
+ lines2.push(`\u2716 ${issue2.message}`);
1757
1758
  if (issue2.path?.length)
1758
- lines.push(` \u2192 at ${toDotPath(issue2.path)}`);
1759
+ lines2.push(` \u2192 at ${toDotPath(issue2.path)}`);
1759
1760
  }
1760
- return lines.join("\n");
1761
+ return lines2.join("\n");
1761
1762
  }
1762
1763
 
1763
1764
  // ../../node_modules/.pnpm/zod@4.6.7/node_modules/zod/v4/core/parse.js
@@ -2592,9 +2593,9 @@ var Doc = class {
2592
2593
  return;
2593
2594
  }
2594
2595
  const content = arg;
2595
- const lines = content.split("\n").filter((x) => x);
2596
- const minIndent = Math.min(...lines.map((x) => x.length - x.trimStart().length));
2597
- const dedented = lines.map((x) => x.slice(minIndent)).map((x) => " ".repeat(this.indent * 2) + x);
2596
+ const lines2 = content.split("\n").filter((x) => x);
2597
+ const minIndent = Math.min(...lines2.map((x) => x.length - x.trimStart().length));
2598
+ const dedented = lines2.map((x) => x.slice(minIndent)).map((x) => " ".repeat(this.indent * 2) + x);
2598
2599
  for (const line of dedented) {
2599
2600
  this.content.push(line);
2600
2601
  }
@@ -5077,7 +5078,7 @@ var recursive = /* @__PURE__ */ new WeakMap();
5077
5078
  var NONE = 0;
5078
5079
  var ASSUMED = 1;
5079
5080
  var PROVEN = 2;
5080
- function isRecursive(inst, stack, resolve2) {
5081
+ function isRecursive(inst, stack, resolve3) {
5081
5082
  const cached2 = recursive.get(inst);
5082
5083
  if (cached2 !== void 0)
5083
5084
  return cached2 ? PROVEN : NONE;
@@ -5087,7 +5088,7 @@ function isRecursive(inst, stack, resolve2) {
5087
5088
  let result = NONE;
5088
5089
  const check2 = (child) => {
5089
5090
  if (result !== PROVEN && child?._zod) {
5090
- const answer = isRecursive(child, stack, resolve2);
5091
+ const answer = isRecursive(child, stack, resolve3);
5091
5092
  if (answer > result)
5092
5093
  result = answer;
5093
5094
  }
@@ -5098,7 +5099,7 @@ function isRecursive(inst, stack, resolve2) {
5098
5099
  const desc = Object.getOwnPropertyDescriptor(sh, key);
5099
5100
  if (spread && !desc.enumerable)
5100
5101
  continue;
5101
- const child = desc.get ? ASSUMED : desc.value?._zod ? isRecursive(desc.value, stack, resolve2) : NONE;
5102
+ const child = desc.get ? ASSUMED : desc.value?._zod ? isRecursive(desc.value, stack, resolve3) : NONE;
5102
5103
  if (child > answer)
5103
5104
  answer = child;
5104
5105
  }
@@ -5162,7 +5163,7 @@ function isRecursive(inst, stack, resolve2) {
5162
5163
  break;
5163
5164
  // `$ZodLazy` caches its inner on the def, so a resolved edge is followed exactly
5164
5165
  case "lazy": {
5165
- const inner = def._cachedInner ?? (resolve2 ? inst._zod.innerType : void 0);
5166
+ const inner = def._cachedInner ?? (resolve3 ? inst._zod.innerType : void 0);
5166
5167
  merge2(inner ? isRecursive(inner, stack, false) : ASSUMED);
5167
5168
  break;
5168
5169
  }
@@ -5242,14 +5243,14 @@ var memo = {
5242
5243
  var _a3;
5243
5244
  (_a3 = inst._zod).deferred ?? (_a3.deferred = []);
5244
5245
  inst._zod.deferred.push(() => {
5245
- const base = inst._zod.parse;
5246
+ const base2 = inst._zod.parse;
5246
5247
  const wrapped = (payload, ctx) => {
5247
5248
  if (ctx.direction !== "backward" && isBackEdge(ctx, payload.value))
5248
5249
  throw new $ZodCyclicError();
5249
- return base(payload, ctx);
5250
+ return base2(payload, ctx);
5250
5251
  };
5251
5252
  inst._zod.parse = wrapped;
5252
- if (inst._zod.run === base)
5253
+ if (inst._zod.run === base2)
5253
5254
  inst._zod.run = wrapped;
5254
5255
  });
5255
5256
  },
@@ -5261,15 +5262,15 @@ var memo = {
5261
5262
  let lastBucket;
5262
5263
  (_a3 = inst._zod).deferred ?? (_a3.deferred = []);
5263
5264
  inst._zod.deferred.push(() => {
5264
- const base = inst._zod.parse;
5265
+ const base2 = inst._zod.parse;
5265
5266
  const wrapped = (payload, ctx) => {
5266
5267
  if (isRecursiveInst === void 0) {
5267
5268
  const walked = isRecursive(inst, /* @__PURE__ */ new Set(), false);
5268
5269
  if (walked === NONE) {
5269
- inst._zod.parse = base;
5270
+ inst._zod.parse = base2;
5270
5271
  if (inst._zod.run === wrapped)
5271
- inst._zod.run = base;
5272
- return base(payload, ctx);
5272
+ inst._zod.run = base2;
5273
+ return base2(payload, ctx);
5273
5274
  }
5274
5275
  if (walked === PROVEN || rechecked)
5275
5276
  isRecursiveInst = true;
@@ -5278,7 +5279,7 @@ var memo = {
5278
5279
  }
5279
5280
  const input2 = payload.value;
5280
5281
  if (!isRef(input2))
5281
- return base(payload, ctx);
5282
+ return base2(payload, ctx);
5282
5283
  let state = ctx[STATE];
5283
5284
  if (!state) {
5284
5285
  state = { buckets: /* @__PURE__ */ new WeakMap(), backEdges: void 0 };
@@ -5307,7 +5308,7 @@ var memo = {
5307
5308
  }
5308
5309
  handoff = bucket;
5309
5310
  const depth = open.length;
5310
- const result = base(payload, ctx);
5311
+ const result = base2(payload, ctx);
5311
5312
  handoff = void 0;
5312
5313
  const entry = open.length > depth ? open.pop() : void 0;
5313
5314
  if (result instanceof Promise) {
@@ -5322,7 +5323,7 @@ var memo = {
5322
5323
  return result;
5323
5324
  };
5324
5325
  inst._zod.parse = wrapped;
5325
- if (inst._zod.run === base)
5326
+ if (inst._zod.run === base2)
5326
5327
  inst._zod.run = wrapped;
5327
5328
  });
5328
5329
  }
@@ -13231,9 +13232,9 @@ function codePointLengthVar(doc, ctx, accessor, inDoubt) {
13231
13232
  doc.write(`const ${v} = typeof ${accessor} === "string" && ${inDoubt} ? ${cpLen}(${accessor}) : ${accessor}.length;`);
13232
13233
  return v;
13233
13234
  }
13234
- function numericOperand(value, label) {
13235
+ function numericOperand(value, label2) {
13235
13236
  if (typeof value !== "number" || !Number.isFinite(value)) {
13236
- throw new ZodCompileUnsupportedError(`${label} bound of type ${typeof value}`);
13237
+ throw new ZodCompileUnsupportedError(`${label2} bound of type ${typeof value}`);
13237
13238
  }
13238
13239
  return `${value}`;
13239
13240
  }
@@ -19727,8 +19728,347 @@ function date4(params) {
19727
19728
  return _coercedDate(ZodDate, params);
19728
19729
  }
19729
19730
 
19731
+ // ../../features/gates/shared/expand.ts
19732
+ var MAX_DEPTH = 4;
19733
+ var MAX_SEGMENTS = 64;
19734
+ var MAX_LABEL = 80;
19735
+ function scan(text, onChar) {
19736
+ const state = { quote: null, depth: 0, backtick: false };
19737
+ for (let i = 0; i < text.length; i++) {
19738
+ const ch = text[i];
19739
+ const skip = onChar(i, state);
19740
+ if (typeof skip === "number") {
19741
+ i = skip;
19742
+ continue;
19743
+ }
19744
+ if (state.quote === "'") {
19745
+ if (ch === "'") state.quote = null;
19746
+ continue;
19747
+ }
19748
+ if (ch === "\\") {
19749
+ i += 1;
19750
+ continue;
19751
+ }
19752
+ if (state.quote === '"') {
19753
+ if (ch === '"') state.quote = null;
19754
+ else if (ch === "$" && text[i + 1] === "(") {
19755
+ state.depth += 1;
19756
+ i += 1;
19757
+ } else if (ch === ")" && state.depth > 0) state.depth -= 1;
19758
+ continue;
19759
+ }
19760
+ if (ch === "'" || ch === '"') state.quote = ch;
19761
+ else if (ch === "`") state.backtick = !state.backtick;
19762
+ else if (ch === "(") state.depth += 1;
19763
+ else if (ch === ")" && state.depth > 0) state.depth -= 1;
19764
+ }
19765
+ }
19766
+ var atTop = (s) => s.quote === null && s.depth === 0 && !s.backtick;
19767
+ function splitList(text) {
19768
+ const parts = [];
19769
+ let start = 0;
19770
+ scan(text, (i, s) => {
19771
+ if (!atTop(s)) return;
19772
+ const ch = text[i];
19773
+ const next = text[i + 1];
19774
+ const prev = text[i - 1];
19775
+ if (ch === "#" && (i === 0 || /\s/.test(prev))) {
19776
+ parts.push(text.slice(start, i));
19777
+ const end = text.indexOf("\n", i);
19778
+ start = end === -1 ? text.length : end + 1;
19779
+ return end === -1 ? text.length : end;
19780
+ }
19781
+ if (ch === ";" || ch === "\n") {
19782
+ parts.push(text.slice(start, i));
19783
+ start = i + 1;
19784
+ } else if (ch === "&" && next === "&" || ch === "|" && next === "|") {
19785
+ parts.push(text.slice(start, i));
19786
+ start = i + 2;
19787
+ return i + 1;
19788
+ } else if (ch === "&" && prev !== ">" && prev !== "<" && prev !== "|" && next !== ">") {
19789
+ parts.push(text.slice(start, i));
19790
+ start = i + 1;
19791
+ }
19792
+ });
19793
+ parts.push(text.slice(start));
19794
+ return parts.map((part) => part.trim()).filter(Boolean);
19795
+ }
19796
+ function splitPipeline(text) {
19797
+ const parts = [];
19798
+ let start = 0;
19799
+ scan(text, (i, s) => {
19800
+ if (!atTop(s) || text[i] !== "|") return;
19801
+ parts.push(text.slice(start, i));
19802
+ const skipAmp = text[i + 1] === "&";
19803
+ start = i + (skipAmp ? 2 : 1);
19804
+ return skipAmp ? i + 1 : void 0;
19805
+ });
19806
+ parts.push(text.slice(start));
19807
+ return parts.map((part) => part.trim()).filter(Boolean);
19808
+ }
19809
+ function shellWords(text) {
19810
+ const words = [];
19811
+ let current = "";
19812
+ let inWord = false;
19813
+ let quote = null;
19814
+ let depth = 0;
19815
+ let backtick = false;
19816
+ for (let i = 0; i < text.length; i++) {
19817
+ const ch = text[i];
19818
+ if (quote === "'") {
19819
+ if (ch === "'") quote = null;
19820
+ else current += ch;
19821
+ continue;
19822
+ }
19823
+ if (ch === "\\" && i + 1 < text.length) {
19824
+ current += text[i + 1];
19825
+ inWord = true;
19826
+ i += 1;
19827
+ continue;
19828
+ }
19829
+ if (quote === '"') {
19830
+ if (ch === '"' && depth === 0) quote = null;
19831
+ else {
19832
+ if (ch === "$" && text[i + 1] === "(") depth += 1;
19833
+ else if (ch === ")" && depth > 0) depth -= 1;
19834
+ current += ch;
19835
+ }
19836
+ continue;
19837
+ }
19838
+ if (depth > 0 || backtick) {
19839
+ if (ch === "(") depth += 1;
19840
+ else if (ch === ")") depth -= 1;
19841
+ else if (ch === "`") backtick = false;
19842
+ current += ch;
19843
+ continue;
19844
+ }
19845
+ if (/\s/.test(ch)) {
19846
+ if (inWord) words.push(current);
19847
+ current = "";
19848
+ inWord = false;
19849
+ continue;
19850
+ }
19851
+ inWord = true;
19852
+ if (ch === "'" || ch === '"') quote = ch;
19853
+ else if (ch === "$" && text[i + 1] === "(") {
19854
+ depth = 1;
19855
+ current += "$(";
19856
+ i += 1;
19857
+ } else if (ch === "`") {
19858
+ backtick = true;
19859
+ current += ch;
19860
+ } else current += ch;
19861
+ }
19862
+ if (inWord) words.push(current);
19863
+ return words;
19864
+ }
19865
+ var ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;
19866
+ var base = (word) => word.slice(word.lastIndexOf("/") + 1);
19867
+ function skipFlags(words, withValue = /* @__PURE__ */ new Set()) {
19868
+ let i = 0;
19869
+ while (i < words.length && words[i].startsWith("-") && words[i] !== "--") {
19870
+ i += withValue.has(words[i]) ? 2 : 1;
19871
+ }
19872
+ if (words[i] === "--") i += 1;
19873
+ return words.slice(i);
19874
+ }
19875
+ var SUDO_VALUE = /* @__PURE__ */ new Set(["-u", "-g", "-C", "-D", "-h", "-p", "-r", "-t", "-U", "-T"]);
19876
+ var NPX_VALUE = /* @__PURE__ */ new Set(["-p", "--package", "-c", "--call"]);
19877
+ var ENV_VALUE = /* @__PURE__ */ new Set(["-u", "--unset", "-C", "--chdir", "-S", "--split-string"]);
19878
+ function stripPrefixes(input2) {
19879
+ let words = input2;
19880
+ for (let guard = 0; guard < 16 && words.length > 0; guard++) {
19881
+ const first = base(words[0]);
19882
+ const second = words[1];
19883
+ let next = null;
19884
+ if (ASSIGNMENT.test(words[0])) next = words.slice(1);
19885
+ else if (first === "env") {
19886
+ next = skipFlags(words.slice(1), ENV_VALUE);
19887
+ while (next.length > 0 && ASSIGNMENT.test(next[0])) next = next.slice(1);
19888
+ } else if (first === "sudo" || first === "doas") next = skipFlags(words.slice(1), SUDO_VALUE);
19889
+ else if (["time", "nohup", "command", "exec", "builtin"].includes(first)) {
19890
+ next = skipFlags(words.slice(1));
19891
+ } else if (first === "nice") next = skipFlags(words.slice(1), /* @__PURE__ */ new Set(["-n"]));
19892
+ else if (first === "npx" || first === "bunx") next = skipFlags(words.slice(1), NPX_VALUE);
19893
+ else if (first === "pnpm" && (second === "exec" || second === "dlx")) {
19894
+ next = skipFlags(words.slice(2));
19895
+ } else if (first === "yarn" && (second === "dlx" || second === "exec")) {
19896
+ next = skipFlags(words.slice(2));
19897
+ } else if (first === "npm" && (second === "exec" || second === "x")) {
19898
+ next = skipFlags(words.slice(2), NPX_VALUE);
19899
+ } else if (first === "dotenv" || first === "dotenvx") {
19900
+ const dash = words.indexOf("--");
19901
+ next = dash === -1 ? skipFlags(words.slice(1), /* @__PURE__ */ new Set(["-e", "-f", "-v", "-c"])) : words.slice(dash + 1);
19902
+ if (first === "dotenvx" && next[0] === "run")
19903
+ next = skipFlags(next.slice(1), /* @__PURE__ */ new Set(["-f"]));
19904
+ } else if (first === "cross-env") {
19905
+ next = words.slice(1);
19906
+ while (next.length > 0 && ASSIGNMENT.test(next[0])) next = next.slice(1);
19907
+ }
19908
+ if (next === null) break;
19909
+ words = next;
19910
+ }
19911
+ return words;
19912
+ }
19913
+ var SHELLS = /* @__PURE__ */ new Set(["sh", "bash", "zsh", "dash", "ksh"]);
19914
+ function innerScript(words) {
19915
+ if (words.length === 0) return null;
19916
+ const first = base(words[0]);
19917
+ if (SHELLS.has(first)) {
19918
+ const flag = words.findIndex((word, i) => i > 0 && /^-[A-Za-z]*c[A-Za-z]*$/.test(word));
19919
+ return flag !== -1 && words[flag + 1] !== void 0 ? words[flag + 1] : null;
19920
+ }
19921
+ if (first === "eval") return words.slice(1).join(" ") || null;
19922
+ return null;
19923
+ }
19924
+ function substitutions(text) {
19925
+ const found = [];
19926
+ for (let i = 0; i < text.length; i++) {
19927
+ if (text[i] === "'") {
19928
+ const end = text.indexOf("'", i + 1);
19929
+ if (end === -1) break;
19930
+ i = end;
19931
+ continue;
19932
+ }
19933
+ if (text[i] === "$" && text[i + 1] === "(" && text[i + 2] !== "(") {
19934
+ let depth = 1;
19935
+ let j = i + 2;
19936
+ for (; j < text.length && depth > 0; j++) {
19937
+ if (text[j] === "(") depth += 1;
19938
+ else if (text[j] === ")") depth -= 1;
19939
+ }
19940
+ found.push(text.slice(i + 2, j - 1));
19941
+ i = j - 1;
19942
+ } else if (text[i] === "`") {
19943
+ const end = text.indexOf("`", i + 1);
19944
+ if (end === -1) break;
19945
+ found.push(text.slice(i + 1, end));
19946
+ i = end;
19947
+ }
19948
+ }
19949
+ return found.filter((inner) => inner.trim());
19950
+ }
19951
+ var PNPM_BUILTINS = new Set(
19952
+ "add i install update up upgrade remove rm uninstall un link ln unlink import rebuild rb prune fetch patch patch-commit exec dlx create publish pack audit list ls outdated why store root bin config c get set env init setup server deploy licenses doctor approve-builds ignored-builds self-update cat-file cat-index find-hash recursive multi m help".split(
19953
+ " "
19954
+ )
19955
+ );
19956
+ var YARN_BUILTINS = new Set(
19957
+ "add install remove upgrade up publish init info why dlx exec workspace workspaces config cache link unlink pack version global import npm plugin set constraints dedupe explain node rebuild search stage unplug patch patch-commit login logout owner tag team audit autoclean check generate-lock-entry help licenses list outdated policies upgrade-interactive".split(
19958
+ " "
19959
+ )
19960
+ );
19961
+ var RUNNER_VALUE = /* @__PURE__ */ new Set([
19962
+ "-C",
19963
+ "--dir",
19964
+ "--prefix",
19965
+ "--filter",
19966
+ "-F",
19967
+ "-w",
19968
+ "--workspace",
19969
+ "--cwd"
19970
+ ]);
19971
+ var NPM_LIFECYCLE = {
19972
+ test: "test",
19973
+ t: "test",
19974
+ tst: "test",
19975
+ start: "start",
19976
+ stop: "stop",
19977
+ restart: "restart"
19978
+ };
19979
+ function scriptName(words, scripts) {
19980
+ if (words.length < 2) return null;
19981
+ const tool = base(words[0]);
19982
+ const rest = skipFlags(words.slice(1), RUNNER_VALUE);
19983
+ const sub = rest[0];
19984
+ if (!sub) return null;
19985
+ const after = skipFlags(rest.slice(1), RUNNER_VALUE)[0] ?? null;
19986
+ if (tool === "npm") {
19987
+ if (["run", "run-script", "rum", "urn"].includes(sub)) return after;
19988
+ return NPM_LIFECYCLE[sub] ?? null;
19989
+ }
19990
+ if (tool === "pnpm") {
19991
+ if (sub === "run" || sub === "run-script") return after;
19992
+ return PNPM_BUILTINS.has(sub) ? null : sub;
19993
+ }
19994
+ if (tool === "yarn") {
19995
+ if (sub === "run") return after;
19996
+ return YARN_BUILTINS.has(sub) ? null : sub;
19997
+ }
19998
+ if (tool === "bun") {
19999
+ if (sub === "run") return after;
20000
+ return scripts && sub in scripts ? sub : null;
20001
+ }
20002
+ return null;
20003
+ }
20004
+ function label(words) {
20005
+ const text = words.join(" ");
20006
+ return text.length > MAX_LABEL ? `${text.slice(0, MAX_LABEL - 1)}\u2026` : text;
20007
+ }
20008
+ function expandCommand(command, options) {
20009
+ const maxDepth = options.maxDepth ?? MAX_DEPTH;
20010
+ const maxSegments = options.maxSegments ?? MAX_SEGMENTS;
20011
+ const segments = [];
20012
+ let scripts;
20013
+ const loadScripts = () => {
20014
+ if (scripts === void 0) {
20015
+ try {
20016
+ scripts = options.readScripts?.(options.cwd) ?? null;
20017
+ } catch {
20018
+ scripts = null;
20019
+ }
20020
+ }
20021
+ return scripts;
20022
+ };
20023
+ const visit2 = (text, via, depth, seen) => {
20024
+ for (const pipeline of splitList(text)) {
20025
+ if (segments.length >= maxSegments) return;
20026
+ const group = /^\(([\s\S]*)\)$/.exec(pipeline) ?? /^\{([\s\S]*?);?\s*\}$/.exec(pipeline);
20027
+ if (group) {
20028
+ if (depth < maxDepth) visit2(group[1], via, depth + 1, seen);
20029
+ continue;
20030
+ }
20031
+ const commands = splitPipeline(pipeline).map((part) => stripPrefixes(shellWords(part)));
20032
+ const flat = commands.map((words) => words.join(" ")).filter(Boolean).join(" | ");
20033
+ if (flat) segments.push({ text: flat, via });
20034
+ if (depth >= maxDepth) continue;
20035
+ for (const inner of substitutions(pipeline)) {
20036
+ visit2(inner, [...via, "$(\u2026)"], depth + 1, seen);
20037
+ }
20038
+ for (const words of commands) {
20039
+ const script = innerScript(words);
20040
+ if (script) {
20041
+ const wrapper = base(words[0]) === "eval" ? "eval" : `${base(words[0])} -c`;
20042
+ visit2(script, [...via, wrapper], depth + 1, seen);
20043
+ }
20044
+ const name = scriptName(words, loadScripts() ?? null);
20045
+ const all = loadScripts();
20046
+ if (!name || !all || seen.has(name) || !(name in all)) continue;
20047
+ const nextSeen = new Set(seen).add(name);
20048
+ const from = [...via, label(words)];
20049
+ for (const key of [`pre${name}`, name, `post${name}`]) {
20050
+ const body = all[key];
20051
+ if (typeof body === "string" && body.trim()) {
20052
+ visit2(body.slice(0, 2e3), from, depth + 1, nextSeen);
20053
+ }
20054
+ }
20055
+ }
20056
+ }
20057
+ };
20058
+ visit2(command, [], 0, /* @__PURE__ */ new Set());
20059
+ return segments;
20060
+ }
20061
+
19730
20062
  // ../../features/gates/shared/presets.ts
20063
+ var GATE_TOOLS = ["bash", "edit", "mcp"];
20064
+ var TOOL_NOUN = {
20065
+ bash: "command",
20066
+ edit: "edit",
20067
+ mcp: "tool call"
20068
+ };
19731
20069
  var presetSchema = external_exports.object({
20070
+ // Defaults to "bash", so presets written before file and MCP gates keep their meaning.
20071
+ tool: external_exports.enum(GATE_TOOLS).optional(),
19732
20072
  name: external_exports.string().regex(/^[a-z0-9][a-z0-9-]{0,39}$/).min(1),
19733
20073
  patterns: external_exports.array(external_exports.string().min(1).max(300)).min(1).max(20),
19734
20074
  // A match is skipped when any of these also matches (e.g. --dry-run changes nothing).
@@ -19786,7 +20126,8 @@ var DEFAULT_PRESETS = [
19786
20126
  {
19787
20127
  name: "publish",
19788
20128
  patterns: [
19789
- "\\b(npm|pnpm|yarn)\\s+publish\\b",
20129
+ // Flags (and their values) may sit between the tool and "publish": pnpm --filter cli publish.
20130
+ "\\b(npm|pnpm|yarn)\\b[^;&|\\n]*\\s+publish(\\s|$)",
19790
20131
  "\\bcargo\\s+publish\\b",
19791
20132
  "\\btwine\\s+upload\\b"
19792
20133
  ],
@@ -19797,8 +20138,8 @@ var DEFAULT_PRESETS = [
19797
20138
  {
19798
20139
  name: "infra",
19799
20140
  patterns: [
19800
- "\\bterraform\\s+(apply|destroy)\\b",
19801
- "\\bpulumi\\s+(up|destroy)\\b",
20141
+ "\\bterraform\\b[^;&|\\n]*\\s+(apply|destroy)(\\s|$)",
20142
+ "\\bpulumi\\b[^;&|\\n]*\\s+(up|destroy)(\\s|$)",
19802
20143
  "\\baws\\s+.*\\bdelete-[a-z-]+\\b",
19803
20144
  "\\baws\\s+s3\\s+(rm|rb)\\b",
19804
20145
  "\\bgcloud\\s+.*\\bdelete\\b"
@@ -19806,6 +20147,21 @@ var DEFAULT_PRESETS = [
19806
20147
  unless: [DRY_RUN],
19807
20148
  quorum: 2
19808
20149
  },
20150
+ {
20151
+ // Code Tollgate can't read before it runs: fetched from the network, decoded, or built
20152
+ // from a variable at run time. A person should look before it runs.
20153
+ name: "opaque",
20154
+ patterns: [
20155
+ "\\b(curl|wget)\\b[^|;&]*\\|\\s*(sudo(\\s+-\\S+)*\\s+)?(env\\s+)?(ba|z|da|k)?sh\\b",
20156
+ "\\bbase64\\s+(-d|-D|--decode)\\b[^|;&]*\\|\\s*(sudo\\s+)?(ba|z|da|k)?sh\\b",
20157
+ "\\b(ba|z|da|k)?sh\\s+<\\(",
20158
+ "(^|[\\s;&|])(source|\\.)\\s+<\\(",
20159
+ "(^|[\\s;&|])eval\\s+[\"']?[$`]",
20160
+ "\\b(ba|z|da|k)?sh(\\s+-\\w+)*\\s+-\\w*c\\s+[\"']?[$`]"
20161
+ ],
20162
+ env: "other",
20163
+ quorum: 1
20164
+ },
19809
20165
  {
19810
20166
  name: "destructive-fs",
19811
20167
  patterns: [
@@ -19814,6 +20170,52 @@ var DEFAULT_PRESETS = [
19814
20170
  env: "other",
19815
20171
  quorum: 1,
19816
20172
  outsideRepoOnly: true
20173
+ },
20174
+ // File edits are matched as "edit <path>" or "write <path>", the path relative to the repo
20175
+ // (or ~/… outside it).
20176
+ {
20177
+ // An agent that may not run a command could otherwise switch the guard off: the hook's
20178
+ // registration, the repo's presets, or this machine's Tollgate token.
20179
+ name: "guard-config",
20180
+ tool: "edit",
20181
+ patterns: [
20182
+ "(^|[\\s/])\\.claude/settings(\\.local)?\\.json$",
20183
+ "(^|[\\s/])\\.tollgate\\.json$",
20184
+ "(^|[\\s/])\\.config/tollgate/"
20185
+ ],
20186
+ env: "other",
20187
+ quorum: 1
20188
+ },
20189
+ {
20190
+ // Where a deploy is defined: a change here ships with the next push, gate or not.
20191
+ name: "ci-config",
20192
+ tool: "edit",
20193
+ patterns: [
20194
+ "(^|[\\s/])\\.github/workflows/",
20195
+ "(^|[\\s/])\\.gitlab-ci\\.ya?ml$",
20196
+ "(^|[\\s/])(vercel|netlify)\\.(json|toml)$",
20197
+ "(^|[\\s/])(fly|railway)\\.(json|toml)$"
20198
+ ],
20199
+ quorum: 1,
20200
+ prodQuorum: 2
20201
+ },
20202
+ {
20203
+ name: "env-file",
20204
+ tool: "edit",
20205
+ patterns: ["(^|[\\s/])\\.env(\\.[^/\\s]+)?$"],
20206
+ unless: ["\\.env\\.(example|sample|template|defaults)$"],
20207
+ quorum: 1
20208
+ },
20209
+ {
20210
+ // MCP calls are matched as "mcp__server__tool {json args}". Tool names that move money,
20211
+ // ship, merge or destroy, whichever server they come from.
20212
+ name: "mcp-risky",
20213
+ tool: "mcp",
20214
+ patterns: [
20215
+ "^mcp__\\S+?__(\\S*_)?(delete|drop|destroy|deploy|publish|merge|refund|charge|payout|transfer|execute_sql|apply_migration)(_\\S*)?(\\s|$)"
20216
+ ],
20217
+ quorum: 1,
20218
+ prodQuorum: 2
19817
20219
  }
19818
20220
  ];
19819
20221
  var PROD_RE = /(^|[\s=:/_-])(prod|production)($|[\s.:/_-])|--prod\b/i;
@@ -19834,8 +20236,8 @@ function compile2(source) {
19834
20236
  }
19835
20237
  return regexCache.get(source) ?? null;
19836
20238
  }
19837
- function resolvePath(base, target) {
19838
- const parts = (target.startsWith("/") ? target : `${base}/${target}`).split("/");
20239
+ function resolvePath(base2, target) {
20240
+ const parts = (target.startsWith("/") ? target : `${base2}/${target}`).split("/");
19839
20241
  const out = [];
19840
20242
  for (const part of parts) {
19841
20243
  if (part === "" || part === ".") continue;
@@ -19861,20 +20263,62 @@ function rmTargetsOutsideRepo(command, cwd, repoRoot) {
19861
20263
  }
19862
20264
  return false;
19863
20265
  }
20266
+ function envOf(tool, text) {
20267
+ if (tool === "edit") return detectEnv(text.replace(/\./g, "/"));
20268
+ if (tool === "mcp") return detectEnv(text.replace(/["{},]/g, " "));
20269
+ return detectEnv(text);
20270
+ }
19864
20271
  function matchPreset(presets, input2) {
20272
+ const tool = input2.tool ?? "bash";
19865
20273
  for (const preset of presets) {
20274
+ if ((preset.tool ?? "bash") !== tool) continue;
19866
20275
  const hit = preset.patterns.some((source) => compile2(source)?.test(input2.command) ?? false);
19867
20276
  if (!hit) continue;
19868
20277
  if (preset.unless?.some((source) => compile2(source)?.test(input2.command) ?? false)) continue;
19869
20278
  if (preset.outsideRepoOnly && !rmTargetsOutsideRepo(input2.command, input2.cwd, input2.repoRoot)) {
19870
20279
  continue;
19871
20280
  }
19872
- const env = preset.env ?? detectEnv(input2.command);
20281
+ const env = preset.env ?? envOf(tool, input2.command);
19873
20282
  const quorum = env === "prod" ? Math.max(preset.quorum, preset.prodQuorum ?? 0) : preset.quorum;
19874
20283
  return { preset, env, quorum };
19875
20284
  }
19876
20285
  return null;
19877
20286
  }
20287
+ function withDefaultPresets(presets) {
20288
+ const defaults = new Map(DEFAULT_PRESETS.map((preset) => [preset.name, preset]));
20289
+ const names = new Set(presets.map((preset) => preset.name));
20290
+ const merged = presets.map((preset) => {
20291
+ const builtIn = defaults.get(preset.name);
20292
+ return builtIn ? {
20293
+ ...preset,
20294
+ tool: builtIn.tool,
20295
+ patterns: [.../* @__PURE__ */ new Set([...preset.patterns, ...builtIn.patterns])]
20296
+ } : preset;
20297
+ });
20298
+ return [...merged, ...DEFAULT_PRESETS.filter((preset) => !names.has(preset.name))];
20299
+ }
20300
+ var ENV_RANK = { prod: 2, staging: 1, other: 0 };
20301
+ function matchCommand(presets, input2) {
20302
+ const tool = input2.tool ?? "bash";
20303
+ const pieces = tool === "bash" ? [
20304
+ { text: input2.command, via: [] },
20305
+ ...expandCommand(input2.command, { cwd: input2.cwd, readScripts: input2.readScripts }),
20306
+ ...input2.extra ?? []
20307
+ ] : [{ text: input2.command, via: [] }];
20308
+ let best = null;
20309
+ for (const piece of pieces) {
20310
+ const match = matchPreset(presets, {
20311
+ command: piece.text,
20312
+ cwd: input2.cwd,
20313
+ repoRoot: input2.repoRoot,
20314
+ tool
20315
+ });
20316
+ if (!match) continue;
20317
+ const better = best === null || match.quorum > best.quorum || match.quorum === best.quorum && ENV_RANK[match.env] > ENV_RANK[best.env] || match.quorum === best.quorum && match.env === best.env && best.via.length === 0 && piece.via.length > 0 && best.preset.name === match.preset.name;
20318
+ if (better) best = { ...match, segment: piece.text, via: piece.via };
20319
+ }
20320
+ return best;
20321
+ }
19878
20322
 
19879
20323
  // src/api.ts
19880
20324
  var REQUEST_TIMEOUT_MS = 5e3;
@@ -19954,6 +20398,28 @@ function findRepoRoot(cwd) {
19954
20398
  dir = parent;
19955
20399
  }
19956
20400
  }
20401
+ function readScripts(cwd, repoRoot) {
20402
+ let dir = cwd;
20403
+ for (; ; ) {
20404
+ const file2 = join(dir, "package.json");
20405
+ if (existsSync(file2)) {
20406
+ try {
20407
+ const scripts = JSON.parse(readFileSync(file2, "utf8")).scripts;
20408
+ if (!scripts || typeof scripts !== "object") return null;
20409
+ return Object.fromEntries(
20410
+ Object.entries(scripts).filter(
20411
+ (entry) => typeof entry[1] === "string"
20412
+ )
20413
+ );
20414
+ } catch {
20415
+ return null;
20416
+ }
20417
+ }
20418
+ const parent = dirname(dir);
20419
+ if (dir === repoRoot || parent === dir || !dir.startsWith(repoRoot)) return null;
20420
+ dir = parent;
20421
+ }
20422
+ }
19957
20423
  function readConfigFile(repoRoot) {
19958
20424
  const raw = JSON.parse(readFileSync(join(repoRoot, CONFIG_FILE), "utf8"));
19959
20425
  return configSchema.parse(raw);
@@ -20106,6 +20572,8 @@ function commandHash(input2) {
20106
20572
  }
20107
20573
  var createGateSchema = external_exports.object({
20108
20574
  preset: external_exports.string().min(1).max(40),
20575
+ // CLI 0.2.4+: what was stopped. Older hooks only send Bash commands.
20576
+ tool: external_exports.enum(GATE_TOOLS).default("bash"),
20109
20577
  quorum: external_exports.number().int().min(1).max(9).optional(),
20110
20578
  env: external_exports.enum(["prod", "staging", "other"]).optional(),
20111
20579
  command: external_exports.string().min(1).max(4e3),
@@ -20116,9 +20584,16 @@ var createGateSchema = external_exports.object({
20116
20584
  context: external_exports.string().max(2048).default(""),
20117
20585
  // Claude Code's own one-line description of the Bash call ("Apply pending migrations").
20118
20586
  intent: external_exports.string().max(300).nullable().optional(),
20587
+ // What a wrapper or package script really runs ("pnpm db:deploy" -> "prisma migrate deploy"),
20588
+ // redacted by the hook. The server matches it again; it can only raise a gate, never lower it.
20589
+ via: external_exports.array(
20590
+ external_exports.object({ from: external_exports.string().min(1).max(300), runs: external_exports.string().min(1).max(1e3) }).strict()
20591
+ ).max(5).optional(),
20119
20592
  redactions: external_exports.number().int().min(0).max(1e4).default(0),
20120
20593
  cmdHash: external_exports.string().regex(SHA256_RE),
20121
- sessionId: external_exports.string().max(200).optional()
20594
+ sessionId: external_exports.string().max(200).optional(),
20595
+ // How long this hook waits before parking the gate (CLI 0.2.3+), for the countdown.
20596
+ waitSec: external_exports.number().int().min(5).max(600).optional()
20122
20597
  }).strict();
20123
20598
  var consumeGrantSchema = external_exports.object({ cmdHash: external_exports.string().regex(SHA256_RE) }).strict();
20124
20599
 
@@ -20206,32 +20681,116 @@ function truncate(text, maxChars) {
20206
20681
  return `${text.slice(0, Math.max(0, maxChars - 15))}\u2026 [truncated]`;
20207
20682
  }
20208
20683
 
20684
+ // src/tool-call.ts
20685
+ import { isAbsolute, relative, resolve, sep } from "node:path";
20686
+ var EDIT_TOOLS = /* @__PURE__ */ new Set(["Edit", "Write", "MultiEdit", "NotebookEdit"]);
20687
+ var MAX_DETAIL_LINES = 80;
20688
+ var str = (value) => typeof value === "string" ? value : "";
20689
+ function displayPath(file2, cwd, repoRoot, home) {
20690
+ const absolute = resolve(cwd, file2);
20691
+ const inside = (base2) => {
20692
+ const rel = relative(base2, absolute);
20693
+ return rel && !rel.startsWith("..") && !isAbsolute(rel) ? rel.split(sep).join("/") : null;
20694
+ };
20695
+ const inRepo = inside(repoRoot);
20696
+ if (inRepo) return inRepo;
20697
+ const inHome = inside(home);
20698
+ return inHome ? `~/${inHome}` : absolute.split(sep).join("/");
20699
+ }
20700
+ function lines(prefix, text) {
20701
+ return text === "" ? [] : text.split("\n").map((line) => `${prefix}${line}`);
20702
+ }
20703
+ function hideEnvValues(text) {
20704
+ return text.replace(/^([+-] \s*(?:export\s+)?[A-Za-z_][A-Za-z0-9_.]*\s*=).*$/gm, "$1[REDACTED]");
20705
+ }
20706
+ function editDetail(toolName, input2) {
20707
+ let out;
20708
+ if (toolName === "Write") {
20709
+ out = lines("+ ", str(input2.content));
20710
+ } else if (toolName === "NotebookEdit") {
20711
+ out = input2.edit_mode === "delete" ? ["(deletes a notebook cell)"] : lines("+ ", str(input2.new_source));
20712
+ } else {
20713
+ const edits = toolName === "MultiEdit" && Array.isArray(input2.edits) ? input2.edits : [input2];
20714
+ out = edits.flatMap((edit, index) => [
20715
+ ...index > 0 ? [""] : [],
20716
+ ...lines("- ", str(edit.old_string)),
20717
+ ...lines("+ ", str(edit.new_string)),
20718
+ ...edit.replace_all === true ? ["(every occurrence in the file)"] : []
20719
+ ]);
20720
+ }
20721
+ const shown = out.slice(0, MAX_DETAIL_LINES);
20722
+ if (out.length > shown.length) shown.push(`\u2026 ${out.length - shown.length} more lines`);
20723
+ return shown.join("\n");
20724
+ }
20725
+ function readToolCall(toolName, rawInput, where) {
20726
+ const input2 = rawInput && typeof rawInput === "object" ? rawInput : {};
20727
+ const hashSource = `${toolName}
20728
+ ${JSON.stringify(input2)}`;
20729
+ if (EDIT_TOOLS.has(toolName)) {
20730
+ const file2 = str(input2.file_path) || str(input2.notebook_path);
20731
+ if (!file2) return null;
20732
+ const path = displayPath(file2, where.cwd, where.repoRoot, where.home);
20733
+ const detail = editDetail(toolName, input2);
20734
+ const isEnv = /(^|\/)\.env[^/]*$/.test(path);
20735
+ return {
20736
+ tool: "edit",
20737
+ subject: `${toolName === "Write" ? "write" : "edit"} ${path}`,
20738
+ hashSource,
20739
+ detail: isEnv ? hideEnvValues(detail) : detail
20740
+ };
20741
+ }
20742
+ if (toolName.startsWith("mcp__")) {
20743
+ const args = JSON.stringify(input2);
20744
+ return {
20745
+ tool: "mcp",
20746
+ subject: args === "{}" ? toolName : `${toolName} ${args}`,
20747
+ hashSource,
20748
+ detail: ""
20749
+ };
20750
+ }
20751
+ return null;
20752
+ }
20753
+
20209
20754
  // src/hook.ts
20210
20755
  var POLL_MS = 2e3;
20211
20756
  var PARK_MARGIN_SEC = 15;
20212
- function denyMessages(gate) {
20757
+ function denyMessages(gate, tool = "bash") {
20213
20758
  const who = gate.decidedBy || "a teammate";
20759
+ const noun = TOOL_NOUN[tool];
20760
+ const question = tool === "bash" ? `${who} asked a question in TG-${gate.code} before deciding: "${gate.question}" Answer it specifically and honestly in the description of your next Bash call, then run the same command again so the team can decide. If the answer changes your plan, change the command too.` : `${who} asked a question in TG-${gate.code} before deciding: "${gate.question}" Answer it to the user, then make the same ${noun} again so the team can decide. If the answer changes your plan, change the ${noun} too.`;
20214
20761
  return {
20215
- denied: gate.question ? `${who} asked a question in TG-${gate.code} before deciding: "${gate.question}" Answer it specifically and honestly in the description of your next Bash call, then run the same command again so the team can decide. If the answer changes your plan, change the command too.` : `Denied by ${who} in TG-${gate.code}: ${gate.denyReason ?? "no reason given"}. Do not retry this command as is. Change your plan to address the reason, then propose the new command.`,
20216
- parked: `Parked as TG-${gate.code}: nobody on the team decided in time. Continue with other work that doesn't need this command. Retry the exact same command later; once the team approves TG-${gate.code} it passes immediately (${gate.url}).`,
20762
+ denied: gate.question ? question : `Denied by ${who} in TG-${gate.code}: ${gate.denyReason ?? "no reason given"}. Do not retry this ${noun} as is. Change your plan to address the reason, then propose the new ${noun}.`,
20763
+ parked: `Parked as TG-${gate.code}: nobody on the team decided in time. Continue with other work that doesn't need this ${noun}. Retry the exact same ${noun} later; once the team approves TG-${gate.code} it passes immediately (${gate.url}).`,
20217
20764
  expired: `TG-${gate.code} expired without approval. Ask the user whether to raise it again.`
20218
20765
  };
20219
20766
  }
20220
- function notLinkedMessage() {
20221
- return "Tollgate blocked this command because this repo is not linked on this machine. Ask the user to run: npx tollgate-cli init";
20767
+ function notLinkedMessage(tool = "bash") {
20768
+ return `Tollgate blocked this ${TOOL_NOUN[tool]} because this repo is not linked on this machine. Ask the user to run: npx tollgate-cli init`;
20222
20769
  }
20223
- function failClosedMessage(server, error62) {
20770
+ function failClosedMessage(server, error62, tool = "bash") {
20224
20771
  const detail = error62 instanceof Error ? error62.message : String(error62);
20225
- return `Tollgate could not reach ${server} (${detail}), so this risky command was blocked to be safe. Tell the user; do not try to work around it.`;
20772
+ return `Tollgate could not reach ${server} (${detail}), so this risky ${TOOL_NOUN[tool]} was blocked to be safe. Tell the user; do not try to work around it.`;
20226
20773
  }
20227
20774
  async function decide(input2, deps) {
20228
- if (input2.tool_name !== "Bash") return { kind: "none" };
20229
- const command = typeof input2.tool_input?.command === "string" ? input2.tool_input.command : "";
20775
+ const toolName = input2.tool_name ?? "";
20776
+ const isBash = toolName === "Bash";
20777
+ if (!isBash && !EDIT_TOOLS.has(toolName) && !toolName.startsWith("mcp__")) {
20778
+ return { kind: "none" };
20779
+ }
20780
+ const command = isBash && typeof input2.tool_input?.command === "string" ? input2.tool_input.command : "";
20230
20781
  const cwd = input2.cwd || process.cwd();
20231
- if (!command.trim()) return { kind: "none" };
20782
+ if (isBash && !command.trim()) return { kind: "none" };
20232
20783
  const config2 = deps.loadConfig(cwd);
20233
20784
  if (!config2) return { kind: "none", note: "no .tollgate.json" };
20234
- const match = matchPreset(config2.presets, { command, cwd, repoRoot: config2.repoRoot });
20785
+ const call = isBash ? { tool: "bash", subject: command, hashSource: command, detail: "" } : readToolCall(toolName, input2.tool_input, { cwd, repoRoot: config2.repoRoot, home: deps.home });
20786
+ if (!call) return { kind: "none" };
20787
+ const match = matchCommand(withDefaultPresets(config2.presets), {
20788
+ command: call.subject,
20789
+ cwd,
20790
+ repoRoot: config2.repoRoot,
20791
+ tool: call.tool,
20792
+ readScripts: isBash && deps.readScripts ? (dir) => deps.readScripts(dir, config2.repoRoot) : void 0
20793
+ });
20235
20794
  if (!match) return { kind: "none" };
20236
20795
  if (deps.env.TOLLGATE_BYPASS === "1") {
20237
20796
  deps.audit("bypass", { preset: match.preset.name, repo: config2.repo });
@@ -20240,14 +20799,14 @@ async function decide(input2, deps) {
20240
20799
  if (config2.error) {
20241
20800
  return {
20242
20801
  kind: "deny",
20243
- reason: `Tollgate blocked this command because .tollgate.json is invalid (${config2.error}). Ask the user to fix it or run: npx tollgate-cli init`
20802
+ reason: `Tollgate blocked this ${TOOL_NOUN[call.tool]} because .tollgate.json is invalid (${config2.error}). Ask the user to fix it or run: npx tollgate-cli init`
20244
20803
  };
20245
20804
  }
20246
20805
  const token = deps.loadToken(config2.repoRoot);
20247
- if (!token) return { kind: "deny", reason: notLinkedMessage() };
20806
+ if (!token) return { kind: "deny", reason: notLinkedMessage(call.tool) };
20248
20807
  const started = deps.now();
20249
20808
  const deadline = started + Math.max(5, config2.hookTimeoutSec - PARK_MARGIN_SEC) * 1e3;
20250
- const cmdHash = commandHash({ repo: config2.repo, cwd, command });
20809
+ const cmdHash = commandHash({ repo: config2.repo, cwd, command: call.hashSource });
20251
20810
  const api = deps.api(config2, token);
20252
20811
  try {
20253
20812
  const grant = await api.consumeGrant(cmdHash);
@@ -20255,14 +20814,24 @@ async function decide(input2, deps) {
20255
20814
  return grant.mode === "delegate" ? { kind: "allow", reason: "Approved earlier by the team (single-use grant)." } : { kind: "none", note: "grant redeemed" };
20256
20815
  }
20257
20816
  const secrets = deps.envSecrets(config2.repoRoot);
20258
- const redactedCommand = redact(command, secrets);
20817
+ const redactedCommand = redact(call.subject, secrets);
20259
20818
  const redactedCwd = redact(cwd, secrets);
20260
- const context = redact(deps.context(command, config2.repoRoot), secrets);
20819
+ const context = redact(
20820
+ isBash ? deps.context(`${command}
20821
+ ${match.segment}`, config2.repoRoot) : call.detail,
20822
+ secrets
20823
+ );
20824
+ const via = match.via.length > 0 ? {
20825
+ from: redact(match.via.join(" \u2192 "), secrets),
20826
+ runs: redact(match.segment, secrets)
20827
+ } : null;
20261
20828
  const branch = deps.gitBranch(config2.repoRoot);
20262
- const description = input2.tool_input?.description;
20829
+ const description = isBash ? input2.tool_input?.description : void 0;
20263
20830
  const intent = typeof description === "string" && description.trim() ? redact(description.trim(), secrets) : null;
20264
20831
  let gate = await api.createGate({
20265
20832
  preset: match.preset.name,
20833
+ // Only sent when it isn't a command, so servers from before file gates still accept it.
20834
+ ...call.tool === "bash" ? {} : { tool: call.tool },
20266
20835
  quorum: match.quorum,
20267
20836
  env: match.env,
20268
20837
  command: truncate(redactedCommand.text, 4e3),
@@ -20270,9 +20839,11 @@ async function decide(input2, deps) {
20270
20839
  branch: branch ? truncate(redact(branch, secrets).text, 200) : null,
20271
20840
  context: truncate(context.text, 2048),
20272
20841
  intent: intent ? truncate(intent.text, 300) : null,
20273
- redactions: redactedCommand.count + redactedCwd.count + context.count + (intent?.count ?? 0),
20842
+ via: via ? [{ from: truncate(via.from.text, 300), runs: truncate(via.runs.text, 1e3) }] : void 0,
20843
+ redactions: redactedCommand.count + redactedCwd.count + context.count + (intent?.count ?? 0) + (via ? via.from.count + via.runs.count : 0),
20274
20844
  cmdHash,
20275
- sessionId: input2.session_id
20845
+ sessionId: input2.session_id,
20846
+ waitSec: Math.round((deadline - started) / 1e3)
20276
20847
  });
20277
20848
  deps.progress(
20278
20849
  `Tollgate: waiting on your team \xB7 TG-${gate.code} (0/${gate.quorum}) \xB7 ${gate.url}`
@@ -20281,18 +20852,19 @@ async function decide(input2, deps) {
20281
20852
  if (gate.status === "approved") {
20282
20853
  return gate.mode === "delegate" ? { kind: "allow", reason: `Approved by the team in TG-${gate.code}.` } : { kind: "none", note: `TG-${gate.code} approved` };
20283
20854
  }
20284
- if (gate.status === "denied") return { kind: "deny", reason: denyMessages(gate).denied };
20285
- if (gate.status === "expired") return { kind: "deny", reason: denyMessages(gate).expired };
20286
- if (gate.status === "parked") return { kind: "deny", reason: denyMessages(gate).parked };
20855
+ const messages = denyMessages(gate, call.tool);
20856
+ if (gate.status === "denied") return { kind: "deny", reason: messages.denied };
20857
+ if (gate.status === "expired") return { kind: "deny", reason: messages.expired };
20858
+ if (gate.status === "parked") return { kind: "deny", reason: messages.parked };
20287
20859
  if (deps.now() + POLL_MS >= deadline) {
20288
20860
  await api.park(gate.id).catch(() => void 0);
20289
- return { kind: "deny", reason: denyMessages(gate).parked };
20861
+ return { kind: "deny", reason: messages.parked };
20290
20862
  }
20291
20863
  await deps.sleep(POLL_MS);
20292
20864
  gate = await api.getGate(gate.id).catch(() => gate);
20293
20865
  }
20294
20866
  } catch (error62) {
20295
- return { kind: "deny", reason: failClosedMessage(config2.server, error62) };
20867
+ return { kind: "deny", reason: failClosedMessage(config2.server, error62, call.tool) };
20296
20868
  }
20297
20869
  }
20298
20870
  function renderDecision(decision) {
@@ -20310,6 +20882,7 @@ function renderDecision(decision) {
20310
20882
  import { copyFileSync, existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
20311
20883
  import { join as join2 } from "node:path";
20312
20884
  var SETTINGS_FILE = join2(".claude", "settings.local.json");
20885
+ var HOOK_MATCHER = "Bash|Edit|Write|MultiEdit|NotebookEdit|mcp__.*";
20313
20886
  function isTollgateHook(command) {
20314
20887
  return /tollgate/.test(command) && /\bhook\b/.test(command);
20315
20888
  }
@@ -20322,7 +20895,7 @@ function mergeHookSettings(existing, hookCommand, timeoutSec) {
20322
20895
  hooks.PreToolUse = [
20323
20896
  ...kept,
20324
20897
  {
20325
- matcher: "Bash",
20898
+ matcher: HOOK_MATCHER,
20326
20899
  hooks: [
20327
20900
  {
20328
20901
  type: "command",
@@ -20388,7 +20961,7 @@ function writeRepoFiles(input2) {
20388
20961
  }
20389
20962
 
20390
20963
  // src/main.ts
20391
- var HELP = `tollgate ${"0.2.1"}: your team approves risky agent commands before they run
20964
+ var HELP = `tollgate ${"0.2.4"}: your team approves risky agent commands before they run
20392
20965
 
20393
20966
  Usage:
20394
20967
  npx tollgate-cli init --token <tg_\u2026> [--server <url>] [--repo <owner/name>]
@@ -20405,7 +20978,9 @@ function hookDeps() {
20405
20978
  envSecrets,
20406
20979
  gitBranch,
20407
20980
  context: buildContext,
20981
+ readScripts,
20408
20982
  env: process.env,
20983
+ home: homedir2(),
20409
20984
  now: () => Date.now(),
20410
20985
  sleep: (ms) => new Promise((done) => setTimeout(done, ms)),
20411
20986
  audit: (event, detail) => {
@@ -20472,7 +21047,7 @@ async function runInit(argv) {
20472
21047
  dir: { type: "string" }
20473
21048
  }
20474
21049
  });
20475
- const repoRoot = resolve(values.dir ?? process.cwd());
21050
+ const repoRoot = resolve2(values.dir ?? process.cwd());
20476
21051
  const token = values.token ?? process.env.TOLLGATE_TOKEN;
20477
21052
  const server = (values.server ?? process.env.TOLLGATE_SERVER ?? "").replace(/\/+$/, "");
20478
21053
  if (!token || !server) {
@@ -20482,7 +21057,7 @@ async function runInit(argv) {
20482
21057
  }
20483
21058
  const who = await whoAmI(server, token);
20484
21059
  const repo = values.repo ?? who.repo.name ?? gitRepoName(repoRoot) ?? "repo";
20485
- const bundle = installBundle(resolve(process.argv[1] ?? ""), tollgateHome());
21060
+ const bundle = installBundle(resolve2(process.argv[1] ?? ""), tollgateHome());
20486
21061
  const tokenFile2 = saveToken(repoRoot, { token, server, repo });
20487
21062
  const files = writeRepoFiles({ repoRoot, server, repo, hookCommand: hookCommandFor(bundle) });
20488
21063
  process.stdout.write(
@@ -20572,7 +21147,7 @@ async function main() {
20572
21147
  return runUnlink();
20573
21148
  case "--version":
20574
21149
  case "-v":
20575
- process.stdout.write(`${"0.2.1"}
21150
+ process.stdout.write(`${"0.2.4"}
20576
21151
  `);
20577
21152
  return;
20578
21153
  default:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tollgate-cli",
3
- "version": "0.2.1",
3
+ "version": "0.2.4",
4
4
  "description": "Your team approves risky Claude Code commands before they run.",
5
5
  "license": "MIT",
6
6
  "type": "module",