tollgate-cli 0.2.3 → 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.
@@ -46,7 +53,7 @@ Tollgate is not a shell parser and not a sandbox. It does **not** read Makefiles
46
53
 
47
54
  ## What leaves your machine
48
55
 
49
- 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.
50
57
 
51
58
  ## Commands
52
59
 
@@ -58,6 +65,8 @@ Only for commands that match a preset: the command and working directory after *
58
65
  | `unlink` | Forget this repo's token on this machine (revoke it in the web app) |
59
66
  | `hook` | The hook itself; Claude Code calls it with JSON on stdin |
60
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
+
61
70
  ## Safety notes
62
71
 
63
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
  }
@@ -20059,7 +20060,15 @@ function expandCommand(command, options) {
20059
20060
  }
20060
20061
 
20061
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
+ };
20062
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(),
20063
20072
  name: external_exports.string().regex(/^[a-z0-9][a-z0-9-]{0,39}$/).min(1),
20064
20073
  patterns: external_exports.array(external_exports.string().min(1).max(300)).min(1).max(20),
20065
20074
  // A match is skipped when any of these also matches (e.g. --dry-run changes nothing).
@@ -20161,6 +20170,52 @@ var DEFAULT_PRESETS = [
20161
20170
  env: "other",
20162
20171
  quorum: 1,
20163
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
20164
20219
  }
20165
20220
  ];
20166
20221
  var PROD_RE = /(^|[\s=:/_-])(prod|production)($|[\s.:/_-])|--prod\b/i;
@@ -20208,15 +20263,22 @@ function rmTargetsOutsideRepo(command, cwd, repoRoot) {
20208
20263
  }
20209
20264
  return false;
