toolfunnel 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/README.md +238 -235
  2. package/SECURITY.md +84 -84
  3. package/bin/toolfunnel.js +530 -451
  4. package/docs/KNOWN_BUGS.md +100 -80
  5. package/docs/MANUAL.pdf +0 -0
  6. package/docs/design.md +84 -84
  7. package/docs/packaging.md +116 -115
  8. package/hooks/hooks.manifest.json +27 -27
  9. package/hooks/scripts/example-audit.js +21 -21
  10. package/hooks/scripts/example-deny-dangerous.js +39 -39
  11. package/mcp/expose.example.json +30 -30
  12. package/mcp/servers/mock-modern/server.js +147 -147
  13. package/mcp/servers/mock-upstream/server.js +379 -380
  14. package/package.json +67 -67
  15. package/src/auth/config.js +172 -174
  16. package/src/auth/install.js +181 -181
  17. package/src/auth/resource-server.js +358 -353
  18. package/src/core/atomic-write.js +97 -0
  19. package/src/core/child-reaper.js +61 -61
  20. package/src/core/config-home.js +226 -134
  21. package/src/core/events.js +105 -105
  22. package/src/core/hook-engine.js +304 -329
  23. package/src/core/hook-loader.js +766 -824
  24. package/src/core/hook-runner.js +615 -566
  25. package/src/core/logger.js +295 -297
  26. package/src/core/matcher.js +100 -100
  27. package/src/core/metrics.js +90 -75
  28. package/src/core/output-cap.js +31 -0
  29. package/src/core/rename-migration.js +178 -163
  30. package/src/core/requires.js +138 -138
  31. package/src/core/server-config.js +98 -98
  32. package/src/extend/add-hook.md +155 -152
  33. package/src/extend/add-mcp.md +119 -119
  34. package/src/extend/configure.md +88 -88
  35. package/src/extend/create-tool.md +168 -168
  36. package/src/extend/howto.js +102 -102
  37. package/src/extend/package.md +190 -190
  38. package/src/extend/wrap.md +99 -99
  39. package/src/mcp/aggregator.js +2248 -1674
  40. package/src/mcp/expose-store.js +755 -577
  41. package/src/mcp/gated-run.js +210 -210
  42. package/src/mcp/http-transport.js +1657 -1563
  43. package/src/mcp/mcp-client.js +1174 -1070
  44. package/src/mcp/modern.js +409 -406
  45. package/src/mcp/protocol.js +473 -468
  46. package/src/mcp/server.js +3317 -3112
  47. package/src/mcp/tool-approvals.js +396 -0
  48. package/src/tools/registry.js +607 -661
  49. package/src/tools/tool-state.js +170 -170
  50. package/src/ui/public/app.js +1246 -1187
  51. package/src/ui/public/index.html +286 -286
  52. package/src/ui/public/styles.css +535 -535
  53. package/src/ui/server.js +1742 -1616
  54. package/tools/scripts/base64.js +139 -139
  55. package/tools/scripts/danger.js +61 -61
  56. package/tools/scripts/echo.js +39 -39
  57. package/tools/scripts/hash.js +101 -101
  58. package/tools/scripts/json.js +109 -109
  59. package/tools/scripts/text-stats.js +131 -131
  60. package/tools/scripts/tf-accept-change.js +67 -0
  61. package/tools/scripts/tf-env.js +43 -43
  62. package/tools/scripts/tf-hook-add.js +151 -151
  63. package/tools/scripts/tf-hook-set.js +120 -120
  64. package/tools/scripts/tf-list.js +192 -192
  65. package/tools/scripts/tf-log.js +86 -86
  66. package/tools/scripts/tf-mcp-add.js +130 -117
  67. package/tools/scripts/tf-mcp-set.js +121 -121
  68. package/tools/scripts/tf-pack.js +237 -237
  69. package/tools/scripts/tf-tool-add.js +134 -134
  70. package/tools/scripts/tf-tool-set.js +142 -142
  71. package/tools/scripts/tf-wrap.js +103 -103
  72. package/tools/scripts/uuid.js +102 -102
  73. package/tools/tools.register.json +16 -5
