toolfunnel 0.3.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 (66) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +324 -0
  3. package/bin/toolfunnel.js +112 -0
  4. package/docs/admin-ui.md +404 -0
  5. package/docs/configuration.md +402 -0
  6. package/docs/hooks-and-gating.md +428 -0
  7. package/docs/logging.md +223 -0
  8. package/docs/management.md +429 -0
  9. package/docs/oauth.md +103 -0
  10. package/docs/setup.md +352 -0
  11. package/docs/tools-and-upstreams.md +469 -0
  12. package/hooks/hooks.manifest.json +27 -0
  13. package/hooks/scripts/.gitkeep +3 -0
  14. package/hooks/scripts/example-audit.js +21 -0
  15. package/hooks/scripts/example-deny-dangerous.js +31 -0
  16. package/mcp/expose.example.json +30 -0
  17. package/mcp/servers/mock-upstream/server.js +170 -0
  18. package/package.json +63 -0
  19. package/src/auth/config.js +172 -0
  20. package/src/auth/install.js +181 -0
  21. package/src/auth/resource-server.js +319 -0
  22. package/src/core/events.js +105 -0
  23. package/src/core/hook-engine.js +275 -0
  24. package/src/core/hook-loader.js +791 -0
  25. package/src/core/hook-runner.js +346 -0
  26. package/src/core/logger.js +169 -0
  27. package/src/core/matcher.js +51 -0
  28. package/src/core/metrics.js +75 -0
  29. package/src/core/tool-registry.js +180 -0
  30. package/src/extend/add-hook.md +142 -0
  31. package/src/extend/add-mcp.md +121 -0
  32. package/src/extend/create-tool.md +132 -0
  33. package/src/extend/howto.js +98 -0
  34. package/src/extend/package.md +154 -0
  35. package/src/mcp/aggregator.js +802 -0
  36. package/src/mcp/expose-store.js +430 -0
  37. package/src/mcp/gated-run.js +194 -0
  38. package/src/mcp/http-transport.js +962 -0
  39. package/src/mcp/mcp-client.js +524 -0
  40. package/src/mcp/protocol.js +466 -0
  41. package/src/mcp/server.js +1240 -0
  42. package/src/packages/packager.js +713 -0
  43. package/src/tools/drift.js +138 -0
  44. package/src/tools/registry.js +468 -0
  45. package/src/tools/tool-state.js +104 -0
  46. package/src/ui/public/app.js +1055 -0
  47. package/src/ui/public/index.html +249 -0
  48. package/src/ui/public/logo.jpg +0 -0
  49. package/src/ui/public/styles.css +535 -0
  50. package/src/ui/server.js +1144 -0
  51. package/tools/scripts/base64.js +139 -0
  52. package/tools/scripts/danger.js +61 -0
  53. package/tools/scripts/echo.js +39 -0
  54. package/tools/scripts/hash.js +101 -0
  55. package/tools/scripts/json.js +109 -0
  56. package/tools/scripts/text-stats.js +131 -0
  57. package/tools/scripts/tf-hook-add.js +152 -0
  58. package/tools/scripts/tf-hook-set.js +113 -0
  59. package/tools/scripts/tf-list.js +195 -0
  60. package/tools/scripts/tf-log.js +83 -0
  61. package/tools/scripts/tf-mcp-add.js +119 -0
  62. package/tools/scripts/tf-mcp-set.js +104 -0
  63. package/tools/scripts/tf-tool-add.js +129 -0
  64. package/tools/scripts/tf-tool-set.js +148 -0
  65. package/tools/scripts/uuid.js +102 -0
  66. package/tools/tools.register.json +171 -0
