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 +10 -1
- package/dist/tollgate.mjs +214 -57
- package/package.json +1 -1
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 {
|
|
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
|
|
849
|
+
let str2 = "";
|
|
849
850
|
for (let i = 0; i < length; i++) {
|
|
850
|
-
|
|
851
|
+
str2 += chars[Math.floor(Math.random() * chars.length)];
|
|
851
852
|
}
|
|
852
|
-
return
|
|
853
|
+
return str2;
|
|
853
854
|
}
|
|
854
|
-
function esc(
|
|
855
|
-
return JSON.stringify(
|
|
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(
|
|
970
|
-
return
|
|
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(
|
|
1237
|
-
const units =
|
|
1238
|
-
if (!highSurrogate.test(
|
|
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 ((
|
|
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
|
|
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
|
-
|
|
1757
|
+
lines2.push(`\u2716 ${issue2.message}`);
|
|
1757
1758
|
if (issue2.path?.length)
|
|
1758
|
-
|
|
1759
|
+
lines2.push(` \u2192 at ${toDotPath(issue2.path)}`);
|
|
1759
1760
|
}
|
|
1760
|
-
return
|
|
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
|
|
2596
|
-
const minIndent = Math.min(...
|
|
2597
|
-
const dedented =
|
|
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,
|
|
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,
|
|
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,
|
|
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 ?? (
|
|
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 ??
|
|
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 ? {
|
|
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
|
|
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 ?
|
|
20621
|
-
parked: `Parked as TG-${gate.code}: nobody on the team decided in time. Continue with other work that doesn't need this
|
|
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
|
|
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
|
|
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
|
-
|
|
20634
|
-
const
|
|
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
|
-
|
|
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
|
|
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(
|
|
20817
|
+
const redactedCommand = redact(call.subject, secrets);
|
|
20669
20818
|
const redactedCwd = redact(cwd, secrets);
|
|
20670
|
-
const context = redact(
|
|
20671
|
-
|
|
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
|
-
|
|
20702
|
-
if (gate.status === "
|
|
20703
|
-
if (gate.status === "
|
|
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:
|
|
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:
|
|
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.
|
|
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 =
|
|
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(
|
|
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.
|
|
21150
|
+
process.stdout.write(`${"0.2.4"}
|
|
20994
21151
|
`);
|
|
20995
21152
|
return;
|
|
20996
21153
|
default:
|