@@ -1,80 +1,100 @@
1
- # Known bugs
2
-
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).
7
-
8
- ## Open
9
-
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).
29
-
30
- ## Fixed in 0.7.0
31
-
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`.
55
-
56
- ## By design (not bugs)
57
-
58
- These answer "why doesn't it do X". They are considered decisions, not defects.
59
-
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.
79
-
80
- Found a bug, or want to help with the open work? Issues and PRs are welcome.
1
+ # Known bugs
2
+
3
+ The **Open** list is what remains open as of 0.8.0. **Fixed in 0.8.0** marks the defects a
4
+ 0.7.x user could meet that this release closed. The full change list is in the
5
+ [release notes](https://github.com/Rendeverance/toolfunnel/releases).
6
+
7
+ ## Open
8
+
9
+ - **OPEN - Modern-upstream subscriptions are not received.** The client does not yet open a
10
+ `subscriptions/listen` stream to modern upstreams, so their change-notifications never arrive;
11
+ the listen ack honestly refuses `resourceSubscriptions` when no legacy subscribe-capable
12
+ upstream is in scope.
13
+ - **OPEN - The elicitation bridge does not cover legacy clients.** A wrapped upstream's mid-call
14
+ `elicitation/create` bridges to MODERN clients as MRTR (`input_required` + retry), form and url
15
+ mode, gated on the caller's declared capability. A LEGACY client gets an automatic
16
+ decline instead - relaying backwards requests to legacy clients is unbuilt - and
17
+ `sampling/createMessage` / `roots/list` from upstreams are answered `-32601`. An elicitation can
18
+ only be bound to a call when exactly ONE wrapped call is in flight for that upstream (always
19
+ true on stdio); ambiguous concurrent HTTP calls decline rather than guess.
20
+ - **OPEN (deferred) - HTTP cancels are dropped rather than translated.** The sessionless modern
21
+ era gives a POST no connection identity, so a client `notifications/cancelled` arriving over
22
+ HTTP is dropped rather than risk cancelling another client's call. Fixing this needs design
23
+ thought - identifying the caller without reintroducing sessions - so it is deferred rather than
24
+ patched. stdio (one client per pipe) translates and forwards cancels for both forwarded methods
25
+ and wrapped tool calls; the remaining best-effort windows there are sub-millisecond (a cancel
26
+ racing the instant a forward is issued) plus the PreToolUse gate-evaluation phase of a tool
27
+ call (registered at upstream-issue time, post-gate).
28
+ - **OPEN - A wrapped server's `instructions` are not covered by tool approval.** Tool descriptor
29
+ approval pins each tool's model-facing fields (`name`, `title`, `description`, `inputSchema`,
30
+ `outputSchema`, `annotations`). In wrap mode the wrapped server's `instructions` also reach the
31
+ model, unpinned, and a tool's `_meta`, `execution` and `icons` are forwarded without being
32
+ hashed. Pinning `instructions` is planned.
33
+ - **OPEN - A command on a drive mounted after startup stops being retried.** A missing command is a
34
+ permanent failure: about five minutes of retries, then the upstream stops (named on stderr and
35
+ in `/health.stopped`) until `expose.json` is saved, the gateway restarts, or the upstream is
36
+ reconnected. A drive or share that appears later than that is not noticed on its own.
37
+ - **OPEN (minor) - A very large approvals file slows refused calls.** Each refused call re-reads
38
+ `mcp/tool-approvals.json`; at 22 MB that costs about half a second per refused call. A home
39
+ with two real upstreams holds about 250 KB.
40
+
41
+ ## Fixed in 0.8.0
42
+
43
+ - ***FIXED*** - **A hook's decision was ignored when the hook printed anything before its JSON.**
44
+ The exit code decided instead, so a gate that logged a line and then denied, exiting 0, let the
45
+ call through. The decision is now read wherever it sits, and two different decisions (a hook
46
+ that echoes model-supplied text after its own answer) fail closed. Pinned by
47
+ `test/gate-semantics.test.js`.
48
+ - ***FIXED*** - **The hook engine substituted stand-ins when one of its parts failed to load.** It
49
+ now refuses to load and names the missing part. Pinned by `test/hook-engine-requires.test.js`.
50
+ - ***FIXED*** - **Child hooks saw the wrong `HOOKS_DIR` and `CLAUDE_PROJECT_DIR`.** Both now point
51
+ at the config home. Pinned by `test/hook-env.test.js`.
52
+ - ***FIXED*** - **A relative upstream `cwd` was checked against one directory and spawned in
53
+ another.** The isolation guard and the spawn now both resolve it against the config home.
54
+ Pinned by `test/upstream-cwd.test.js`.
55
+ - ***FIXED*** - **Stopping a stdio gateway could leave its upstreams running (POSIX).** SIGINT or
56
+ SIGTERM now closes every upstream, then exits 130 or 143. Pinned by
57
+ `test/stdio-shutdown.test.js`.
58
+ - ***FIXED*** - **A permanent startup failure retried forever, and on Windows a missing command
59
+ looked like a crashing server.** A missing command, a missing working directory or an isolation
60
+ refusal now stops after about five minutes with one line naming the cause, and shows in
61
+ `/health.stopped`. Pinned by `test/permanent-failure-ceiling.test.js`.
62
+ - ***FIXED*** - **A wrong-typed config field was coerced or dropped silently.** The config now
63
+ refuses to load, naming the file, the entry and the field. Pinned by
64
+ `test/fail-loud-config.test.js`.
65
+ - ***FIXED*** - **The legacy dispatcher answered notifications.** A notification now never gets a
66
+ reply. Pinned by `test/legacy-notifications.test.js`.
67
+
68
+ ## By design (not bugs)
69
+
70
+ These answer "why doesn't it do X". They are considered decisions, not defects.
71
+
72
+ - **The config home defaults to the package root.** A git clone keeps working unchanged, and
73
+ seeding protects it from `npm update`, with an every-start relocation hint. Defaulting it to a
74
+ per-user directory instead needs a migration story for existing installs, so it ships as its own
75
+ release rather than as a rider. (`preuninstall` as a rescue mechanism is tested DEAD on npm
76
+ 11.9.0 - it will not be built.)
77
+ - **Funnel-mode curated descriptions carry an `[upstream]` title prefix** that lean passthrough
78
+ does not. A toggle to reconcile the two is cosmetic.
79
+ - **The `command` slot is not path-guarded, and `PATH` is deliberately permitted.** The command
80
+ names the interpreter (`node`, a system binary), which by definition lives outside the config
81
+ home, and guarding `PATH` would break how commands resolve at all - so an upstream entry
82
+ combining `command: "node"` with a caller-supplied `env.PATH` runs whatever that PATH resolves.
83
+ This is a documented boundary, not an oversight: an upstream entry already specifies an
84
+ arbitrary command to execute, so the isolation guard is a **tripwire** for configs that quietly
85
+ reach outside an auditable config home - not a sandbox against an untrusted pack author.
86
+ Installing a pack is installing software (see [SECURITY.md](../SECURITY.md)).
87
+ - **A meta-less `server/discover` is answered rather than refused** - permissive by design.
88
+ - **Discover works on a DISABLED upstream**, so its tools can be inspected before enabling. An
89
+ explicit allowance on the discover path only; the disabled upstream never reaches the tool
90
+ surface.
91
+ - **Upstreams connect all at once, with no concurrency cap.** Right for the handful to tens of
92
+ upstreams a gateway holds.
93
+ - **The legacy SSE transport's dead-client prune branch never runs.** The stream's close and error
94
+ handlers already clean up; the only effect is a counter that reads high. Cosmetic.
95
+ - **Tool approval trusts a version bump.** A change at the same server version waits for
96
+ acceptance; a changed version is approved and reported, because that is what an update looks
97
+ like. A maintainer can bump the version too: `strictApproval: true` on an upstream holds every
98
+ change for acceptance.
99
+
100
+ 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
@@ -1,84 +1,84 @@
1
- # Design decisions
2
-
3
- Short notes on the decisions that shape ToolFunnel, so you can judge the reasoning without
4
- reading the source. Each one is also documented at the place in the code where it bites.
5
-
6
- ## 1. Zero runtime dependencies
7
-
8
- ToolFunnel sits in a privileged position: it executes tools and enforces policy. Every
9
- transitive dependency in that position is something you have to trust or audit. So the gateway
10
- uses Node built-ins only; "dependencies" in package.json is empty and stays empty. The one
11
- exception is deliberate and opt-in: OAuth token validation uses jose, installed only when you
12
- run `toolfunnel install-oauth`, version-pinned, because hand-rolled JWT crypto is how security
13
- holes are born. Dev tooling for the test suite (jose, the MCP SDK for interop tests) never
14
- ships.
15
-
16
- ## 2. No SDK
17
-
18
- The gateway hand-rolls JSON-RPC over stdio and HTTP rather than building on the official MCP
19
- SDK. Two reasons. First, the zero-dependency stance above. Second, ToolFunnel has to speak two
20
- protocol eras at once, on both sides, which the SDK does not do; owning the wire layer is what
21
- makes the dual-era serving and the wrap possible at all.
22
-
23
- ## 3. The gate: a deny is absolute, an unreadable decision is a deny
24
-
25
- Every server-side execution path goes through one function, gatedRun, and that function treats
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.
40
-
41
- ## 4. The isolation boundary
42
-
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).
52
-
53
- ## 5. Two eras, served per request
54
-
55
- The 2026-07-28 revision removes the handshake that defines the older protocol, so "which era"
56
- is a property of each request, and that is how the gateway treats it: era detection per
57
- request, no mode switch, no configuration. Legacy answers stay byte-identical to what a
58
- pre-0.6.0 client saw; the gateway's own modern decoration only ever appears on modern
59
- requests. (A wrapped modern upstream's results are relayed verbatim, whatever era the
60
- caller speaks - stripping a server's own fields would be data loss and a wrapper tell.)
61
-
62
- ## 6. Config lives in one folder
63
-
64
- Everything mutable (tools, upstreams, hooks, identity, logs) lives in the config home,
65
- relocatable with `--config-dir` or `TOOLFUNNEL_HOME`, seeded once, never overwritten by
66
- updates. One folder to back up, one folder to package, one folder to hand a colleague. The
67
- gateway prints the resolved home at every start so there is never any doubt about where state
68
- lives.
69
-
70
- ## 7. Single operator by design
71
-
72
- Each ToolFunnel instance serves one operator. That is a decision, not an oversight: one
73
- instance per user gives process isolation, crash containment, per-user wrap state, and a
74
- security boundary you can reason about in one sentence. Multi-user deployment composes *in
75
- front of* the gateway rather than inside it - an authenticating proxy at the OAuth boundary
76
- (ToolFunnel is already an OAuth 2.1 resource server) routing each authenticated user to their
77
- own instance, with the per-request `_meta` lane carrying user context through to upstreams
78
- that want it. Nothing in the core couples the operator to a token subject, so that door stays
79
- open without the core ever growing user-shaped complexity.
80
-
81
- A Team edition built on this pattern - per-user identity, audit trails, quotas, per-user
82
- policy - is on the roadmap if there is interest. If your team wants that sooner, or you need a
83
- bespoke gateway built on ToolFunnel for your own requirements, open an issue on this
84
- repository titled `enquiry` - tailored builds are available.
1
+ # Design decisions
2
+
3
+ Short notes on the decisions that shape ToolFunnel, so you can judge the reasoning without
4
+ reading the source. Each one is also documented at the place in the code where it bites.
5
+
6
+ ## 1. Zero runtime dependencies
7
+
8
+ ToolFunnel sits in a privileged position: it executes tools and enforces policy. Every
9
+ transitive dependency in that position is something you have to trust or audit. So the gateway
10
+ uses Node built-ins only; "dependencies" in package.json is empty and stays empty. The one
11
+ exception is deliberate and opt-in: OAuth token validation uses jose, installed only when you
12
+ run `toolfunnel install-oauth`, version-pinned, because hand-rolled JWT crypto is how security
13
+ holes are born. Dev tooling for the test suite (jose, the MCP SDK for interop tests) never
14
+ ships.
15
+
16
+ ## 2. No SDK
17
+
18
+ The gateway hand-rolls JSON-RPC over stdio and HTTP rather than building on the official MCP
19
+ SDK. Two reasons. First, the zero-dependency stance above. Second, ToolFunnel has to speak two
20
+ protocol eras at once, on both sides, which the SDK does not do; owning the wire layer is what
21
+ makes the dual-era serving and the wrap possible at all.
22
+
23
+ ## 3. The gate: a deny is absolute, an unreadable decision is a deny
24
+
25
+ Every server-side execution path goes through one function, gatedRun, and that function treats
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.
40
+
41
+ ## 4. The isolation boundary
42
+
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).
52
+
53
+ ## 5. Two eras, served per request
54
+
55
+ The 2026-07-28 revision removes the handshake that defines the older protocol, so "which era"
56
+ is a property of each request, and that is how the gateway treats it: era detection per
57
+ request, no mode switch, no configuration. Legacy answers stay byte-identical to what a
58
+ pre-0.6.0 client saw; the gateway's own modern decoration only ever appears on modern
59
+ requests. (A wrapped modern upstream's results are relayed verbatim, whatever era the
60
+ caller speaks - stripping a server's own fields would be data loss and a wrapper tell.)
61
+
62
+ ## 6. Config lives in one folder
63
+
64
+ Everything mutable (tools, upstreams, hooks, identity, logs) lives in the config home,
65
+ relocatable with `--config-dir` or `TOOLFUNNEL_HOME`, seeded once, never overwritten by
66
+ updates (a tool new in a release is added to the register once). One folder to back up, one folder to package, one folder to hand a colleague. The
67
+ gateway prints the resolved home at every start so there is never any doubt about where state
68
+ lives.
69
+
70
+ ## 7. Single operator by design
71
+
72
+ Each ToolFunnel instance serves one operator. That is a decision, not an oversight: one
73
+ instance per user gives process isolation, crash containment, per-user wrap state, and a
74
+ security boundary you can reason about in one sentence. Multi-user deployment composes *in
75
+ front of* the gateway rather than inside it - an authenticating proxy at the OAuth boundary
76
+ (ToolFunnel is already an OAuth 2.1 resource server) routing each authenticated user to their
77
+ own instance, with the per-request `_meta` lane carrying user context through to upstreams
78
+ that want it. Nothing in the core couples the operator to a token subject, so that door stays
79
+ open without the core ever growing user-shaped complexity.
80
+
81
+ A Team edition built on this pattern - per-user identity, audit trails, quotas, per-user
82
+ policy - is on the roadmap if there is interest. If your team wants that sooner, or you need a
83
+ bespoke gateway built on ToolFunnel for your own requirements, open an issue on this
84
+ repository titled `enquiry` - tailored builds are available.