toolfunnel 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -7
- package/SECURITY.md +84 -0
- package/bin/toolfunnel.js +97 -18
- package/docs/KNOWN_BUGS.md +71 -90
- package/docs/MANUAL.pdf +0 -0
- package/docs/design.md +24 -10
- package/hooks/hooks.manifest.json +1 -1
- package/hooks/scripts/example-deny-dangerous.js +12 -4
- package/mcp/servers/mock-upstream/server.js +36 -6
- package/package.json +67 -65
- package/src/auth/config.js +2 -2
- package/src/auth/resource-server.js +12 -3
- package/src/core/child-reaper.js +61 -0
- package/src/core/events.js +7 -7
- package/src/core/hook-engine.js +85 -31
- package/src/core/hook-loader.js +46 -13
- package/src/core/hook-runner.js +258 -38
- package/src/core/logger.js +138 -12
- package/src/core/matcher.js +59 -10
- package/src/core/rename-migration.js +163 -0
- package/src/core/requires.js +1 -1
- package/src/extend/add-hook.md +2 -2
- package/src/extend/add-mcp.md +3 -6
- package/src/extend/configure.md +2 -1
- package/src/extend/create-tool.md +1 -1
- package/src/extend/howto.js +3 -3
- package/src/extend/package.md +11 -8
- package/src/mcp/aggregator.js +480 -52
- package/src/mcp/expose-store.js +130 -13
- package/src/mcp/gated-run.js +26 -17
- package/src/mcp/http-transport.js +274 -22
- package/src/mcp/mcp-client.js +194 -31
- package/src/mcp/modern.js +44 -10
- package/src/mcp/server.js +513 -89
- package/src/tools/registry.js +172 -24
- package/src/ui/public/app.js +17 -4
- package/src/ui/server.js +297 -29
- package/tools/scripts/tf-hook-set.js +11 -3
- package/tools/tools.register.json +227 -227
- package/src/core/tool-registry.js +0 -180
- package/src/packages/packager.js +0 -715
- package/src/tools/drift.js +0 -138
package/README.md
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|