@@ -0,0 +1,180 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * tool-registry.js — backend-agnostic tool catalogue for the host loop.
5
+ *
6
+ * The host asks the registry for tool definitions to hand to a backend
7
+ * (see ARCHITECTURE.md: `backend.send({ system, messages, tools })`). When the
8
+ * model requests a tool by name, the host executes it here. The registry holds
9
+ * NO host imports so it runs headless under `node --test`.
10
+ *
11
+ * A tool is registered as: register(name, { description, inputSchema, run })
12
+ * - description: human/model-facing string
13
+ * - inputSchema: a JSON-schema-ish object describing the input (handed to the backend)
14
+ * - run(input): the implementation; may be sync or async; its return value is the result
15
+ */
16
+
17
+ const fs = require('node:fs');
18
+ const path = require('node:path');
19
+
20
+ class ToolRegistry {
21
+ constructor() {
22
+ // name -> { description, inputSchema, run }
23
+ this._tools = new Map();
24
+ }
25
+
26
+ /**
27
+ * Register a tool under a unique name.
28
+ * Throws on a bad shape so misconfiguration is caught at wiring time, not at
29
+ * model-call time (the loop must never receive a half-formed tool).
30
+ *
31
+ * @param {string} name
32
+ * @param {{ description?: string, inputSchema?: object, run: (input:any)=>any }} def
33
+ * @returns {ToolRegistry} this (for chaining)
34
+ */
35
+ register(name, def) {
36
+ if (typeof name !== 'string' || name.length === 0) {
37
+ throw new Error('ToolRegistry.register: name must be a non-empty string');
38
+ }
39
+ if (!def || typeof def !== 'object') {
40
+ throw new Error(`ToolRegistry.register("${name}"): definition object is required`);
41
+ }
42
+ if (typeof def.run !== 'function') {
43
+ throw new Error(`ToolRegistry.register("${name}"): "run" must be a function`);
44
+ }
45
+ if (this._tools.has(name)) {
46
+ throw new Error(`ToolRegistry.register("${name}"): tool already registered`);
47
+ }
48
+ this._tools.set(name, {
49
+ description: typeof def.description === 'string' ? def.description : '',
50
+ // Default to an open object schema so a tool without a declared schema
51
+ // still produces a valid definition for the backend.
52
+ inputSchema:
53
+ def.inputSchema && typeof def.inputSchema === 'object'
54
+ ? def.inputSchema
55
+ : { type: 'object', properties: {} },
56
+ run: def.run,
57
+ });
58
+ return this;
59
+ }
60
+
61
+ /** @param {string} name @returns {boolean} */
62
+ has(name) {
63
+ return this._tools.has(name);
64
+ }
65
+
66
+ /**
67
+ * Execute a registered tool by name. Always returns a Promise so the host
68
+ * loop can `await` uniformly regardless of whether `run` was sync or async.
69
+ * Throws a clear error if the tool is unknown (the host turns this into a
70
+ * tool result / PostToolUse feedback rather than crashing the loop).
71
+ *
72
+ * @param {string} name
73
+ * @param {any} input
74
+ * @returns {Promise<any>} the tool's result
75
+ */
76
+ async execute(name, input) {
77
+ const tool = this._tools.get(name);
78
+ if (!tool) {
79
+ const known = this.list().map((t) => t.name);
80
+ throw new Error(
81
+ `ToolRegistry.execute: unknown tool "${name}". ` +
82
+ `Registered tools: ${known.length ? known.join(', ') : '(none)'}`
83
+ );
84
+ }
85
+ // Await covers both sync return values and returned Promises.
86
+ return await tool.run(input);
87
+ }
88
+
89
+ /**
90
+ * Tool definitions array, shaped for handing to a backend:
91
+ * `[{ name, description, inputSchema }]`. The `run` function is deliberately
92
+ * omitted — the backend never needs (or should see) the implementation.
93
+ *
94
+ * @returns {Array<{ name: string, description: string, inputSchema: object }>}
95
+ */
96
+ list() {
97
+ const out = [];
98
+ for (const [name, tool] of this._tools) {
99
+ out.push({
100
+ name,
101
+ description: tool.description,
102
+ inputSchema: tool.inputSchema,
103
+ });
104
+ }
105
+ return out;
106
+ }
107
+ }
108
+
109
+ /**
110
+ * makeDemoRegistry — a ToolRegistry preloaded with two demo tools used by the
111
+ * host tests (host.test.js drives the lifecycle through these).
112
+ *
113
+ * echo — returns its input unchanged (trivial, deterministic).
114
+ * readFileSandboxed — reads a UTF-8 file, but ONLY within `sandboxRoot`,
115
+ * rejecting any path that escapes the sandbox. This is
116
+ * the same defense-in-depth posture the hook loader's
117
+ * writeScript guard uses (HOOK_ENGINE.md §8) — a tool the
118
+ * model can call must not become a filesystem escape.
119
+ *
120
+ * @param {object} [opts]
121
+ * @param {string} [opts.sandboxRoot=process.cwd()] absolute root for readFileSandboxed
122
+ * @returns {ToolRegistry}
123
+ */
124
+ function makeDemoRegistry(opts) {
125
+ const sandboxRoot = path.resolve((opts && opts.sandboxRoot) || process.cwd());
126
+ const registry = new ToolRegistry();
127
+
128
+ registry.register('echo', {
129
+ description: 'Returns its input unchanged. Useful for testing the tool loop.',
130
+ inputSchema: {
131
+ type: 'object',
132
+ description: 'Any JSON value passed through as-is.',
133
+ properties: {},
134
+ additionalProperties: true,
135
+ },
136
+ run: (input) => input,
137
+ });
138
+
139
+ registry.register('readFileSandboxed', {
140
+ description:
141
+ 'Reads a UTF-8 text file located within the sandbox root. ' +
142
+ 'Rejects absolute paths and any relative path that escapes the sandbox.',
143
+ inputSchema: {
144
+ type: 'object',
145
+ properties: {
146
+ path: {
147
+ type: 'string',
148
+ description: 'Path to read, relative to the sandbox root.',
149
+ },
150
+ },
151
+ required: ['path'],
152
+ },
153
+ run: (input) => {
154
+ const requested = input && input.path;
155
+ if (typeof requested !== 'string' || requested.length === 0) {
156
+ throw new Error('readFileSandboxed: "path" (non-empty string) is required');
157
+ }
158
+ // Reject absolute paths outright; everything must be relative to the sandbox.
159
+ if (path.isAbsolute(requested)) {
160
+ throw new Error(
161
+ `readFileSandboxed: absolute paths are not allowed ("${requested}")`
162
+ );
163
+ }
164
+ // Resolve against the sandbox, then confirm the result is still inside it.
165
+ const resolved = path.resolve(sandboxRoot, requested);
166
+ const rel = path.relative(sandboxRoot, resolved);
167
+ // rel starting with ".." (or an absolute path on a different drive) means escape.
168
+ if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) {
169
+ throw new Error(
170
+ `readFileSandboxed: path escapes sandbox root ("${requested}")`
171
+ );
172
+ }
173
+ return fs.readFileSync(resolved, 'utf8');
174
+ },
175
+ });
176
+
177
+ return registry;
178
+ }
179
+
180
+ module.exports = { ToolRegistry, makeDemoRegistry };
@@ -0,0 +1,142 @@
1
+ # How to add a hook (self-extension)
2
+
3
+ This is the instruction served by `toolfunnel_howto({ topic: "add-hook" })`. It explains how to add a hook
4
+ to the Hook Engine: the manifest entry shape, where the script goes, and how the enabled/disabled
5
+ state is persisted separately in `hooks.state.json`. The full contract is HOOK_ENGINE.md — this is
6
+ the authoring view.
7
+
8
+ A hook is exactly two things:
9
+ 1. **A manifest entry** in `hooks/hooks.manifest.json` (its config: event, matcher, command, …).
10
+ 2. **A script** under `hooks/scripts/` that the entry's `command` runs.
11
+
12
+ Activation (on/off) is NOT in the manifest's source of truth at runtime — it lives in an overlay,
13
+ `hooks/hooks.state.json` (see §4).
14
+
15
+ ---
16
+
17
+ ## 1. The manifest entry shape
18
+
19
+ `hooks.manifest.json` has `version`, `hooksDir`, and a `hooks` array. Each entry:
20
+
21
+ ```json
22
+ {
23
+ "id": "pre-tool-use/deny-dangerous",
24
+ "event": "PreToolUse",
25
+ "matcher": "Bash|toolfunnel_run_tool",
26
+ "type": "command",
27
+ "command": "bash \"${HOOKS_DIR}/scripts/deny-dangerous.sh\"",
28
+ "script": "scripts/deny-dangerous.sh",
29
+ "timeout": 5,
30
+ "enabled": true,
31
+ "description": "Denies any tool call whose args contain a destructive pattern (rm -rf, etc)."
32
+ }
33
+ ```
34
+
35
+ | Field | Type | Meaning |
36
+ |---|---|---|
37
+ | `id` | string | Unique key, format `"<kebab-event>/<script-basename-without-ext>"` (e.g. `pre-tool-use/deny-dangerous`). The loader and `hooks.state.json` key on this. |
38
+ | `event` | string | One of the six lifecycle events: `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `Stop`, `PreCompact`. |
39
+ | `matcher` | string | Regex anchored full-match against `tool_name`. `""`, `undefined`, or `"*"` = always fire. Tool-less events (SessionStart, UserPromptSubmit, Stop, PreCompact) ignore it. Example: `"Bash|Write|Edit"`. |
40
+ | `type` | string | `"command"` (the only type today). |
41
+ | `command` | string | The command line the runner spawns. `${HOOKS_DIR}` expands to the absolute `hooks` path at load time. |
42
+ | `script` | string | The script path relative to `hooksDir`, e.g. `scripts/deny-dangerous.sh`. Used by the manager's open/edit (`readScript`/`writeScript`). |
43
+ | `timeout` | number | **Seconds** (the runner multiplies by 1000). The runner kills the child past this. |
44
+ | `enabled` | boolean | The manifest's default/seed state. The live value is the overlay in `hooks.state.json` — see §4. |
45
+ | `description` | string | What the hook does, for the manager UI. |
46
+
47
+ One entry **per hook command** — a settings.json event with N hooks becomes N entries.
48
+
49
+ ---
50
+
51
+ ## 2. Where the script goes
52
+
53
+ ```
54
+ hooks/
55
+ hooks.manifest.json # config entries (above)
56
+ hooks.state.json # enabled/disabled overlay, keyed by hook id
57
+ scripts/ # your hook scripts live HERE
58
+ deny-dangerous.sh
59
+ ```
60
+
61
+ (The loader itself — `hook-loader.js` — is engine code and is NOT user content; it lives in
62
+ `src/core/`, not in `hooks/`.)
63
+
64
+ `writeScript` (the manager's save path) refuses any path that resolves outside
65
+ `hooks/scripts/` — defense-in-depth for the isolation rule. Author your script there only;
66
+ never point a hook at a script in a live host hooks directory. Clone the behaviour in if you need it.
67
+
68
+ ---
69
+
70
+ ## 3. What the script returns (the two protocols)
71
+
72
+ The runner spawns the command, writes the event payload as JSON on stdin, then reads the result.
73
+ Two protocols (HOOK_ENGINE.md §3):
74
+
75
+ **A) Exit-code (simple):**
76
+ - `exit 0` → success. On `SessionStart`/`UserPromptSubmit`, stdout becomes **injected context**;
77
+ on other events stdout is advisory.
78
+ - `exit 2` → **blocking**. stderr is the reason.
79
+ - any other code → non-blocking error (stderr captured, loop continues; never throws).
80
+
81
+ **B) JSON (advanced)** — honored **only on exit 0**, when stdout parses as a JSON object:
82
+ ```json
83
+ {
84
+ "continue": true,
85
+ "decision": "block",
86
+ "reason": "…",
87
+ "hookSpecificOutput": {
88
+ "hookEventName": "PreToolUse",
89
+ "permissionDecision": "allow" | "deny" | "ask",
90
+ "permissionDecisionReason": "…",
91
+ "additionalContext": "…"
92
+ }
93
+ }
94
+ ```
95
+ - **PreToolUse blocks via `hookSpecificOutput.permissionDecision: "deny"`** — NOT the top-level
96
+ `decision` field. `"allow"` passes; `"ask"` is non-blocking in the autonomous host.
97
+ - **Top-level `decision: "block"`** is for the other blockable events (UserPromptSubmit, PostToolUse,
98
+ Stop, PreCompact). There is no `"approve"`.
99
+ - `continue: false` stops the whole loop. `hookSpecificOutput.additionalContext` → injected text.
100
+
101
+ This is the mechanism that makes a PreToolUse hook gate `toolfunnel_run_tool`: match its `tool_name`, and
102
+ return `permissionDecision: "deny"` to block the call before the tool runs.
103
+
104
+ ---
105
+
106
+ ## 4. Activation state lives in `hooks.state.json`
107
+
108
+ The Hook Manager design splits **what exists** from **what's on**:
109
+
110
+ - **The `scripts/` folder is the source of truth for what exists.** On startup the loader scans it
111
+ and reconciles with the manifest: newly-found scripts are added as entries (event/matcher inferred
112
+ or flagged "unconfigured"); manifest entries whose script is missing are flagged.
113
+ - **The manifest carries config** (event, matcher, command, timeout, description, seed `enabled`).
114
+ - **`hooks.state.json` is the activation overlay** — an object keyed by hook `id` recording the live
115
+ enabled/disabled value:
116
+
117
+ ```json
118
+ {
119
+ "version": 1,
120
+ "enabled": {
121
+ "pre-tool-use/deny-dangerous": true,
122
+ "user-prompt-submit/inject-context": false
123
+ }
124
+ }
125
+ ```
126
+
127
+ Toggling a hook writes `hooks.state.json` **atomically** (temp + rename). It survives restarts and is
128
+ kept separate from the auto-detected inventory, so re-scanning the folder never clobbers your toggles.
129
+ At load, the overlay wins over the manifest's seed `enabled` for any id it lists.
130
+
131
+ ---
132
+
133
+ ## 5. Steps to add a hook
134
+
135
+ 1. Write the script into `hooks/scripts/<name>.sh` (or a node script). Read the stdin JSON
136
+ payload; emit one of the two protocols above.
137
+ 2. Add the manifest entry: `id` = `"<kebab-event>/<name>"`, set `event`, `matcher`, `command`
138
+ (`bash "${HOOKS_DIR}/scripts/<name>.sh"`), `script`, `timeout` (seconds), `description`, and a
139
+ seed `enabled`.
140
+ 3. Set its live state in `hooks.state.json` via the loader's `setEnabled(id, bool)` (atomic write).
141
+ 4. Verify: the manager lists it under its event; a run-once test fires it with a sample payload and
142
+ shows the result. For a gate, confirm a matching `toolfunnel_run_tool` call is denied with your reason.
@@ -0,0 +1,121 @@
1
+ # How to register an upstream MCP (self-extension)
2
+
3
+ This is the instruction served by `toolfunnel_howto({ topic: "add-mcp" })`. It explains how to connect an
4
+ **upstream MCP server** (a browser-automation MCP, a chat bridge, …) to the aggregator and choose **which of its
5
+ tools to expose downstream** to the CLI. All of this is configured in one persisted file:
6
+ `mcp/expose.json`.
7
+
8
+ The architecture (see the architecture overview, §1): many upstream MCPs each expose many tools; the
9
+ aggregator connects to them and the **MCP Manager picks which tools to surface** to the CLI as
10
+ *curated-direct* tools — alongside the four lean meta-tools (`toolfunnel_list_tools`,
11
+ `toolfunnel_tool_instructions`, `toolfunnel_run_tool`, `toolfunnel_howto`). The CLI sees a small surface; everything
12
+ else stays reachable through the register.
13
+
14
+ > Phase note: the live aggregator (`src/mcp/aggregator.js`) is Phase 2. This doc defines the
15
+ > `expose.json` shape so the config is authorable now and the aggregator reads exactly these fields
16
+ > when it lands.
17
+
18
+ ---
19
+
20
+ ## 1. `expose.json` — the two blocks
21
+
22
+ `expose.json` persists (a) the upstream MCP connections and (b) the curated-direct selection drawn
23
+ from them:
24
+
25
+ ```json
26
+ {
27
+ "version": 1,
28
+ "upstreams": [
29
+ {
30
+ "id": "chat",
31
+ "transport": "stdio",
32
+ "command": "node",
33
+ "args": ["vendor/chat-mcp/server.js"],
34
+ "env": { "CHAT_DB": "..." },
35
+ "enabled": true,
36
+ "description": "Chat bridge MCP — chats, messages, send."
37
+ },
38
+ {
39
+ "id": "browser",
40
+ "transport": "stdio",
41
+ "command": "npx",
42
+ "args": ["@example/browser-mcp"],
43
+ "enabled": false,
44
+ "description": "Browser automation MCP."
45
+ }
46
+ ],
47
+ "expose": [
48
+ {
49
+ "upstream": "chat",
50
+ "tool": "list_chats",
51
+ "as": "chat_list_chats",
52
+ "category": "chat",
53
+ "enabled": true
54
+ },
55
+ {
56
+ "upstream": "chat",
57
+ "tool": "send_message",
58
+ "as": "chat_send_message",
59
+ "category": "chat",
60
+ "enabled": true
61
+ }
62
+ ]
63
+ }
64
+ ```
65
+
66
+ ### `upstreams[]` — one entry per upstream MCP
67
+
68
+ | Field | Type | Meaning |
69
+ |---|---|---|
70
+ | `id` | string | Stable key for this upstream. Referenced by `expose[].upstream`. Unique. |
71
+ | `transport` | string | How the aggregator connects. `"stdio"` (spawn `command`+`args`) is the baseline. |
72
+ | `command` / `args` / `env` | string / string[] / object | The process the aggregator spawns for a `stdio` upstream. Paths must point **inside the project's vendor directory** (clone vendored servers in; never reference servers configured elsewhere on the host). |
73
+ | `enabled` | boolean | Whether the aggregator connects to this upstream at startup. |
74
+ | `description` | string | Human-facing note for the MCP Manager UI. |
75
+
76
+ ### `expose[]` — which upstream tools become curated-direct
77
+
78
+ | Field | Type | Meaning |
79
+ |---|---|---|
80
+ | `upstream` | string | Must match an `upstreams[].id`. |
81
+ | `tool` | string | The upstream tool's own name (as the upstream's `tools/list` reports it). |
82
+ | `as` | string | The downstream name the CLI sees (namespaced so two upstreams can't collide, e.g. `chat_send_message`). Optional — defaults to `<upstream>_<tool>`. |
83
+ | `category` | string | Grouping label, same convention as register entries. |
84
+ | `enabled` | boolean | Whether this tool is actually exposed downstream right now. |
85
+
86
+ Only the tools listed in `expose[]` with `enabled: true` are surfaced to the CLI as curated-direct
87
+ tools. Everything else on the upstream stays connected but hidden — reachable, if needed, by adding
88
+ an `expose` entry (no upstream reconnect required to *list*, though see §3 on the CLI surface).
89
+
90
+ ---
91
+
92
+ ## 2. Steps to register an upstream and expose a tool
93
+
94
+ 1. Add an entry to `upstreams[]` with a unique `id`, the `stdio` `command`/`args`, and
95
+ `enabled: true`. Vendor the server **inside the project's vendor directory** if it isn't already.
96
+ 2. Let the aggregator connect and run the upstream's `tools/list` (the MCP Manager UI surfaces the
97
+ discovered tools).
98
+ 3. For each tool you want hot, add an `expose[]` entry: `{ upstream, tool, as, category, enabled:true }`.
99
+ 4. Leave the long tail unexposed — the model can still reach those via the register/meta-tools if you
100
+ later mirror them, but the *curated-direct* surface stays lean by design (the dial in §1 of the
101
+ architecture: directly-exposed = the HOT tools; the register meta-tools = the long tail).
102
+
103
+ ---
104
+
105
+ ## 3. Curated-direct vs. the register (update cost)
106
+
107
+ This matters because the two layers refresh differently (see the architecture overview, §1):
108
+
109
+ - **Curated-direct tools** (what `expose[]` produces) are negotiated with the CLI **at connect time**.
110
+ Changing the exposed set therefore needs a **reconnect**, or a `notifications/tools/list_changed`
111
+ message *if the CLI honours it* (VERIFY per host; restart is the safe baseline).
112
+ - **Register tools** (the `toolfunnel_list_tools`/`run` path) are **always live** — the model re-reads the
113
+ register on every `toolfunnel_list_tools` call, so no reconnect is needed.
114
+
115
+ Rule of thumb: expose a handful of HOT, high-reliability tools curated-direct; everything else lives
116
+ in the register and is reached through the meta-tools.
117
+
118
+ > Safety: a curated-direct upstream tool is still ultimately invoked by the model. When you wire
119
+ > upstream execution through the host, route it through the same PreToolUse gate the register's `run`
120
+ > uses (see `toolfunnel_howto({ topic: "add-hook" })`) so the safety invariant holds uniformly. The gate
121
+ > firing on every execution path is a tested invariant, not an assumption.
@@ -0,0 +1,132 @@
1
+ # How to add a tool (self-extension)
2
+
3
+ This is the instruction served by `toolfunnel_howto({ topic: "create-tool" })`. It tells you — the
4
+ model — how to add a new tool to the register so it becomes callable through `toolfunnel_run_tool`
5
+ (and listable via `toolfunnel_list_tools` / `toolfunnel_tool_instructions`). No restart is needed: the register
6
+ is read live every time the meta-tools are called.
7
+
8
+ A tool is exactly two things:
9
+ 1. **A register entry** in `tools/tools.register.json` (structured metadata + how to invoke it).
10
+ 2. **A script** under `tools/scripts/` that the entry's `invoke` points at (only for
11
+ `invoke.type === "script"`; a `shell` tool needs no file).
12
+
13
+ ---
14
+
15
+ ## 1. The register entry shape
16
+
17
+ `tools.register.json` is a JSON file with one top-level array of entries. Each entry:
18
+
19
+ ```json
20
+ {
21
+ "id": "echo-upper",
22
+ "name": "Echo Upper",
23
+ "summary": "Uppercases the supplied text and echoes it back.",
24
+ "category": "demo",
25
+ "instructions": "Pass { text: string }. Returns the text uppercased. Use this only as a sanity check of the run path.",
26
+ "invoke": { "type": "script", "path": "scripts/echo-upper.sh" }
27
+ }
28
+ ```
29
+
30
+ Field-by-field — these names are fixed (registry.js reads exactly these):
31
+
32
+ | Field | Type | Meaning |
33
+ |---|---|---|
34
+ | `id` | string | Stable unique key, kebab-case. `toolfunnel_run_tool({ name })` and `registry.run(id, args)` look up by this. Never reuse an existing `id`. |
35
+ | `name` | string | Human/model-facing display name. Shown in the manager UI. |
36
+ | `summary` | string | One line. This is ALL that `toolfunnel_list_tools` returns for the tool — keep it tight and discriminating so the model can pick the right tool from the brief alone. |
37
+ | `category` | string | Grouping label (e.g. `demo`, `memory`, `messaging`, `media`). Used by `toolfunnel_list_tools({ category })` to filter. |
38
+ | `instructions` | string | Full usage doc returned by `toolfunnel_tool_instructions({ name })`. Describe the args object, what comes back, and any safety caveats. This is the long-tail payload — be complete here, terse in `summary`. |
39
+ | `invoke` | object | How `run` executes the tool. See below. |
40
+
41
+ ### The `invoke` object — two forms
42
+
43
+ ```json
44
+ { "type": "script", "path": "scripts/echo-upper.sh" }
45
+ ```
46
+ - `type: "script"` — run a file under `tools/scripts/`. `path` is **relative to `tools/`**
47
+ and MUST stay inside `tools/scripts/` (registry.js path-guards it, same defense-in-depth as the
48
+ hook loader's `writeScript`). The args object is delivered to the script (e.g. as a JSON blob on
49
+ stdin or argv per the registry's convention) and the script's stdout is the tool output.
50
+
51
+ ```json
52
+ { "type": "shell", "command": "node \"${TOOLS_DIR}/scripts/echo-upper.js\"" }
53
+ ```
54
+ - `type: "shell"` — run a command line. `${TOOLS_DIR}` expands to the absolute `tools` path at run
55
+ time (mirrors `${HOOKS_DIR}` in the hook manifest). Prefer `script` for anything reusable; reserve
56
+ `shell` for thin one-liners.
57
+
58
+ > Isolation rule: an `invoke` must only reference scripts/commands that live **inside the gateway's
59
+ > own tree**. Never point `invoke` at a live tool that lives outside it (e.g. in a host's external
60
+ > tool directory). If you want an external tool's behaviour, clone its script into
61
+ > `tools/scripts/` first, then point at the clone.
62
+
63
+ ---
64
+
65
+ ## 2. Where the script goes
66
+
67
+ ```
68
+ tools/
69
+ tools.register.json # the entries (above)
70
+ scripts/ # your tool scripts live HERE and nowhere else
71
+ echo-upper.sh
72
+ ```
73
+
74
+ (`registry.js` — the engine that loads/persists the register and runs list/instructions/run — is
75
+ NOT user content. It lives in `src/tools/registry.js` and is not part of this tree.)
76
+
77
+ The script reads its arguments (the `args` object passed to `run`) and writes its result to stdout.
78
+ Exit non-zero with a message on stderr to signal failure — the registry surfaces that as the tool's
79
+ error. Keep scripts pure and side-effect-honest: anything destructive is exactly what the PreToolUse
80
+ gate exists to catch (next section).
81
+
82
+ ---
83
+
84
+ ## 3. How `run` works — and why it is gated
85
+
86
+ `toolfunnel_run_tool({ name, args })` does **not** execute the script directly. It calls
87
+ `registry.run(id, args)`, which routes the call through the gateway's **hook engine** before — and after —
88
+ the tool actually runs:
89
+
90
+ ```
91
+ toolfunnel_run_tool(name, args)
92
+ │
93
+ ▼
94
+ registry.run(id, args)
95
+ │
96
+ ├─► hookEngine.fire("PreToolUse", ctx, { tool_name, tool_input: args })
97
+ │ └─ if result.blocked → STOP. Return { ok:false, error: <reason> }. Script never runs.
98
+ │
99
+ ├─► execute the invoke (script/shell) only if NOT blocked
100
+ │
101
+ └─► hookEngine.fire("PostToolUse", ctx, { tool_name, tool_input: args, tool_response })
102
+ └─ feedback only; the tool already ran (cannot un-run it)
103
+ ```
104
+
105
+ - **The PreToolUse gate is the whole safety case.** A hook denies a call by returning, on exit 0,
106
+ `{ "hookSpecificOutput": { "hookEventName": "PreToolUse", "permissionDecision": "deny",
107
+ "permissionDecisionReason": "…" } }` — OR by `exit 2` with the reason on stderr. (PreToolUse uses
108
+ `permissionDecision`, NOT a top-level `decision` — see HOOK_ENGINE.md §3.) When that fires,
109
+ `run` returns an error result and the script is never spawned.
110
+ - `permissionDecision: "allow"` passes; `"ask"` is non-blocking in the autonomous host (no
111
+ interactive prompt) — the reason is captured but the tool proceeds.
112
+ - The result shape `run` returns matches the meta-tool contract: `{ ok, output, error? }`.
113
+
114
+ So adding a tool grants real power, but every invocation is mediated by the same gate that protects
115
+ every other tool. If your new tool is dangerous, the correct control is a PreToolUse hook that
116
+ matches its `tool_name` (see `toolfunnel_howto({ topic: "add-hook" })`), not omitting the tool.
117
+
118
+ ---
119
+
120
+ ## 4. Steps to add a tool
121
+
122
+ 1. Pick a unique `id` (check `toolfunnel_list_tools` first so you don't collide).
123
+ 2. If `invoke.type === "script"`, write the script into `tools/scripts/<id>.sh` (or `.js`).
124
+ 3. Add the entry to the `tools.register.json` array (via `registry.js`'s add/edit path so the write
125
+ is atomic — never hand-edit and risk a torn file).
126
+ 4. Confirm with `toolfunnel_list_tools` (your `summary` appears) and `toolfunnel_tool_instructions({ name })`
127
+ (your `instructions` appear).
128
+ 5. Test with `toolfunnel_run_tool({ name, args })`. Verify the gate behaves: a benign call returns
129
+ `{ ok:true, output }`; if a matching PreToolUse hook should deny it, confirm it does.
130
+
131
+ No reconnect is needed — the register is dynamic. (Only *curated-direct* tools negotiated at MCP
132
+ connect need a reconnect; register tools reached via the meta-tools are always live.)
@@ -0,0 +1,98 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * howto.js — the loader behind the `toolfunnel_howto` meta-tool (architecture notes §2 + §6).
5
+ *
6
+ * `toolfunnel_howto({ topic })` returns the self-extension instructions for one topic. This module
7
+ * resolves a topic to its authored markdown file (the topic -> file map below) and reads it.
8
+ *
9
+ * howto(topic) -> string // the markdown content for that topic
10
+ *
11
+ * Topics (the four documented in §2): create-tool | add-mcp | add-hook | package.
12
+ * Unknown topics THROW (the protocol layer turns that into a clean tool error, not a crash).
13
+ *
14
+ * Isolation: reads are confined to THIS directory (src/extend). The topic map is a fixed
15
+ * allow-list of basenames — there is no path interpolation from the caller, so a malicious
16
+ * `topic` cannot traverse out of src/extend. We additionally re-verify the resolved path is
17
+ * inside this directory (defense-in-depth, mirroring the hook loader's writeScript guard).
18
+ *
19
+ * CommonJS only. Node built-ins only. No transport, pure file read — unit-testable directly.
20
+ */
21
+
22
+ const fs = require('node:fs');
23
+ const path = require('node:path');
24
+
25
+ // The directory these instruction files live in (this module's own directory).
26
+ const EXTEND_DIR = __dirname;
27
+
28
+ /**
29
+ * Fixed topic -> filename map. The set of keys IS the list of valid topics; the values are
30
+ * plain basenames (no separators) so nothing the caller passes is ever joined into a path.
31
+ * @type {Readonly<Record<string,string>>}
32
+ */
33
+ const TOPIC_FILES = Object.freeze({
34
+ 'create-tool': 'create-tool.md',
35
+ 'add-mcp': 'add-mcp.md',
36
+ 'add-hook': 'add-hook.md',
37
+ package: 'package.md',
38
+ });
39
+
40
+ /**
41
+ * The valid topic names, for error messages and for callers that want to enumerate
42
+ * (e.g. the protocol layer validating args, or a UI listing the howto topics).
43
+ * @returns {string[]}
44
+ */
45
+ function topics() {
46
+ return Object.keys(TOPIC_FILES);
47
+ }
48
+
49
+ /**
50
+ * Resolve a topic to the instruction markdown content.
51
+ *
52
+ * @param {string} topic one of: create-tool | add-mcp | add-hook | package
53
+ * @returns {string} the UTF-8 markdown content of the matching file
54
+ * @throws {Error} if `topic` is not a string, is unknown, or (defensively) resolves outside
55
+ * src/extend or the file is missing.
56
+ */
57
+ function howto(topic) {
58
+ if (typeof topic !== 'string' || topic.length === 0) {
59
+ throw new Error(
60
+ `howto: topic must be a non-empty string. Known topics: ${topics().join(', ')}`
61
+ );
62
+ }
63
+
64
+ // Look up against the fixed allow-list — own property only (guards against "constructor",
65
+ // "__proto__", etc. resolving via the prototype chain).
66
+ if (!Object.prototype.hasOwnProperty.call(TOPIC_FILES, topic)) {
67
+ throw new Error(
68
+ `howto: unknown topic "${topic}". Known topics: ${topics().join(', ')}`
69
+ );
70
+ }
71
+
72
+ const fileName = TOPIC_FILES[topic];
73
+ const filePath = path.join(EXTEND_DIR, fileName);
74
+
75
+ // Defense-in-depth: confirm the resolved path is still inside EXTEND_DIR. With a fixed
76
+ // basename map this can never fail, but the guard documents and enforces the isolation rule.
77
+ const rel = path.relative(EXTEND_DIR, filePath);
78
+ if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) {
79
+ throw new Error(`howto: refusing to read outside src/extend ("${fileName}")`);
80
+ }
81
+
82
+ try {
83
+ return fs.readFileSync(filePath, 'utf8');
84
+ } catch (err) {
85
+ // A known topic whose file is missing is a packaging error, not an unknown-topic error.
86
+ throw new Error(
87
+ `howto: instruction file for topic "${topic}" is missing or unreadable ` +
88
+ `(${filePath}): ${(err && err.message) || String(err)}`
89
+ );
90
+ }
91
+ }
92
+
93
+ module.exports = {
94
+ howto,
95
+ topics,
96
+ TOPIC_FILES,
97
+ EXTEND_DIR,
98
+ };