20210
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
+ }
20211
20271
  function matchPreset(presets, input2) {
20272
+ const tool = input2.tool ?? "bash";
20212
20273
  for (const preset of presets) {
20274
+ if ((preset.tool ?? "bash") !== tool) continue;
20213
20275
  const hit = preset.patterns.some((source) => compile2(source)?.test(input2.command) ?? false);
20214
20276
  if (!hit) continue;
20215
20277
  if (preset.unless?.some((source) => compile2(source)?.test(input2.command) ?? false)) continue;
20216
20278
  if (preset.outsideRepoOnly && !rmTargetsOutsideRepo(input2.command, input2.cwd, input2.repoRoot)) {
20217
20279
  continue;
20218
20280
  }
20219
- const env = preset.env ?? detectEnv(input2.command);
20281
+ const env = preset.env ?? envOf(tool, input2.command);
20220
20282
  const quorum = env === "prod" ? Math.max(preset.quorum, preset.prodQuorum ?? 0) : preset.quorum;
20221
20283
  return { preset, env, quorum };
20222
20284
  }
@@ -20227,23 +20289,29 @@ function withDefaultPresets(presets) {
20227
20289
  const names = new Set(presets.map((preset) => preset.name));
20228
20290
  const merged = presets.map((preset) => {
20229
20291
  const builtIn = defaults.get(preset.name);
20230
- return builtIn ? { ...preset, patterns: [.../* @__PURE__ */ new Set([...preset.patterns, ...builtIn.patterns])] } : preset;
20292
+ return builtIn ? {
20293
+ ...preset,
20294
+ tool: builtIn.tool,
20295
+ patterns: [.../* @__PURE__ */ new Set([...preset.patterns, ...builtIn.patterns])]
20296
+ } : preset;
20231
20297
  });
20232
20298
  return [...merged, ...DEFAULT_PRESETS.filter((preset) => !names.has(preset.name))];
20233
20299
  }
20234
20300
  var ENV_RANK = { prod: 2, staging: 1, other: 0 };
20235
20301
  function matchCommand(presets, input2) {
20236
- const pieces = [
20302
+ const tool = input2.tool ?? "bash";
20303
+ const pieces = tool === "bash" ? [
20237
20304
  { text: input2.command, via: [] },
20238
20305
  ...expandCommand(input2.command, { cwd: input2.cwd, readScripts: input2.readScripts }),
20239
20306
  ...input2.extra ?? []
20240
- ];
20307
+ ] : [{ text: input2.command, via: [] }];
20241
20308
  let best = null;
20242
20309
  for (const piece of pieces) {
20243
20310
  const match = matchPreset(presets, {
20244
20311
  command: piece.text,
20245
20312
  cwd: input2.cwd,
20246
- repoRoot: input2.repoRoot
20313
+ repoRoot: input2.repoRoot,
20314
+ tool
20247
20315
  });
20248
20316
  if (!match) continue;
20249
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;
@@ -20504,6 +20572,8 @@ function commandHash(input2) {
20504
20572
  }
20505
20573
  var createGateSchema = external_exports.object({
20506
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"),
20507
20577
  quorum: external_exports.number().int().min(1).max(9).optional(),
20508
20578
  env: external_exports.enum(["prod", "staging", "other"]).optional(),
20509
20579
  command: external_exports.string().min(1).max(4e3),
@@ -20611,36 +20681,115 @@ function truncate(text, maxChars) {
20611
20681
  return `${text.slice(0, Math.max(0, maxChars - 15))}\u2026 [truncated]`;
20612
20682
  }
20613
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
+
20614
20754
  // src/hook.ts
20615
20755
  var POLL_MS = 2e3;
20616
20756
  var PARK_MARGIN_SEC = 15;
20617
- function denyMessages(gate) {
20757
+ function denyMessages(gate, tool = "bash") {
20618
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.`;
20619
20761
  return {
20620
- 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.`,
20621
- 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}).`,
20622
20764
  expired: `TG-${gate.code} expired without approval. Ask the user whether to raise it again.`
20623
20765
  };
20624
20766
  }
20625
- function notLinkedMessage() {
20626
- 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`;
20627
20769
  }
20628
- function failClosedMessage(server, error62) {
20770
+ function failClosedMessage(server, error62, tool = "bash") {
20629
20771
  const detail = error62 instanceof Error ? error62.message : String(error62);
20630
- 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.`;
20631
20773
  }
20632
20774
  async function decide(input2, deps) {
20633
- if (input2.tool_name !== "Bash") return { kind: "none" };
20634
- 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 : "";
20635
20781
  const cwd = input2.cwd || process.cwd();
20636
- if (!command.trim()) return { kind: "none" };
20782
+ if (isBash && !command.trim()) return { kind: "none" };
20637
20783
  const config2 = deps.loadConfig(cwd);
20638
20784
  if (!config2) return { kind: "none", note: "no .tollgate.json" };
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" };
20639
20787
  const match = matchCommand(withDefaultPresets(config2.presets), {
20640
- command,
20788
+ command: call.subject,
20641
20789
  cwd,
20642
20790
  repoRoot: config2.repoRoot,
20643
- readScripts: deps.readScripts ? (dir) => deps.readScripts(dir, config2.repoRoot) : void 0
20791
+ tool: call.tool,
20792
+ readScripts: isBash && deps.readScripts ? (dir) => deps.readScripts(dir, config2.repoRoot) : void 0
20644
20793
  });
20645
20794
  if (!match) return { kind: "none" };
20646
20795
  if (deps.env.TOLLGATE_BYPASS === "1") {
@@ -20650,14 +20799,14 @@ async function decide(input2, deps) {
20650
20799
  if (config2.error) {
20651
20800
  return {
20652
20801
  kind: "deny",
20653
- 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`
20654
20803
  };
20655
20804
  }
20656
20805
  const token = deps.loadToken(config2.repoRoot);
20657
- if (!token) return { kind: "deny", reason: notLinkedMessage() };
20806
+ if (!token) return { kind: "deny", reason: notLinkedMessage(call.tool) };
20658
20807
  const started = deps.now();
20659
20808
  const deadline = started + Math.max(5, config2.hookTimeoutSec - PARK_MARGIN_SEC) * 1e3;
20660
- const cmdHash = commandHash({ repo: config2.repo, cwd, command });
20809
+ const cmdHash = commandHash({ repo: config2.repo, cwd, command: call.hashSource });
20661
20810
  const api = deps.api(config2, token);
20662
20811
  try {
20663
20812
  const grant = await api.consumeGrant(cmdHash);
@@ -20665,19 +20814,24 @@ async function decide(input2, deps) {
20665
20814
  return grant.mode === "delegate" ? { kind: "allow", reason: "Approved earlier by the team (single-use grant)." } : { kind: "none", note: "grant redeemed" };
20666
20815
  }
20667
20816
  const secrets = deps.envSecrets(config2.repoRoot);
20668
- const redactedCommand = redact(command, secrets);
20817
+ const redactedCommand = redact(call.subject, secrets);
20669
20818
  const redactedCwd = redact(cwd, secrets);
20670
- const context = redact(deps.context(`${command}
20671
- ${match.segment}`, config2.repoRoot), secrets);
20819
+ const context = redact(
20820
+ isBash ? deps.context(`${command}
20821
+ ${match.segment}`, config2.repoRoot) : call.detail,
20822
+ secrets
20823
+ );
20672
20824
  const via = match.via.length > 0 ? {
20673
20825
  from: redact(match.via.join(" \u2192 "), secrets),
20674
20826
  runs: redact(match.segment, secrets)
20675
20827
  } : null;
20676
20828
  const branch = deps.gitBranch(config2.repoRoot);
20677
- const description = input2.tool_input?.description;
20829
+ const description = isBash ? input2.tool_input?.description : void 0;
20678
20830
  const intent = typeof description === "string" && description.trim() ? redact(description.trim(), secrets) : null;
20679
20831
  let gate = await api.createGate({
20680
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 },
20681
20835
  quorum: match.quorum,
20682
20836
  env: match.env,
20683
20837
  command: truncate(redactedCommand.text, 4e3),
@@ -20698,18 +20852,19 @@ ${match.segment}`, config2.repoRoot), secrets);
20698
20852
  if (gate.status === "approved") {
20699
20853
  return gate.mode === "delegate" ? { kind: "allow", reason: `Approved by the team in TG-${gate.code}.` } : { kind: "none", note: `TG-${gate.code} approved` };
20700
20854
  }
20701
- if (gate.status === "denied") return { kind: "deny", reason: denyMessages(gate).denied };
20702
- if (gate.status === "expired") return { kind: "deny", reason: denyMessages(gate).expired };
20703
- 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 };
20704
20859
  if (deps.now() + POLL_MS >= deadline) {
20705
20860
  await api.park(gate.id).catch(() => void 0);
20706
- return { kind: "deny", reason: denyMessages(gate).parked };
20861
+ return { kind: "deny", reason: messages.parked };
20707
20862
  }
20708
20863
  await deps.sleep(POLL_MS);
20709
20864
  gate = await api.getGate(gate.id).catch(() => gate);
20710
20865
  }
20711
20866
  } catch (error62) {
20712
- return { kind: "deny", reason: failClosedMessage(config2.server, error62) };
20867
+ return { kind: "deny", reason: failClosedMessage(config2.server, error62, call.tool) };
20713
20868
  }
20714
20869
  }
20715
20870
  function renderDecision(decision) {
@@ -20727,6 +20882,7 @@ function renderDecision(decision) {
20727
20882
  import { copyFileSync, existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
20728
20883
  import { join as join2 } from "node:path";
20729
20884
  var SETTINGS_FILE = join2(".claude", "settings.local.json");
20885
+ var HOOK_MATCHER = "Bash|Edit|Write|MultiEdit|NotebookEdit|mcp__.*";
20730
20886
  function isTollgateHook(command) {
20731
20887
  return /tollgate/.test(command) && /\bhook\b/.test(command);
20732
20888
  }
@@ -20739,7 +20895,7 @@ function mergeHookSettings(existing, hookCommand, timeoutSec) {
20739
20895
  hooks.PreToolUse = [
20740
20896
  ...kept,
20741
20897
  {
20742
- matcher: "Bash",
20898
+ matcher: HOOK_MATCHER,
20743
20899
  hooks: [
20744
20900
  {
20745
20901
  type: "command",
@@ -20805,7 +20961,7 @@ function writeRepoFiles(input2) {
20805
20961
  }
20806
20962
 
20807
20963
  // src/main.ts
20808
- var HELP = `tollgate ${"0.2.3"}: 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
20809
20965
 
20810
20966
  Usage:
20811
20967
  npx tollgate-cli init --token <tg_\u2026> [--server <url>] [--repo <owner/name>]
@@ -20824,6 +20980,7 @@ function hookDeps() {
20824
20980
  context: buildContext,
20825
20981
  readScripts,
20826
20982
  env: process.env,
20983
+ home: homedir2(),
20827
20984
  now: () => Date.now(),
20828
20985
  sleep: (ms) => new Promise((done) => setTimeout(done, ms)),
20829
20986
  audit: (event, detail) => {
@@ -20890,7 +21047,7 @@ async function runInit(argv) {
20890
21047
  dir: { type: "string" }
20891
21048
  }
20892
21049
  });
20893
- const repoRoot = resolve(values.dir ?? process.cwd());
21050
+ const repoRoot = resolve2(values.dir ?? process.cwd());
20894
21051
  const token = values.token ?? process.env.TOLLGATE_TOKEN;
20895
21052
  const server = (values.server ?? process.env.TOLLGATE_SERVER ?? "").replace(/\/+$/, "");
20896
21053
  if (!token || !server) {
@@ -20900,7 +21057,7 @@ async function runInit(argv) {
20900
21057
  }
20901
21058
  const who = await whoAmI(server, token);
20902
21059
  const repo = values.repo ?? who.repo.name ?? gitRepoName(repoRoot) ?? "repo";
20903
- const bundle = installBundle(resolve(process.argv[1] ?? ""), tollgateHome());
21060
+ const bundle = installBundle(resolve2(process.argv[1] ?? ""), tollgateHome());
20904
21061
  const tokenFile2 = saveToken(repoRoot, { token, server, repo });
20905
21062
  const files = writeRepoFiles({ repoRoot, server, repo, hookCommand: hookCommandFor(bundle) });
20906
21063
  process.stdout.write(
@@ -20990,7 +21147,7 @@ async function main() {
20990
21147
  return runUnlink();
20991
21148
  case "--version":
20992
21149
  case "-v":
20993
- process.stdout.write(`${"0.2.3"}
21150
+ process.stdout.write(`${"0.2.4"}
20994
21151
  `);
20995
21152
  return;
20996
21153
  default:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tollgate-cli",
3
- "version": "0.2.3",
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",