|
|
4
4
|
|
|
5
|
+
[](https://github.com/Rendeverance/toolfunnel/actions/workflows/ci.yml)
|
|
6
|
+
|
|
5
7
|
> A zero-dependency MCP and Local tool gateway: host your own tools in any language, forward and curate tools from other MCP servers, expose them leanly to cut agent token cost, gate every call through your own policy hooks before it runs, create your own MCP servers easily with zero code - and **wrap any MCP server so it speaks both protocol eras, invisibly**.
|
|
6
8
|
|
|
7
9
|
**Zero code, zero dependencies: your own MCP server in 60 seconds** - from three ordinary scripts to a named, packaged, policy-gated MCP server (everything on screen is real output; see [demo/](demo/)):
|
|
@@ -16,11 +18,11 @@ Toolfunnel is designed to solve multiple issues in one package :)
|
|
|
16
18
|
|
|
17
19
|
The Model Context Protocol (MCP) lets an AI agent call tools from many servers. But every connected server dumps **all** of its tool schemas into the model's context on every turn. Connect a handful of rich MCP servers and you've spent thousands of tokens describing tools the agent won't use this turn - slower, costlier, noisier.
|
|
18
20
|
|
|
19
|
-
Similarly, working with AI I found myself generating many many tools, some of which I wanted to use with different AI workflows, some I
|
|
21
|
+
Similarly, working with AI I found myself generating many many tools, some of which I wanted to use with different AI workflows, some I didn't, and I didn't want to keep setting them up, I also didn't wish to keep many different setups for different workflows. Therefore ToolFunnel was born so that all of my multi-use tools and common MCP servers can be in one place, with any workflow, and I can easily select which I want to use with whatever workflow and even change or add tools during a session with simple toggles in the UI. I also wanted to wire up and test MCPs *live*, in the running session, without resetting the CLI or restarting anything - and have their tools show up in the tools list straight away; ToolFunnel does exactly that.
|
|
20
22
|
|
|
21
23
|
Often also, there's also no consistent way to **govern** what an agent may run: hooks and policies live in the *host* (a specific CLI or otherwise), so they don't travel when you switch clients - with ToolFunnel, this is easy because your hooks travel with the tools - it can become your one swiss-army knife for many different workflows.
|
|
22
24
|
|
|
23
|
-
Additionally, I wanted an easy and consistent way to package my own tools and create new MCP servers from other tools, whatever language they were written in. FastMCP could do that, but not everything is written in python. I also
|
|
25
|
+
Additionally, I wanted an easy and consistent way to package my own tools and create new MCP servers from other tools, whatever language they were written in. FastMCP could do that, but not everything is written in python. I also didn't want to audit huge numbers of dependencies - a personal choice, yes - so I wanted something that could be audited quickly and easily.
|
|
24
26
|
|
|
25
27
|
Finally, when the MCP changes were announced as breaking changes, it became apparent this would eventually break a lot of MCP servers and/or clients out there, many of which might not be actively maintained. Not only that but changing MCP protocol generations takes time and might take considerable effort depending on the complexity of the server - therefore I added a quick, simple single command tool that allows ToolFunnel to wrap an MCP and easily translate between new and legacy protocols - quick, low effort, maximum gain.
|
|
26
28
|
|
|
@@ -38,18 +40,20 @@ ToolFunnel is one small MCP server that sits between your agent and everything e
|
|
|
38
40
|
6. **Audit when you want it** - a toggleable JSONL log (default off) records tool runs, every gate allow/deny decision, and every upstream connect / disconnect / reconnect.
|
|
39
41
|
7. **Live & self-healing** - attach/curate/toggle on a running gateway with no restart; if an attached MCP's process dies, the gateway detects it and reconnects in the background with backoff.
|
|
40
42
|
8. **Build your own MCP server, no code, no SDK** - assemble scripts and curated upstream tools, switch the meta-tools off, and ToolFunnel *is* your MCP server; `tf_pack` ships it as a `npx`-installable npm package.
|
|
41
|
-
9. **Wrap any MCP server** - one command turns ToolFunnel into a transparent, dual-era wrapper for a single MCP server: modern clients can use legacy servers, legacy clients can use modern servers, and neither side can tell ToolFunnel is there. *(The headline of
|
|
43
|
+
9. **Wrap any MCP server** - one command turns ToolFunnel into a transparent, dual-era wrapper for a single MCP server: modern clients can use legacy servers, legacy clients can use modern servers, and neither side can tell ToolFunnel is there. *(The headline of the dual-era work - see below.)*
|
|
42
44
|
|
|
43
45
|
## Under the hood
|
|
44
46
|
|
|
45
47
|
Three claims in this README carry the most weight, and each one has a proof you can run:
|
|
46
48
|
|
|
47
|
-
- **
|
|
49
|
+
- **A deny is absolute - and an unreadable decision is a deny.** A PreToolUse deny means the tool's execute path is never entered. The test suite proves it directly: a tool plants a side-effect file, the gate denies the call, and the test asserts the file never appears. A hook whose decision cannot be *read* - output past the cap, unparseable JSON - is also denied, because an unreadable answer must never read as permission. The deliberate exception: a hook script that crashes, is missing, or hangs past its timeout is a non-blocking error and the call proceeds (Claude Code hook parity - one broken hook file must not take every tool offline). So write denies as `exit 2`, never as a bare non-zero. `test/gate-semantics.test.js` pins every case.
|
|
48
50
|
- **The wrap is tested at the wire, against servers we didn't write.** Elicitation bridging, cancel translation into the upstream's own request ids, identity mirroring and subscription replay all run over real pipes in the suite, including integration tests against the official MCP SDK client and a real npx-launched third-party server.
|
|
49
|
-
- **The suite runs on 3 operating systems.** CI covers Linux, macOS and Windows across Node 18, 20 and 22 (
|
|
51
|
+
- **The suite runs on 3 operating systems.** CI covers Linux, macOS and Windows across Node 18, 20 and 22 (63 test files). `npm test` runs the same suite locally.
|
|
50
52
|
|
|
51
53
|
The reasoning behind the bigger design decisions (why no SDK, why fail-closed, why zero dependencies, where the isolation boundary sits) is in [docs/design.md](docs/design.md).
|
|
52
54
|
|
|
55
|
+
**Current release: 0.7.0** - the reconciliation pass against the finalised 2026-07-28 specification plus a defect-hunt hardening pass: best-version protocol negotiation for connecting clients, stricter HTTP header enforcement, verbatim upstream error relay, hook-matcher migration on rename, both elicitation modes, and a refcounted subscription lifecycle. Every item landed with a failing test first and the wire surface byte-diffed before and after. The full list of what changed is in the [release notes](https://github.com/Rendeverance/toolfunnel/releases); what is still open is in [docs/KNOWN_BUGS.md](docs/KNOWN_BUGS.md); the security model is in [SECURITY.md](SECURITY.md).
|
|
56
|
+
|
|
53
57
|
## Wrap any MCP server - one command, both protocol eras, invisible
|
|
54
58
|
|
|
55
59
|
The 2026-07-28 MCP revision is a **breaking** protocol change: it removes the `initialize` handshake and sessions that every earlier server and client is built on. When clients upgrade, unmaintained legacy servers stop working with them; older clients can't talk to new-style servers at all. A lot of good tools are going to get stranded on the wrong side of that line.
|
|
@@ -119,7 +123,7 @@ Hooks speak the Claude-Code hook protocol (event JSON on stdin, exit `2` to bloc
|
|
|
119
123
|
|
|
120
124
|
Hosting tools and proxying other MCP servers is a crowded space - but for my own use case, which is why I rolled my own solution, I wanted something different.
|
|
121
125
|
|
|
122
|
-
A quick feature comparison as of
|
|
126
|
+
A quick feature comparison as of Aug 2nd 2026:
|
|
123
127
|
|
|
124
128
|
| Capability | ToolFunnel | FastMCP | mcpproxy-go | MetaMCP | mcp-anything |
|
|
125
129
|
|---|:--:|:--:|:--:|:--:|:--:|
|
|
@@ -132,7 +136,7 @@ A quick feature comparison as of June 2026:
|
|
|
132
136
|
| Zero runtime dependencies\* | ✓ (Node, no SDK) | ✗ (framework) | ✓ (Go) | ✗ (Docker) | ✓ (Go) |
|
|
133
137
|
| Runtime dependencies (installed / bundled) | **0\*** | many | 40+ (bundled) | many | bundled (Go) |
|
|
134
138
|
| Config web UI | ✓ | ✗ | ✓ | ✓ | ✗ |
|
|
135
|
-
| Dual-era protocol (2026-07-28 + legacy), client & server side | ✓ |
|
|
139
|
+
| Dual-era protocol (2026-07-28 + legacy), client & server side | ✓ | ✗<br>✓(beta) | ✗ | ✗ | ✗ |
|
|
136
140
|
| Transparent single-server wrap (invisible, era-bridging) | ✓ | ✗ | ✗ | ✗ | ✗ |
|
|
137
141
|
|
|
138
142
|
Beyond the dependency count, four capability choices set ToolFunnel apart from a pure proxy like mcpproxy-go:
|
package/SECURITY.md
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# Security Policy
|
|
2
|
+
|
|
3
|
+
ToolFunnel sits between MCP clients and MCP servers - it gates, filters, and forwards
|
|
4
|
+
tool calls. That makes its security surface worth taking seriously, and reports are
|
|
5
|
+
genuinely welcome.
|
|
6
|
+
|
|
7
|
+
## Reporting a vulnerability
|
|
8
|
+
|
|
9
|
+
**Please use GitHub's private vulnerability reporting:** go to the
|
|
10
|
+
[Security tab](https://github.com/Rendeverance/toolfunnel/security) → **Report a
|
|
11
|
+
vulnerability**. That opens a private thread with the maintainer - nothing is public
|
|
12
|
+
until a fix is out.
|
|
13
|
+
|
|
14
|
+
Please **do not** open a public issue for a security-sensitive report. If you're unsure
|
|
15
|
+
whether something is security-relevant, please err on the side of the private route.
|
|
16
|
+
|
|
17
|
+
## What counts
|
|
18
|
+
|
|
19
|
+
Anything that lets a client or upstream server do what the configuration says it
|
|
20
|
+
shouldn't. For example:
|
|
21
|
+
|
|
22
|
+
- Bypassing the tool gate / hidden-tool filtering (`tf_tool_set`, expose config)
|
|
23
|
+
- Auth bypass in the HTTP transport or OAuth resource-server validation
|
|
24
|
+
- Token or credential leakage (in logs, error messages, or forwarded traffic)
|
|
25
|
+
- Escaping the PreToolUse deny-hook
|
|
26
|
+
- Injection via tool names, schemas, or forwarded arguments
|
|
27
|
+
|
|
28
|
+
Bugs that crash ToolFunnel but don't cross a trust boundary are ordinary bugs -
|
|
29
|
+
public issues are fine for those.
|
|
30
|
+
|
|
31
|
+
## What to expect
|
|
32
|
+
|
|
33
|
+
This is a single-maintainer project. Reports get a response on a best-effort basis -
|
|
34
|
+
normally within a few days. Confirmed vulnerabilities are fixed as a priority, and
|
|
35
|
+
you'll be credited in the release notes unless you'd rather not be.
|
|
36
|
+
|
|
37
|
+
## Supported versions
|
|
38
|
+
|
|
39
|
+
Only the latest published release (`npm install toolfunnel`) is supported with
|
|
40
|
+
security fixes.
|
|
41
|
+
|
|
42
|
+
## Trust model
|
|
43
|
+
|
|
44
|
+
**The config home is the trust boundary.** Anyone who can write the config home
|
|
45
|
+
(`tools/`, `mcp/`, `hooks/`, `auth/`, `toolfunnel.json`) can define register tools, hooks,
|
|
46
|
+
and upstream commands that execute with the gateway process's privileges. Protect it with
|
|
47
|
+
filesystem permissions like you would a crontab or a shell profile.
|
|
48
|
+
|
|
49
|
+
Everything else follows from that line:
|
|
50
|
+
|
|
51
|
+
- **Upstream MCP servers are operator-chosen code.** Attaching or wrapping a server means
|
|
52
|
+
trusting it with whatever its command can reach. In funnel mode the gateway *refuses*
|
|
53
|
+
upstream arguments, working directories, and code-loading environment variables that
|
|
54
|
+
reference paths outside the config home. The per-upstream opt-outs are explicit, and each
|
|
55
|
+
is wider than a single check: `allowOutsidePaths` lifts the outside-path refusal for
|
|
56
|
+
everything path-shaped that upstream carries - its arguments (including the value after
|
|
57
|
+
`=` in a `--flag=value` form), the working directory its bare arguments resolve against,
|
|
58
|
+
and its path-list environment variables; `allowCodeLoadingEnv` permits the code-loading
|
|
59
|
+
environment class (`NODE_OPTIONS`, `NODE_PATH`, and their equivalents for other runtimes)
|
|
60
|
+
on its own. Wrap mode permits outside paths with a security notice, because serving your
|
|
61
|
+
real files is usually the point of a wrapped server. The guard is a tripwire for configs that quietly reach
|
|
62
|
+
outside an auditable config home - not a sandbox against an untrusted pack author:
|
|
63
|
+
installing a pack is installing software.
|
|
64
|
+
- **The network surface binds loopback by default** (HTTP, SSE, and the web UI), validates
|
|
65
|
+
the `Origin` header against loopback origins, and enforces the protocol-version
|
|
66
|
+
and parameter-mirroring header rules of the 2026-07-28 revision. Exposing the port beyond
|
|
67
|
+
localhost is an operator decision; put OAuth 2.1 (opt-in) or your own reverse proxy in
|
|
68
|
+
front of it if you do.
|
|
69
|
+
- **Zero runtime dependencies** - the supply-chain audit surface is this repository's own
|
|
70
|
+
code (OAuth, if enabled, adds exactly one audited library: `jose`).
|
|
71
|
+
- **An unreadable gate decision is a deny.** A hook that floods its output past the cap, whose
|
|
72
|
+
decision JSON cannot be parsed, or whose decision slot carries a value outside the exact
|
|
73
|
+
protocol vocabulary (a miscased `"DENY"`, a synonym like `"denied"`, the right word in the
|
|
74
|
+
wrong slot, a wrong type) blocks the call it was gating rather than waving it through. A hook
|
|
75
|
+
matcher that does not compile is the same class: it is refused at authoring, and one already
|
|
76
|
+
in the manifest denies every tool-bearing call of its event until fixed. A `null` in a
|
|
77
|
+
protocol slot is NOT an attempt - it reads as "no value", exactly like an absent key, and
|
|
78
|
+
falls through. The deliberate exception: a hook script that CRASHES, is MISSING, or HANGS
|
|
79
|
+
past its timeout is a non-blocking error and the call proceeds - Claude Code hook parity, so
|
|
80
|
+
a script-execution failure cannot take every tool offline. An authored-config defect (the
|
|
81
|
+
matcher, the decision vocabulary) gets no such grace: the author wrote a gate the runner
|
|
82
|
+
cannot read, and it denies until corrected. Denies are `exit 2`; a gate that cannot reach a
|
|
83
|
+
decision should exit 2 explicitly rather than error out. Pinned by
|
|
84
|
+
`test/gate-semantics.test.js` and `test/matcher-compile.test.js`.
|
package/bin/toolfunnel.js
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* toolfunnel --ui Start the OPTIONAL config web UI on 127.0.0.1:9777
|
|
12
12
|
* toolfunnel --ui --port <n> Bind a specific UI port (0 = OS-assigned)
|
|
13
13
|
* toolfunnel install-oauth Install the OPTIONAL OAuth 2.1 dependency (jose) on demand
|
|
14
|
+
* toolfunnel --version Print the version
|
|
14
15
|
* toolfunnel --help Show this help
|
|
15
16
|
*
|
|
16
17
|
* Zero runtime dependencies. The stdio path runs the server's main(); the HTTP
|
|
@@ -21,9 +22,37 @@
|
|
|
21
22
|
|
|
22
23
|
const args = process.argv.slice(2);
|
|
23
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Read a flag's value, accepting BOTH `--flag value` and `--flag=value` (0.7.0). Only the
|
|
27
|
+
* space-separated form was handled, so `--config-dir=/my/home` was silently ignored and the config
|
|
28
|
+
* home fell back to the package root - exactly the class of quiet misconfiguration the resolved-home
|
|
29
|
+
* stderr line and the positional parsing below exist to prevent. The `=` form is what most people
|
|
30
|
+
* type first, and getting it wrong wrote config somewhere they were not looking.
|
|
31
|
+
*/
|
|
24
32
|
function flagValue(name, fallback) {
|
|
25
|
-
|
|
26
|
-
|
|
33
|
+
// ONE left-to-right scan, LAST occurrence wins whichever form it uses. Scanning the = form
|
|
34
|
+
// across all args before the space form made precedence depend on spelling, not position
|
|
35
|
+
// (`--port 9 --port=8` and `--port=8 --port 9` both answered 8). A present-but-EMPTY `=`
|
|
36
|
+
// value (`--config-dir=`) is an error, not a silent fallback - downstream it is
|
|
37
|
+
// indistinguishable from "not given", which for --config-dir meant config quietly written to
|
|
38
|
+
// the package root, the exact misconfiguration this parser exists to prevent.
|
|
39
|
+
const eq = name + '=';
|
|
40
|
+
let value = fallback;
|
|
41
|
+
for (let i = 0; i < args.length; i++) {
|
|
42
|
+
const a = args[i];
|
|
43
|
+
if (typeof a !== 'string') continue;
|
|
44
|
+
if (a.startsWith(eq)) {
|
|
45
|
+
const v = a.slice(eq.length);
|
|
46
|
+
if (v.length === 0) {
|
|
47
|
+
process.stderr.write(`toolfunnel: ${name}= requires a value (write ${name}=<value> or ${name} <value>)\n`);
|
|
48
|
+
process.exit(2);
|
|
49
|
+
}
|
|
50
|
+
value = v;
|
|
51
|
+
} else if (a === name && i + 1 < args.length) {
|
|
52
|
+
value = args[i + 1];
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return value;
|
|
27
56
|
}
|
|
28
57
|
|
|
29
58
|
// POSITIONAL arguments - the subcommand and its target - recognised regardless of where global
|
|
@@ -35,11 +64,18 @@ const VALUE_FLAGS = new Set(['--config-dir', '--port', '--host', '--as']);
|
|
|
35
64
|
const positionals = [];
|
|
36
65
|
for (let i = 0; i < args.length; i++) {
|
|
37
66
|
const a = args[i];
|
|
67
|
+
// `--flag value` consumes the next token; `--flag=value` is self-contained and is skipped by the
|
|
68
|
+
// generic leading-dash rule below, so it must NOT also eat the following token.
|
|
38
69
|
if (VALUE_FLAGS.has(a)) { i++; continue; }
|
|
39
70
|
if (typeof a === 'string' && a.length && a[0] === '-') continue;
|
|
40
71
|
positionals.push(a);
|
|
41
72
|
}
|
|
42
73
|
|
|
74
|
+
if (args.includes('--version') || args.includes('-v')) {
|
|
75
|
+
process.stdout.write(require('../package.json').version + '\n');
|
|
76
|
+
process.exit(0);
|
|
77
|
+
}
|
|
78
|
+
|
|
43
79
|
if (args.includes('--help') || args.includes('-h')) {
|
|
44
80
|
process.stdout.write(
|
|
45
81
|
[
|
|
@@ -58,6 +94,7 @@ if (args.includes('--help') || args.includes('-h')) {
|
|
|
58
94
|
' working with modern clients. `wrap --off` restores normal.',
|
|
59
95
|
' toolfunnel install-oauth Install the optional OAuth 2.1 dependency (jose)',
|
|
60
96
|
' toolfunnel --config-dir <dir> Use <dir> as the CONFIG HOME (see below)',
|
|
97
|
+
' toolfunnel --version Print the version',
|
|
61
98
|
' toolfunnel --help Show this help',
|
|
62
99
|
'',
|
|
63
100
|
'CONFIG HOME: the mutable config (tools/ mcp/ hooks/ auth/ logs/ toolfunnel.json) lives in',
|
|
@@ -78,6 +115,10 @@ if (args.includes('--help') || args.includes('-h')) {
|
|
|
78
115
|
// Resolve + seed the config home FIRST - before any src/ module is required - so every
|
|
79
116
|
// module-load-time anchor (register/expose/hooks paths, auth + log config, identity) reads the
|
|
80
117
|
// same resolved home. Precedence: --config-dir > TOOLFUNNEL_HOME env > the package root.
|
|
118
|
+
// Whether the user EXPLICITLY placed the home must be captured BEFORE init: initConfigHome
|
|
119
|
+
// exports the resolved home into process.env.TOOLFUNNEL_HOME for the child tools it spawns,
|
|
120
|
+
// so reading the env afterwards is always-true and the hint branch was dead (0.7.0).
|
|
121
|
+
const explicitHome = !!(flagValue('--config-dir', '') || process.env.TOOLFUNNEL_HOME);
|
|
81
122
|
const { initConfigHome } = require('../src/core/config-home');
|
|
82
123
|
const configHome = initConfigHome({ dir: flagValue('--config-dir', '') }).home;
|
|
83
124
|
|
|
@@ -85,7 +126,7 @@ const configHome = initConfigHome({ dir: flagValue('--config-dir', '') }).home;
|
|
|
85
126
|
// `git status` after running from a clone was the discovery mechanism. Defaulted homes get
|
|
86
127
|
// the relocation hint.
|
|
87
128
|
process.stderr.write('[toolfunnel] config home: ' + configHome +
|
|
88
|
-
(
|
|
129
|
+
(explicitHome
|
|
89
130
|
? '\n'
|
|
90
131
|
: ' (default: the package root - use --config-dir <dir> or TOOLFUNNEL_HOME to relocate)\n'));
|
|
91
132
|
|
|
@@ -157,12 +198,40 @@ if (positionals[0] === 'wrap') {
|
|
|
157
198
|
// up front - informed consent, not a silent default (the old behaviour was worse: the guard
|
|
158
199
|
// still applied, and `wrap` reported success while the gateway refused to connect).
|
|
159
200
|
{
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
201
|
+
// Enumerate EVERY class the wrap excuses, using the guard's own lists so the notice and the
|
|
202
|
+
// guard can never classify differently. Args-only was the defect: the archetypal wrap target
|
|
203
|
+
// (a filesystem server on a documents folder) HAS an outside arg so the notice fired there,
|
|
204
|
+
// while a clean-args upstream carrying NODE_OPTIONS=--require and an outside cwd wrapped in
|
|
205
|
+
// total silence - the one moment of informed consent, blind to the class that loads code.
|
|
206
|
+
const { looksLikePath, isInside, isDriveRelative, CODE_OPT_ENV, CODE_PATH_ENV } = require('../src/mcp/aggregator');
|
|
207
|
+
const findings = (Array.isArray(upstream.args) ? upstream.args : [])
|
|
208
|
+
.filter((a) => looksLikePath(a) && !isInside(home, a))
|
|
209
|
+
.map((a) => 'arg ' + a);
|
|
210
|
+
if (typeof upstream.cwd === 'string' && upstream.cwd.length > 0
|
|
211
|
+
&& (isDriveRelative(upstream.cwd) || !isInside(home, upstream.cwd))) {
|
|
212
|
+
findings.push('cwd ' + upstream.cwd + ' (bare args resolve against this)');
|
|
213
|
+
}
|
|
214
|
+
const envObj = upstream.env && typeof upstream.env === 'object' ? upstream.env : null;
|
|
215
|
+
if (envObj) {
|
|
216
|
+
for (const key in envObj) {
|
|
217
|
+
const upper = String(key).toUpperCase();
|
|
218
|
+
const isOpt = key === '__proto__' || CODE_OPT_ENV.includes(upper);
|
|
219
|
+
const isPathVar = CODE_PATH_ENV.includes(upper);
|
|
220
|
+
if (!isOpt && !isPathVar) continue;
|
|
221
|
+
let raw;
|
|
222
|
+
try { raw = String(envObj[key]); } catch (_e) { raw = '(unreadable)'; }
|
|
223
|
+
// An option-string var loads code whatever its value; a path-list var only when a piece
|
|
224
|
+
// of it points outside - the same rule the guard applies, so the two agree by construction.
|
|
225
|
+
if (isPathVar && raw !== '(unreadable)') {
|
|
226
|
+
const parts = (raw.match(/[A-Za-z]:[^;:]*|[^;:]+/g) || []).filter((t) => t.length > 0);
|
|
227
|
+
if (!parts.some((t) => isDriveRelative(t) || !isInside(home, t))) continue;
|
|
228
|
+
}
|
|
229
|
+
findings.push('env ' + key + '=' + raw + ' (loads code into the child)');
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
if (findings.length) {
|
|
233
|
+
process.stderr.write('[toolfunnel] ⚠ WRAP SECURITY NOTICE - upstream "' + target + '" reaches outside the gateway root:\n' +
|
|
234
|
+
findings.map((a) => ' ' + a).join('\n') + '\n' +
|
|
166
235
|
' While WRAPPED, the path-isolation guard is suspended for this upstream: ToolFunnel is a\n' +
|
|
167
236
|
' transparent wrapper here, and the wrapped server can reach whatever those paths reach.\n' +
|
|
168
237
|
' Every call still passes the PreToolUse gate. To restrict it AFTER wrapping: add a\n' +
|
|
@@ -181,26 +250,36 @@ if (positionals[0] === 'wrap') {
|
|
|
181
250
|
// (server/discover), and only on failure fall back to the legacy initialize - otherwise a
|
|
182
251
|
// dual-era upstream would be misreported as legacy just because the legacy handshake works.
|
|
183
252
|
// Separate child per stage: a badly behaved legacy server may react badly to an unknown
|
|
184
|
-
// method, and the fallback must not inherit a
|
|
253
|
+
// method, and the fallback must not inherit a soured child.
|
|
185
254
|
if (upstream.transport === 'stdio') {
|
|
186
|
-
const {
|
|
187
|
-
|
|
255
|
+
const { defaultClientFactory } = require('../src/mcp/aggregator');
|
|
256
|
+
// The probe SPAWNS this upstream, so it goes through the same factory - and therefore the
|
|
257
|
+
// same isolation guard - as every other spawn. Building McpClient directly here meant the
|
|
258
|
+
// one code path that runs an upstream's command before the operator has seen anything was
|
|
259
|
+
// also the one path with no guard on args/cwd/env, while the docs claimed otherwise.
|
|
260
|
+
// allowOutsidePaths is TRUE because this IS the wrap path: outside references are the
|
|
261
|
+
// operator's explicit declaration, so the guard warns instead of refusing (same rule the
|
|
262
|
+
// running gateway applies to the wrapped upstream).
|
|
263
|
+
const probeUpstream = Object.assign({}, upstream, {
|
|
188
264
|
id: target,
|
|
189
|
-
command: upstream.command,
|
|
190
|
-
args: Array.isArray(upstream.args) ? upstream.args : [],
|
|
191
|
-
env: upstream.env || {},
|
|
192
265
|
// Default the probe's cwd to the CONFIG HOME, same as the aggregator's spawn: relative
|
|
193
266
|
// arg paths are guarded against the home, so they must spawn against it too.
|
|
194
267
|
cwd: upstream.cwd || home,
|
|
195
268
|
// Honour a configured slow-boot window; the probe default stays tight.
|
|
196
269
|
requestTimeoutMs: Number.isFinite(upstream.requestTimeoutMs) && upstream.requestTimeoutMs > 0
|
|
197
270
|
? upstream.requestTimeoutMs : 6000,
|
|
198
|
-
|
|
271
|
+
// The probe decides the era ITSELF (server/discover, then a legacy fallback on a fresh
|
|
272
|
+
// child). Neutralise the stored era policy so a pinned upstream still gets both stages -
|
|
273
|
+
// otherwise the factory would force one era and the probe would report its own input.
|
|
274
|
+
legacyPin: false,
|
|
275
|
+
modernOnly: false,
|
|
276
|
+
});
|
|
277
|
+
const newProbeClient = () => defaultClientFactory(probeUpstream, home, () => {}, true, undefined);
|
|
199
278
|
|
|
200
279
|
let reported = false;
|
|
201
280
|
// Stage 1: modern probe (server/discover).
|
|
202
281
|
try {
|
|
203
|
-
const probe =
|
|
282
|
+
const probe = newProbeClient();
|
|
204
283
|
try {
|
|
205
284
|
const disc = await probe.probeDiscover();
|
|
206
285
|
const versions = disc && Array.isArray(disc.supportedVersions) ? disc.supportedVersions : [];
|
|
@@ -226,7 +305,7 @@ if (positionals[0] === 'wrap') {
|
|
|
226
305
|
// Stage 2: legacy fallback (initialize) - the era every current MCP speaks.
|
|
227
306
|
if (!reported) {
|
|
228
307
|
try {
|
|
229
|
-
const legacy =
|
|
308
|
+
const legacy = newProbeClient();
|
|
230
309
|
try {
|
|
231
310
|
const init = await legacy.connect();
|
|
232
311
|
const v = init && typeof init.protocolVersion === 'string' ? init.protocolVersion : 'unknown';
|
package/docs/KNOWN_BUGS.md
CHANGED
|
@@ -1,99 +1,80 @@
|
|
|
1
|
-
# Known
|
|
1
|
+
# Known bugs
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
The **Open** list is what remains open as of 0.7.0. **Fixed in 0.7.0** marks the items from
|
|
4
|
+
0.6.0's file that this release closed - kept and marked rather than silently deleted, so a
|
|
5
|
+
0.6.0 reader can see their fate. The full change list is in the
|
|
6
|
+
[release notes](https://github.com/Rendeverance/toolfunnel/releases).
|
|
5
7
|
|
|
6
|
-
##
|
|
8
|
+
## Open
|
|
7
9
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
10
|
+
- **OPEN - Modern-upstream subscriptions are not received.** The client does not yet open a
|
|
11
|
+
`subscriptions/listen` stream to modern upstreams, so their change-notifications never arrive;
|
|
12
|
+
the listen ack honestly refuses `resourceSubscriptions` when no legacy subscribe-capable
|
|
13
|
+
upstream is in scope.
|
|
14
|
+
- **OPEN - The elicitation bridge does not cover legacy clients.** A wrapped upstream's mid-call
|
|
15
|
+
`elicitation/create` bridges to MODERN clients as MRTR (`input_required` + retry), form and url
|
|
16
|
+
mode, gated on the caller's declared capability (0.7.0). A LEGACY client gets an automatic
|
|
17
|
+
decline instead - relaying backwards requests to legacy clients is unbuilt - and
|
|
18
|
+
`sampling/createMessage` / `roots/list` from upstreams are answered `-32601`. An elicitation can
|
|
19
|
+
only be bound to a call when exactly ONE wrapped call is in flight for that upstream (always
|
|
20
|
+
true on stdio); ambiguous concurrent HTTP calls decline rather than guess.
|
|
21
|
+
- **OPEN (deferred) - HTTP cancels are dropped rather than translated.** The sessionless modern
|
|
22
|
+
era gives a POST no connection identity, so a client `notifications/cancelled` arriving over
|
|
23
|
+
HTTP is dropped rather than risk cancelling another client's call. Fixing this needs design
|
|
24
|
+
thought - identifying the caller without reintroducing sessions - so it is deferred rather than
|
|
25
|
+
patched. stdio (one client per pipe) translates and forwards cancels for both forwarded methods
|
|
26
|
+
and wrapped tool calls; the remaining best-effort windows there are sub-millisecond (a cancel
|
|
27
|
+
racing the instant a forward is issued) plus the PreToolUse gate-evaluation phase of a tool
|
|
28
|
+
call (registered at upstream-issue time, post-gate).
|
|
16
29
|
|
|
17
|
-
|
|
18
|
-
- `server/discover`, request-header validation, and the `subscriptions/listen` stream.
|
|
19
|
-
- A **dual-era client**: ToolFunnel speaks modern to modern upstream servers (stdio).
|
|
20
|
-
- An **opt-in, per-upstream legacy-protocol shim** (`legacyPin`) - off by default, names the exact
|
|
21
|
-
version it drops to, warns at startup and on every call, and is enforced (skips the era probe).
|
|
22
|
-
- **Passthrough wrap** (`toolfunnel wrap <upstreamId>`): one command turns the gateway into a
|
|
23
|
-
transparent, both-era wrapper for one upstream MCP - verbatim identity, tool definitions,
|
|
24
|
-
results, errors, notifications, and per-URI resource subscriptions (forwarded, and replayed
|
|
25
|
-
across silent upstream reconnects), with every call still passing the PreToolUse gate.
|
|
26
|
-
- **Elicitation bridging**: a wrapped legacy server's mid-call questions are translated into the
|
|
27
|
-
modern `input_required` + retry pattern and back - multi-round, single-use tokens, TTL'd -
|
|
28
|
-
tested end-to-end against real published elicitation servers.
|
|
29
|
-
- **Cancel translation** (stdio): a client's cancel reaches the upstream in the upstream's own
|
|
30
|
-
request-id space, for forwarded methods and wrapped tool calls alike.
|
|
31
|
-
- **Identity settings + wrap mirroring**: `toolfunnel.json` gains `clientName`/`clientVersion`
|
|
32
|
-
(the identity upstreams see); under a wrap on stdio the real downstream client's identity is
|
|
33
|
-
mirrored upstream automatically.
|
|
34
|
-
- **UI**: a Settings tab (identity + ports), a per-upstream legacy-pin toggle, and the wrap
|
|
35
|
-
security notice surfaced on the UI wrap path (parity with the CLI).
|
|
36
|
-
- **Agent-facing docs**: `toolfunnel_howto` gains `wrap` and `configure` topics, so a plain agent
|
|
37
|
-
can learn the wrap and the whole no-code config map from inside the protocol.
|
|
38
|
-
- **Method-class timeouts**: tool calls (`tools/call`, `prompts/get`, `resources/read`) wait
|
|
39
|
-
120 s by default - configurable per upstream via `"timeoutMs"` - and a progress report from
|
|
40
|
-
the tool re-arms the window, so a slow-but-alive tool never dies to the clock. The 10 s
|
|
41
|
-
handshake/list window stays fixed as the dead-upstream detector.
|
|
42
|
-
- **Async shell execution**: `shell`-invoke register tools no longer block the event loop
|
|
43
|
-
(was `spawnSync` since 0.5.0) - concurrent HTTP clients keep being served during a long
|
|
44
|
-
shell tool.
|
|
45
|
-
- **Config-home visibility**: every start prints the resolved config home to stderr, with a
|
|
46
|
-
relocation hint when it defaulted to the package root - running from a git clone no longer
|
|
47
|
-
writes config into the repo silently.
|
|
48
|
-
- **Bounded listen streams**: the HTTP transport caps concurrent `subscriptions/listen`
|
|
49
|
-
streams (64) and refuses new ones past the cap with a clear error; existing streams are
|
|
50
|
-
never evicted.
|
|
30
|
+
## Fixed in 0.7.0
|
|
51
31
|
|
|
52
|
-
|
|
32
|
+
- ***FIXED*** - **Legacy version negotiation.** 0.6.0 answered every `initialize` with
|
|
33
|
+
2024-11-05 whatever the client asked for, and supported exactly one legacy revision. The
|
|
34
|
+
gateway now echoes the requested version when it supports it (else answers its latest legacy),
|
|
35
|
+
and the legacy set spans 2024-11-05 / 2025-03-26 / 2025-06-18 / 2025-11-25. Pinned by
|
|
36
|
+
`test/negotiation.test.js`. One direction remains conservative: the client still offers
|
|
37
|
+
2024-11-05 to legacy upstreams (field fidelity is unaffected; only the advertised version
|
|
38
|
+
string is older than either end requires).
|
|
39
|
+
- ***FIXED*** - **Header validation used a generic error code for an unsupported version.** An
|
|
40
|
+
unsupported `MCP-Protocol-Version` header answered the generic `-32600`; it now answers
|
|
41
|
+
`-32022` with `data.supported` and `data.requested` - the same code and shape the `_meta`
|
|
42
|
+
path already used for the same condition, so a client can renegotiate without parsing prose.
|
|
43
|
+
Pinned by `test/header-enforcement.test.js`.
|
|
44
|
+
- ***FIXED*** - **The wrap's identity-mirror reconnect re-ran the full era negotiation** (worst
|
|
45
|
+
case roughly 3 s per respawn against an upstream that ignores `server/discover`). The
|
|
46
|
+
negotiated era is now remembered per upstream, and a death-driven reconnect goes straight to
|
|
47
|
+
the era it knows. A failed hinted connect or an explicit reconnect re-negotiates in full, so
|
|
48
|
+
an upstream upgraded across a restart is never pinned down an era. Pinned by
|
|
49
|
+
`test/reconnect-era-memo.test.js`.
|
|
50
|
+
- ***FIXED*** - **`io.modelcontextprotocol/logLevel` was stripped on wrapped forwards.** Now
|
|
51
|
+
era-keyed at the client boundary: a modern upstream gets the caller's level verbatim in
|
|
52
|
+
`_meta`; a legacy upstream that declared the `logging` capability gets `logging/setLevel`
|
|
53
|
+
issued before the call (and the key never leaks into legacy `_meta`). Pinned by
|
|
54
|
+
`test/loglevel.test.js`.
|
|
53
55
|
|
|
54
|
-
|
|
55
|
-
to legacy upstreams and clamps a wrapped handshake to what the upstream negotiated. A client and
|
|
56
|
-
upstream that both speak a newer legacy revision (e.g. `2025-06-18`) are negotiated down.
|
|
57
|
-
Fidelity of the *fields* is unaffected (verified against real servers - nothing version-gated is
|
|
58
|
-
dropped), but the advertised version string is older than either end requires. DECIDED
|
|
59
|
-
(2026-07-17): best-version negotiation in BOTH directions - newest each peer speaks, modern
|
|
60
|
-
first, legacy fallback. Lands with the reconciliation pass, sequenced after the elicitation
|
|
61
|
-
bridge (a newer legacy offer invites server-initiated requests the client must handle first).
|
|
62
|
-
- **Modern-upstream subscriptions**: the client does not yet open a `subscriptions/listen` stream
|
|
63
|
-
to modern upstreams, so their change-notifications are not received; the listen ack honestly
|
|
64
|
-
refuses `resourceSubscriptions` when no legacy subscribe-capable upstream is in scope.
|
|
65
|
-
- **Elicitation bridge scope**: a wrapped upstream's mid-call `elicitation/create` bridges to
|
|
66
|
-
modern clients as MRTR (`input_required` + retry - built and wire-tested). A LEGACY client
|
|
67
|
-
gets an automatic decline instead (relaying backwards requests to legacy clients is future
|
|
68
|
-
work), and `sampling/createMessage` / `roots/list` from upstreams are answered -32601 for now.
|
|
69
|
-
An elicitation can only be bound to a call when exactly ONE wrapped call is in flight for that
|
|
70
|
-
upstream (always true on stdio); ambiguous concurrent HTTP calls decline rather than guess.
|
|
71
|
-
- Header validation reports `-32020` where `-32022` would be more specific in one path.
|
|
72
|
-
- Two-store wrap state read (expose.json + tools.state.json) has a benign TOCTOU window.
|
|
73
|
-
- A meta-less `server/discover` is answered rather than refused (permissive-by-design).
|
|
74
|
-
- The wrap's identity-mirror reconnect re-runs the full era negotiation - worst case ~3 s extra
|
|
75
|
-
on the first handshake against an upstream that ignores `server/discover`. Reusing the
|
|
76
|
-
already-known era on reconnect is a deferred optimisation.
|
|
77
|
-
- HTTP cancel translation: the sessionless modern era gives a POST no connection identity, so a
|
|
78
|
-
client `notifications/cancelled` over HTTP is dropped rather than risk cancelling another
|
|
79
|
-
client's call. stdio (one client per pipe) translates and forwards cancels for both forwarded
|
|
80
|
-
methods and wrapped tools/call; the remaining best-effort windows are sub-millisecond (a cancel
|
|
81
|
-
racing the instant a forward is issued) plus the PreToolUse gate-evaluation phase of a tool
|
|
82
|
-
call (registered at upstream-issue time, post-gate).
|
|
83
|
-
- `io.modelcontextprotocol/logLevel` (the modern per-request log level) is stripped with the rest
|
|
84
|
-
of the protocol `_meta` keys on wrapped forwards and not re-injected for modern upstreams -
|
|
85
|
-
part of the modern-upstream work above.
|
|
86
|
-
- Discover on a DISABLED upstream works (inspect its tools before enabling - an explicit
|
|
87
|
-
allowance on the discover path only; the disabled upstream never reaches the tool surface).
|
|
56
|
+
## By design (not bugs)
|
|
88
57
|
|
|
89
|
-
|
|
58
|
+
These answer "why doesn't it do X". They are considered decisions, not defects.
|
|
90
59
|
|
|
91
|
-
- The
|
|
92
|
-
|
|
93
|
-
-
|
|
94
|
-
|
|
95
|
-
-
|
|
96
|
-
|
|
97
|
-
|
|
60
|
+
- **The config home defaults to the package root.** A git clone keeps working unchanged, and
|
|
61
|
+
seeding protects it from `npm update`, with an every-start relocation hint. Defaulting it to a
|
|
62
|
+
per-user directory instead needs a migration story for existing installs, so it ships as its own
|
|
63
|
+
release rather than as a rider. (`preuninstall` as a rescue mechanism is tested DEAD on npm
|
|
64
|
+
11.9.0 - it will not be built.)
|
|
65
|
+
- **Funnel-mode curated descriptions carry an `[upstream]` title prefix** that lean passthrough
|
|
66
|
+
does not. A toggle to reconcile the two is cosmetic.
|
|
67
|
+
- **The `command` slot is not path-guarded, and `PATH` is deliberately permitted.** The command
|
|
68
|
+
names the interpreter (`node`, a system binary), which by definition lives outside the config
|
|
69
|
+
home, and guarding `PATH` would break how commands resolve at all - so an upstream entry
|
|
70
|
+
combining `command: "node"` with a caller-supplied `env.PATH` runs whatever that PATH resolves.
|
|
71
|
+
This is a documented boundary, not an oversight: an upstream entry already specifies an
|
|
72
|
+
arbitrary command to execute, so the isolation guard is a **tripwire** for configs that quietly
|
|
73
|
+
reach outside an auditable config home - not a sandbox against an untrusted pack author.
|
|
74
|
+
Installing a pack is installing software (see [SECURITY.md](../SECURITY.md)).
|
|
75
|
+
- **A meta-less `server/discover` is answered rather than refused** - permissive by design.
|
|
76
|
+
- **Discover works on a DISABLED upstream**, so its tools can be inspected before enabling. An
|
|
77
|
+
explicit allowance on the discover path only; the disabled upstream never reaches the tool
|
|
78
|
+
surface.
|
|
98
79
|
|
|
99
|
-
Found a bug, or want to help with the
|
|
80
|
+
Found a bug, or want to help with the open work? Issues and PRs are welcome.
|
package/docs/MANUAL.pdf
CHANGED
|
Binary file
|
package/docs/design.md
CHANGED
|
@@ -20,21 +20,35 @@ SDK. Two reasons. First, the zero-dependency stance above. Second, ToolFunnel ha
|
|
|
20
20
|
protocol eras at once, on both sides, which the SDK does not do; owning the wire layer is what
|
|
21
21
|
makes the dual-era serving and the wrap possible at all.
|
|
22
22
|
|
|
23
|
-
## 3. The gate
|
|
23
|
+
## 3. The gate: a deny is absolute, an unreadable decision is a deny
|
|
24
24
|
|
|
25
25
|
Every server-side execution path goes through one function, gatedRun, and that function treats
|
|
26
|
-
every failure as a deny: a hook that blocks, a hook engine that crashes, a malformed
|
|
27
|
-
result, all of them mean the tool does not
|
|
28
|
-
|
|
29
|
-
|
|
26
|
+
every failure it can *see* as a deny: a hook that blocks, a hook engine that crashes, a malformed
|
|
27
|
+
engine result, a decision too large or too mangled to parse - all of them mean the tool does not
|
|
28
|
+
run. An answer we cannot read is never treated as permission. Denies carry a flag distinguishing
|
|
29
|
+
operator policy ("your hook said no") from wiring failure ("the gate itself broke"), so a broken
|
|
30
|
+
gate can never be mistaken for an approving one.
|
|
31
|
+
|
|
32
|
+
The line is drawn at a hook that never answered at all. A hook *script* that crashes, is missing,
|
|
33
|
+
or hangs past its timeout is a non-blocking error and the call proceeds. That is Claude Code's
|
|
34
|
+
hook protocol and it is a deliberate trade: denying there would mean a single broken hook file -
|
|
35
|
+
a typo, a deleted script, a slow disk - silently taking every tool in the gateway offline, and an
|
|
36
|
+
operator debugging that has no signal to follow. So the runtime failure is reported and the call
|
|
37
|
+
continues, while the *decision* path stays strict. The consequence for anyone writing a gate is
|
|
38
|
+
concrete: deny with `exit 2`, never with a bare non-zero, and if your policy cannot decide, exit 2
|
|
39
|
+
deliberately. Both halves of this contract are pinned by test/gate-semantics.test.js.
|
|
30
40
|
|
|
31
41
|
## 4. The isolation boundary
|
|
32
42
|
|
|
33
|
-
Upstream server definitions can name any executable but
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
43
|
+
Upstream server definitions can name any executable, but every field that decides what code the
|
|
44
|
+
spawned process runs must stay inside the config home: path-shaped arguments (including the
|
|
45
|
+
value after `=` in a `--flag=value` form), the working directory (`cwd`) bare arguments resolve
|
|
46
|
+
against, and the small set of environment variables that load code on their own (`NODE_OPTIONS`,
|
|
47
|
+
`NODE_PATH`, and their equivalents for other runtimes). The command is not guarded - it is the
|
|
48
|
+
interpreter you chose, not the code it runs. The guard relaxes only by explicit opt-out, each
|
|
49
|
+
per upstream and each with a warning naming itself: wrapping (a wrap is an explicit statement
|
|
50
|
+
that this one server is your entire surface), `allowOutsidePaths: true` on the upstream's own
|
|
51
|
+
entry (its paths and path-list env), and `allowCodeLoadingEnv: true` (the env class on its own).
|
|
38
52
|
|
|
39
53
|
## 5. Two eras, served per request
|
|
40
54
|
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"script": "scripts/example-deny-dangerous.js",
|
|
11
11
|
"timeout": 10,
|
|
12
12
|
"enabled": false,
|
|
13
|
-
"description": "EXAMPLE (disabled by default). A PreToolUse policy gate: denies any tool call whose arguments contain an obviously-destructive shell pattern (rm -rf, mkfs, drive format, fork bomb).
|
|
13
|
+
"description": "EXAMPLE (disabled by default). A PreToolUse policy gate: denies any tool call whose arguments contain an obviously-destructive shell pattern (rm -rf, mkfs, drive format, fork bomb). Exit 2 denies and blocks the call before it runs; exit 0 allows. Any OTHER exit (a crash, a missing script, a timeout) is a non-blocking error and the call PROCEEDS - write denies as exit 2, never as a bare non-zero. Enable it in the UI Hooks tab (or set enabled:true here) to see the gate bite; edit scripts/example-deny-dangerous.js to encode your own policy."
|
|
14
14
|
},
|
|
15
15
|
{
|
|
16
16
|
"id": "post-tool-use/example-audit",
|
|
@@ -5,12 +5,20 @@
|
|
|
5
5
|
* EXAMPLE PreToolUse gate - DISABLED by default (enable it in hooks/hooks.manifest.json, or via the
|
|
6
6
|
* UI Hooks tab / the per-tool "Pre" toggle on the Tools tab).
|
|
7
7
|
*
|
|
8
|
-
* This is the shape of a real policy gate: it runs INSIDE the gateway, BEFORE a tool executes, and
|
|
9
|
-
*
|
|
10
|
-
*
|
|
8
|
+
* This is the shape of a real policy gate: it runs INSIDE the gateway, BEFORE a tool executes, and
|
|
9
|
+
* `process.exit(2)` DENIES the call. The lifecycle event arrives as JSON on stdin; here we block any
|
|
10
|
+
* call whose arguments contain an obviously-destructive shell pattern.
|
|
11
11
|
*
|
|
12
12
|
* Block protocol (the simple one): write the reason to stderr and `process.exit(2)`. (A richer JSON
|
|
13
|
-
* protocol is also supported - see docs/
|
|
13
|
+
* protocol is also supported - see docs/MANUAL.pdf section 10.) Exit 0 = allow.
|
|
14
|
+
*
|
|
15
|
+
* EXIT 2, NOT "non-zero" - the difference matters when you write your own. Every OTHER exit (a
|
|
16
|
+
* crash, a syntax error, a missing file, a timeout) is a NON-BLOCKING ERROR and the call proceeds,
|
|
17
|
+
* so `exit(1)` on an error path allows the very call you meant to stop. If your policy cannot
|
|
18
|
+
* reach a decision, exit 2 deliberately. (The gateway does refuse a decision it cannot READ -
|
|
19
|
+
* output past the cap, unparseable JSON - because an unreadable answer is not permission. It
|
|
20
|
+
* cannot do the same for a script that never answered: one broken hook file would take every tool
|
|
21
|
+
* offline.) test/gate-semantics.test.js pins all of this.
|
|
14
22
|
*
|
|
15
23
|
* Zero dependencies - Node built-ins only.
|
|
16
24
|
*/
|