@musashishao/folderforge 1.2.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 (64) hide show
  1. package/README.md +181 -0
  2. package/dist/adapters/child-mcp/client.js +114 -0
  3. package/dist/adapters/child-mcp/registry.js +66 -0
  4. package/dist/audit/audit-log.js +45 -0
  5. package/dist/audit/event-types.js +1 -0
  6. package/dist/core/config.js +211 -0
  7. package/dist/core/container.js +51 -0
  8. package/dist/core/errors.js +37 -0
  9. package/dist/core/logger.js +8 -0
  10. package/dist/core/types.js +4 -0
  11. package/dist/dashboard/server.js +191 -0
  12. package/dist/lsp/protocol.js +116 -0
  13. package/dist/main.js +190 -0
  14. package/dist/managers/db-manager.js +161 -0
  15. package/dist/managers/lsp-manager.js +269 -0
  16. package/dist/managers/process-manager.js +140 -0
  17. package/dist/policy/approvals.js +143 -0
  18. package/dist/policy/command-policy.js +99 -0
  19. package/dist/policy/glob-match.js +61 -0
  20. package/dist/policy/path-policy.js +73 -0
  21. package/dist/policy/policy-engine.js +156 -0
  22. package/dist/policy/rate-limiter.js +96 -0
  23. package/dist/policy/risk.js +112 -0
  24. package/dist/policy/secret-policy.js +132 -0
  25. package/dist/server/mcp-server.js +144 -0
  26. package/dist/server/transports/http.js +133 -0
  27. package/dist/server/transports/stdio.js +14 -0
  28. package/dist/tools/adapter-tools.js +62 -0
  29. package/dist/tools/browser-tools.js +76 -0
  30. package/dist/tools/build-tools.js +78 -0
  31. package/dist/tools/code-tools.js +250 -0
  32. package/dist/tools/coverage-tools.js +135 -0
  33. package/dist/tools/db-tools.js +130 -0
  34. package/dist/tools/diff-util.js +45 -0
  35. package/dist/tools/error-parser.js +57 -0
  36. package/dist/tools/file-tools.js +319 -0
  37. package/dist/tools/format-tools.js +118 -0
  38. package/dist/tools/git-tools.js +371 -0
  39. package/dist/tools/index.js +63 -0
  40. package/dist/tools/memory-tools.js +54 -0
  41. package/dist/tools/output-schemas.js +100 -0
  42. package/dist/tools/pagination.js +92 -0
  43. package/dist/tools/pkg-tools.js +260 -0
  44. package/dist/tools/process-tools.js +128 -0
  45. package/dist/tools/registry.js +194 -0
  46. package/dist/tools/schema-lock.js +152 -0
  47. package/dist/tools/search-tools.js +176 -0
  48. package/dist/tools/security-tools.js +147 -0
  49. package/dist/tools/terminal-tools.js +57 -0
  50. package/dist/tools/workspace-tools.js +186 -0
  51. package/dist/workspace/memory-store.js +67 -0
  52. package/dist/workspace/onboarding.js +46 -0
  53. package/dist/workspace/project-detector.js +95 -0
  54. package/dist/workspace/workspace-manager.js +106 -0
  55. package/docs/adapters.md +76 -0
  56. package/docs/architecture.md +66 -0
  57. package/docs/roadmap.md +172 -0
  58. package/docs/security.md +94 -0
  59. package/docs/tools.md +129 -0
  60. package/examples/claude-desktop.json +18 -0
  61. package/examples/codex.toml +18 -0
  62. package/examples/config.basic.yaml +37 -0
  63. package/examples/config.full.yaml +120 -0
  64. package/package.json +74 -0
package/README.md ADDED
@@ -0,0 +1,181 @@
1
+ # FolderForge
2
+
3
+ FolderForge turns any local folder into a safe, full-tool MCP workspace for AI agents.
4
+
5
+ ## Installation
6
+
7
+ FolderForge is a single CLI (`folderforge`) that an AI coding agent launches over
8
+ stdio. It needs **Node.js >= 22**. It runs against any project folder you point
9
+ it at with `--project`; a config file is optional (without one it allows just
10
+ that project directory and applies safe defaults).
11
+
12
+ ### Option 1 - npx (no install, recommended)
13
+
14
+ The fastest path: let your agent run FolderForge on demand. Nothing to install
15
+ globally. Point any MCP client at it:
16
+
17
+ ```jsonc
18
+ {
19
+ "mcpServers": {
20
+ "folderforge": {
21
+ "command": "npx",
22
+ "args": ["-y", "folderforge", "--stdio", "--project", "/absolute/path/to/your-project"]
23
+ }
24
+ }
25
+ }
26
+ ```
27
+
28
+ Try it in a terminal first:
29
+
30
+ ```bash
31
+ npx -y folderforge --project . --stdio
32
+ ```
33
+
34
+ ### Option 2 - global install from npm
35
+
36
+ Install once, then call `folderforge` from anywhere:
37
+
38
+ ```bash
39
+ npm install -g folderforge
40
+ folderforge --version
41
+
42
+ # run against any folder
43
+ cd /path/to/your-project
44
+ folderforge --stdio --project .
45
+ ```
46
+
47
+ Agent config when installed globally:
48
+
49
+ ```jsonc
50
+ {
51
+ "mcpServers": {
52
+ "folderforge": {
53
+ "command": "folderforge",
54
+ "args": ["--stdio", "--project", "/absolute/path/to/your-project"]
55
+ }
56
+ }
57
+ }
58
+ ```
59
+
60
+ ### Option 3 - from source (Git, for contributors)
61
+
62
+ Clone and build when you want to hack on FolderForge or run an unreleased
63
+ version:
64
+
65
+ ```bash
66
+ git clone https://github.com/your-org/folderforge.git
67
+ cd folderforge
68
+ npm install
69
+ npm run build # emits dist/
70
+
71
+ # optional: expose the `folderforge` command system-wide
72
+ npm link
73
+
74
+ # or run directly without linking
75
+ node dist/main.js --stdio --project /path/to/your-project
76
+ ```
77
+
78
+ During development you can skip the build step and run the TypeScript source
79
+ directly:
80
+
81
+ ```bash
82
+ npm run dev -- --stdio --project /path/to/your-project
83
+ ```
84
+
85
+ You can also install straight from a Git remote without a published npm release:
86
+
87
+ ```bash
88
+ npm install -g github:your-org/folderforge
89
+ ```
90
+
91
+ ### Pointing at a different folder
92
+
93
+ The folder FolderForge serves is independent of where it is installed. Set the
94
+ working project with `--project` (and, if you use a config file, make sure its
95
+ `workspace.allowedDirectories` includes that folder). To work across several
96
+ projects at once, list them all in `allowedDirectories`.
97
+
98
+ ## What it does
99
+
100
+ - Activates a local workspace (single or multiple projects at once)
101
+ - Exposes MCP tools over stdio and localhost HTTP
102
+ - Enforces path, command, and secret policy with a four-level risk model
103
+ - Gates sensitive actions behind an approval queue (persisted across restarts)
104
+ - Records every call to an append-only audit log
105
+ - Supports file, search, shell, process, git, build, code-intelligence,
106
+ memory, browser, and database workflows
107
+
108
+ ## Status (1.0)
109
+
110
+ FolderForge is at **1.0**. The full stack is in place and frozen for release:
111
+
112
+ - **Core** - config loader (with aggregated validation errors), dependency
113
+ container, multi-project workspace activation.
114
+ - **Policy** - path, command, and secret policies + risk model; approval queue
115
+ (once/session scopes) persisted under `.folderforge/approvals.jsonl`;
116
+ per-tool rate limits and daily quotas; pluggable secret scanning with
117
+ Shannon-entropy detection.
118
+ - **Tools** - full native catalog (files, search incl. structural `search_ast`,
119
+ terminal, processes, git, build/quality, code intelligence, memory, security,
120
+ policy/audit, approvals, browser, database) plus `workspace_route` for
121
+ task-preset tool routing. The public tool surface is **frozen** in
122
+ `src/tools/schema-lock.ts` and guarded by tests.
123
+ - **Adapters** - Serena, Playwright, and Desktop Commander child-MCP servers,
124
+ proxied with namespacing (`serena__<tool>`).
125
+ - **Server** - MCP `tools/list` / `tools/call` over stdio and a hardened
126
+ Streamable HTTP transport (constant-time bearer auth, CORS allowlist,
127
+ idle-session expiry).
128
+ - **Observability** - append-only JSONL audit log + ring buffer, `policy_explain`
129
+ dry-run tooling, and a local dashboard (`/status`, `/audit`, `/processes`,
130
+ `/approvals`).
131
+
132
+ See `docs/roadmap.md` for the detailed milestone history and post-1.0 ideas.
133
+
134
+ ### MCP protocol features (1.2)
135
+
136
+ Beyond `tools/list` / `tools/call`, FolderForge supports progress
137
+ notifications (P4), cancellation (P6), and elicitation (P8), wired through a
138
+ per-call control object that leaves the frozen tool schema untouched.
139
+ `git_reset`, `git_push`, and `git_pull` confirm interactively before acting when
140
+ the client supports elicitation, and `git_push` / `git_fetch` / `git_pull` /
141
+ `process_tail` emit progress.
142
+
143
+ ## Run
144
+
145
+ ```bash
146
+ npm install
147
+ npm run dev -- --stdio
148
+ ```
149
+
150
+ or
151
+
152
+ ```bash
153
+ npm run dev -- --port 7331 --host 127.0.0.1
154
+ ```
155
+
156
+ ## Develop
157
+
158
+ ```bash
159
+ npm test # unit + integration (vitest)
160
+ npm run typecheck # tsc --noEmit
161
+ npm run build # emit to dist/
162
+ ```
163
+
164
+ ## Design goals
165
+
166
+ - Safe by default
167
+ - Local-first
168
+ - Auditable
169
+ - MCP-native
170
+ - Production-minded code structure
171
+
172
+ ## Repository structure
173
+
174
+ - `src/` - server, policy, workspace, tools, audit, dashboard
175
+ - `docs/` - architecture, tools, adapters, security, and roadmap docs
176
+ - `examples/` - sample client configs
177
+ - `tests/` - unit and integration tests (incl. the schema-lock guard)
178
+
179
+ ## License
180
+
181
+ Apache-2.0
@@ -0,0 +1,114 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { logger } from '../../core/logger.js';
3
+ /**
4
+ * Minimal JSON-RPC client over a child MCP server's stdio.
5
+ * Implements just enough of the MCP wire protocol to initialize,
6
+ * list tools, and call tools.
7
+ */
8
+ export class StdioChildClient {
9
+ command;
10
+ args;
11
+ env;
12
+ child = null;
13
+ pending = new Map();
14
+ nextId = 1;
15
+ buffer = '';
16
+ initialized = false;
17
+ constructor(command, args, env = {}) {
18
+ this.command = command;
19
+ this.args = args;
20
+ this.env = env;
21
+ }
22
+ async start() {
23
+ if (this.child)
24
+ return;
25
+ this.child = spawn(this.command, this.args, {
26
+ env: { ...process.env, ...this.env },
27
+ stdio: ['pipe', 'pipe', 'pipe'],
28
+ });
29
+ this.child.stdout.on('data', (chunk) => this.onData(chunk));
30
+ this.child.stderr.on('data', (chunk) => logger.debug({ child: this.command, msg: chunk.toString() }, 'child stderr'));
31
+ this.child.on('exit', (code) => {
32
+ logger.warn({ child: this.command, code }, 'child MCP exited');
33
+ this.child = null;
34
+ this.initialized = false;
35
+ for (const p of this.pending.values())
36
+ p.reject(new Error('child process exited'));
37
+ this.pending.clear();
38
+ });
39
+ await this.request('initialize', {
40
+ protocolVersion: '2024-11-05',
41
+ capabilities: {},
42
+ clientInfo: { name: 'folderforge', version: '0.1.0' },
43
+ });
44
+ this.notify('notifications/initialized', {});
45
+ this.initialized = true;
46
+ }
47
+ onData(chunk) {
48
+ this.buffer += chunk.toString('utf8');
49
+ let idx;
50
+ while ((idx = this.buffer.indexOf('\n')) >= 0) {
51
+ const line = this.buffer.slice(0, idx).trim();
52
+ this.buffer = this.buffer.slice(idx + 1);
53
+ if (!line)
54
+ continue;
55
+ try {
56
+ const msg = JSON.parse(line);
57
+ if (typeof msg.id === 'number' && this.pending.has(msg.id)) {
58
+ const p = this.pending.get(msg.id);
59
+ this.pending.delete(msg.id);
60
+ if (msg.error)
61
+ p.reject(new Error(msg.error.message));
62
+ else
63
+ p.resolve(msg.result);
64
+ }
65
+ }
66
+ catch {
67
+ // ignore non-JSON lines
68
+ }
69
+ }
70
+ }
71
+ notify(method, params) {
72
+ if (!this.child)
73
+ return;
74
+ this.child.stdin.write(JSON.stringify({ jsonrpc: '2.0', method, params }) + '\n');
75
+ }
76
+ request(method, params, timeoutMs = 30000) {
77
+ if (!this.child)
78
+ return Promise.reject(new Error('child not started'));
79
+ const id = this.nextId++;
80
+ const payload = JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n';
81
+ return new Promise((resolve, reject) => {
82
+ const timer = setTimeout(() => {
83
+ this.pending.delete(id);
84
+ reject(new Error(`child request timed out: ${method}`));
85
+ }, timeoutMs);
86
+ this.pending.set(id, {
87
+ resolve: (v) => {
88
+ clearTimeout(timer);
89
+ resolve(v);
90
+ },
91
+ reject: (e) => {
92
+ clearTimeout(timer);
93
+ reject(e);
94
+ },
95
+ });
96
+ this.child.stdin.write(payload);
97
+ });
98
+ }
99
+ async listTools() {
100
+ const res = (await this.request('tools/list', {}));
101
+ return res.tools ?? [];
102
+ }
103
+ async callTool(name, args) {
104
+ return this.request('tools/call', { name, arguments: args });
105
+ }
106
+ isReady() {
107
+ return this.initialized && this.child !== null;
108
+ }
109
+ stop() {
110
+ this.child?.kill('SIGTERM');
111
+ this.child = null;
112
+ this.initialized = false;
113
+ }
114
+ }
@@ -0,0 +1,66 @@
1
+ import { StdioChildClient } from './client.js';
2
+ import { logger } from '../../core/logger.js';
3
+ /**
4
+ * Lazily spawns and manages child MCP servers (Serena, Playwright, etc.).
5
+ * Adapters only start on first use to avoid paying their cost upfront.
6
+ */
7
+ export class ChildMcpRegistry {
8
+ entries = new Map();
9
+ constructor(config) {
10
+ const map = [
11
+ ['serena', config.serena],
12
+ ['playwright', config.playwright],
13
+ ['desktopCommander', config.desktopCommander],
14
+ ];
15
+ for (const [name, def] of map) {
16
+ if (def) {
17
+ this.entries.set(name, { name, def, client: null, lazyStarted: false });
18
+ }
19
+ }
20
+ }
21
+ isEnabled(name) {
22
+ return this.entries.get(name)?.def.enabled ?? false;
23
+ }
24
+ async ensure(name) {
25
+ const entry = this.entries.get(name);
26
+ if (!entry)
27
+ throw new Error(`Adapter not configured: ${name}`);
28
+ if (!entry.def.enabled)
29
+ throw new Error(`Adapter disabled: ${name}`);
30
+ if (!entry.client) {
31
+ entry.client = new StdioChildClient(entry.def.command, entry.def.args, entry.def.env ?? {});
32
+ }
33
+ if (!entry.client.isReady()) {
34
+ logger.info({ adapter: name }, 'Starting child MCP adapter');
35
+ await entry.client.start();
36
+ entry.lazyStarted = true;
37
+ }
38
+ return entry.client;
39
+ }
40
+ async health(name) {
41
+ const entry = this.entries.get(name);
42
+ if (!entry)
43
+ return { enabled: false, ready: false };
44
+ if (!entry.def.enabled)
45
+ return { enabled: false, ready: false };
46
+ try {
47
+ const client = await this.ensure(name);
48
+ await client.listTools();
49
+ return { enabled: true, ready: true };
50
+ }
51
+ catch (err) {
52
+ return { enabled: true, ready: false, error: String(err) };
53
+ }
54
+ }
55
+ status() {
56
+ return [...this.entries.values()].map((e) => ({
57
+ name: e.name,
58
+ enabled: e.def.enabled,
59
+ started: e.lazyStarted && (e.client?.isReady() ?? false),
60
+ }));
61
+ }
62
+ stopAll() {
63
+ for (const e of this.entries.values())
64
+ e.client?.stop();
65
+ }
66
+ }
@@ -0,0 +1,45 @@
1
+ import { appendFileSync, mkdirSync, existsSync, readFileSync } from 'node:fs';
2
+ import { join, dirname } from 'node:path';
3
+ import { logger } from '../core/logger.js';
4
+ /**
5
+ * Append-only JSONL audit log, also kept in a ring buffer for fast recent reads.
6
+ */
7
+ export class AuditLog {
8
+ buffer = [];
9
+ maxBuffer = 500;
10
+ filePath;
11
+ constructor(projectRoot) {
12
+ this.filePath = join(projectRoot, '.folderforge', 'audit', 'audit.jsonl');
13
+ try {
14
+ mkdirSync(dirname(this.filePath), { recursive: true });
15
+ }
16
+ catch (err) {
17
+ logger.warn({ err: String(err) }, 'Could not create audit directory');
18
+ }
19
+ }
20
+ record(event) {
21
+ const full = { ts: new Date().toISOString(), ...event };
22
+ this.buffer.push(full);
23
+ if (this.buffer.length > this.maxBuffer)
24
+ this.buffer.shift();
25
+ try {
26
+ appendFileSync(this.filePath, JSON.stringify(full) + '\n', 'utf8');
27
+ }
28
+ catch (err) {
29
+ logger.warn({ err: String(err) }, 'Failed to append audit event');
30
+ }
31
+ return full;
32
+ }
33
+ recent(limit = 50) {
34
+ return this.buffer.slice(-limit).reverse();
35
+ }
36
+ exportPath() {
37
+ return this.filePath;
38
+ }
39
+ exportRaw() {
40
+ if (existsSync(this.filePath)) {
41
+ return readFileSync(this.filePath, 'utf8');
42
+ }
43
+ return this.buffer.map((e) => JSON.stringify(e)).join('\n');
44
+ }
45
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,211 @@
1
+ import { readFileSync, existsSync } from 'node:fs';
2
+ import { resolve, isAbsolute } from 'node:path';
3
+ import { parse as parseYaml } from 'yaml';
4
+ import { logger } from './logger.js';
5
+ const DEFAULT_BLOCKED = [
6
+ 'rm -rf /',
7
+ 'sudo rm',
8
+ 'mkfs',
9
+ 'dd if=',
10
+ 'chmod -R 777 /',
11
+ 'chown -R',
12
+ 'curl * | bash',
13
+ 'wget * | sh',
14
+ 'git reset --hard',
15
+ 'git push --force',
16
+ 'docker system prune',
17
+ 'kubectl delete',
18
+ 'terraform apply',
19
+ ];
20
+ const DEFAULT_DENIED_GLOBS = [
21
+ '**/.env',
22
+ '**/.env.*',
23
+ '**/id_rsa',
24
+ '**/id_ed25519',
25
+ '**/*.pem',
26
+ '**/*.key',
27
+ '**/node_modules/**',
28
+ '**/.git/objects/**',
29
+ ];
30
+ export function defaultConfig(projectRoot) {
31
+ return {
32
+ server: {
33
+ name: 'folderforge',
34
+ transport: 'stdio',
35
+ http: { host: '127.0.0.1', port: 7331 },
36
+ dashboard: { host: '127.0.0.1', port: 7332 },
37
+ },
38
+ workspace: {
39
+ defaultProject: projectRoot,
40
+ allowedDirectories: [projectRoot],
41
+ deniedGlobs: [...DEFAULT_DENIED_GLOBS],
42
+ },
43
+ policy: {
44
+ defaultMode: 'safe',
45
+ requireApproval: [
46
+ 'git_push',
47
+ 'git_commit',
48
+ 'file_delete',
49
+ 'db_write',
50
+ 'shell_high_risk',
51
+ 'docker_prune',
52
+ ],
53
+ blockedCommands: [...DEFAULT_BLOCKED],
54
+ },
55
+ terminal: {
56
+ shell: process.platform === 'win32' ? 'cmd.exe' : '/bin/bash',
57
+ defaultTimeoutMs: 120000,
58
+ maxOutputBytes: 200000,
59
+ envPolicy: 'redact',
60
+ },
61
+ git: {
62
+ allowCommit: 'approval',
63
+ allowPush: 'approval',
64
+ allowResetHard: false,
65
+ },
66
+ rateLimit: {
67
+ enabled: true,
68
+ // Generous default: 60 calls / 10s window keeps interactive agents fast
69
+ // while stopping runaway loops. No daily quota by default.
70
+ default: { maxCalls: 60, windowMs: 10_000 },
71
+ overrides: {
72
+ // Mutating/expensive actions get tighter limits.
73
+ shell_exec: { maxCalls: 20, windowMs: 10_000, dailyQuota: 1000 },
74
+ git_commit: { maxCalls: 10, windowMs: 60_000, dailyQuota: 200 },
75
+ git_push: { maxCalls: 5, windowMs: 60_000, dailyQuota: 50 },
76
+ file_delete: { maxCalls: 20, windowMs: 60_000, dailyQuota: 500 },
77
+ db_write: { maxCalls: 20, windowMs: 60_000, dailyQuota: 500 },
78
+ },
79
+ },
80
+ secretScan: {
81
+ entropyEnabled: true,
82
+ minEntropy: 4.0,
83
+ minLength: 20,
84
+ },
85
+ adapters: {
86
+ serena: { enabled: false, command: 'serena', args: [] },
87
+ playwright: { enabled: false, command: 'npx', args: ['-y', '@playwright/mcp@latest'] },
88
+ desktopCommander: { enabled: false, command: 'npx', args: ['-y', '@wonderwhy-er/desktop-commander@latest'] },
89
+ },
90
+ lsp: {
91
+ enabled: true,
92
+ requestTimeoutMs: 15000,
93
+ },
94
+ };
95
+ }
96
+ function deepMerge(base, override) {
97
+ if (override === undefined || override === null)
98
+ return base;
99
+ if (Array.isArray(base) || Array.isArray(override)) {
100
+ return (override ?? base);
101
+ }
102
+ if (typeof base === 'object' && typeof override === 'object') {
103
+ const out = { ...base };
104
+ for (const [k, v] of Object.entries(override)) {
105
+ const bv = base[k];
106
+ if (v && typeof v === 'object' && !Array.isArray(v) && bv && typeof bv === 'object') {
107
+ out[k] = deepMerge(bv, v);
108
+ }
109
+ else if (v !== undefined) {
110
+ out[k] = v;
111
+ }
112
+ }
113
+ return out;
114
+ }
115
+ return (override ?? base);
116
+ }
117
+ export function loadConfig(opts = {}) {
118
+ const projectRoot = resolve(opts.projectRoot ?? process.cwd());
119
+ let cfg = defaultConfig(projectRoot);
120
+ const candidatePaths = [
121
+ opts.configPath,
122
+ process.env.FOLDERFORGE_CONFIG,
123
+ resolve(projectRoot, 'folderforge.yaml'),
124
+ resolve(projectRoot, '.folderforge.yaml'),
125
+ resolve(projectRoot, '.folderforge/config.yaml'),
126
+ ].filter((p) => Boolean(p));
127
+ for (const p of candidatePaths) {
128
+ const abs = isAbsolute(p) ? p : resolve(projectRoot, p);
129
+ if (existsSync(abs)) {
130
+ try {
131
+ const raw = readFileSync(abs, 'utf8');
132
+ const parsed = parseYaml(raw);
133
+ cfg = deepMerge(cfg, parsed);
134
+ logger.info({ configPath: abs }, 'Loaded config file');
135
+ }
136
+ catch (err) {
137
+ logger.warn({ configPath: abs, err: String(err) }, 'Failed to parse config; using defaults');
138
+ }
139
+ break;
140
+ }
141
+ }
142
+ // Normalize allowed dirs to absolute.
143
+ cfg.workspace.allowedDirectories = cfg.workspace.allowedDirectories.map((d) => isAbsolute(d) ? resolve(d) : resolve(projectRoot, d));
144
+ if (cfg.workspace.defaultProject) {
145
+ cfg.workspace.defaultProject = resolve(cfg.workspace.defaultProject);
146
+ }
147
+ validateConfig(cfg);
148
+ return cfg;
149
+ }
150
+ /**
151
+ * Validate a loaded config and throw a single, human-readable error listing
152
+ * every problem found. Catches the common foot-guns (bad enums, negative
153
+ * limits, empty allowlists) early instead of failing deep inside a handler.
154
+ */
155
+ export function validateConfig(cfg) {
156
+ const errors = [];
157
+ const modes = ['readonly', 'safe', 'dev', 'danger'];
158
+ if (!modes.includes(cfg.policy.defaultMode)) {
159
+ errors.push(`policy.defaultMode must be one of ${modes.join(', ')} (got "${cfg.policy.defaultMode}")`);
160
+ }
161
+ if (!['stdio', 'http'].includes(cfg.server.transport)) {
162
+ errors.push(`server.transport must be "stdio" or "http" (got "${cfg.server.transport}")`);
163
+ }
164
+ if (cfg.server.http.port <= 0 || cfg.server.http.port > 65535) {
165
+ errors.push(`server.http.port must be 1-65535 (got ${cfg.server.http.port})`);
166
+ }
167
+ if (cfg.server.dashboard.port <= 0 || cfg.server.dashboard.port > 65535) {
168
+ errors.push(`server.dashboard.port must be 1-65535 (got ${cfg.server.dashboard.port})`);
169
+ }
170
+ if (!cfg.workspace.allowedDirectories.length) {
171
+ errors.push('workspace.allowedDirectories must list at least one directory');
172
+ }
173
+ if (cfg.terminal.maxOutputBytes <= 0) {
174
+ errors.push(`terminal.maxOutputBytes must be > 0 (got ${cfg.terminal.maxOutputBytes})`);
175
+ }
176
+ if (cfg.terminal.defaultTimeoutMs <= 0) {
177
+ errors.push(`terminal.defaultTimeoutMs must be > 0 (got ${cfg.terminal.defaultTimeoutMs})`);
178
+ }
179
+ if (!['redact', 'passthrough'].includes(cfg.terminal.envPolicy)) {
180
+ errors.push(`terminal.envPolicy must be "redact" or "passthrough" (got "${cfg.terminal.envPolicy}")`);
181
+ }
182
+ if (cfg.rateLimit.enabled) {
183
+ const rules = [
184
+ ['rateLimit.default', cfg.rateLimit.default],
185
+ ...Object.entries(cfg.rateLimit.overrides).map(([k, v]) => [`rateLimit.overrides.${k}`, v]),
186
+ ];
187
+ for (const [label, rule] of rules) {
188
+ if (rule.maxCalls <= 0)
189
+ errors.push(`${label}.maxCalls must be > 0 (got ${rule.maxCalls})`);
190
+ if (rule.windowMs <= 0)
191
+ errors.push(`${label}.windowMs must be > 0 (got ${rule.windowMs})`);
192
+ if (rule.dailyQuota !== undefined && rule.dailyQuota <= 0) {
193
+ errors.push(`${label}.dailyQuota must be > 0 when set (got ${rule.dailyQuota})`);
194
+ }
195
+ }
196
+ }
197
+ if (cfg.secretScan.entropyEnabled) {
198
+ if (cfg.secretScan.minEntropy <= 0) {
199
+ errors.push(`secretScan.minEntropy must be > 0 (got ${cfg.secretScan.minEntropy})`);
200
+ }
201
+ if (cfg.secretScan.minLength <= 0) {
202
+ errors.push(`secretScan.minLength must be > 0 (got ${cfg.secretScan.minLength})`);
203
+ }
204
+ }
205
+ if (cfg.lsp && cfg.lsp.enabled && cfg.lsp.requestTimeoutMs <= 0) {
206
+ errors.push(`lsp.requestTimeoutMs must be > 0 (got ${cfg.lsp.requestTimeoutMs})`);
207
+ }
208
+ if (errors.length) {
209
+ throw new Error(`Invalid FolderForge config:\n - ${errors.join('\n - ')}`);
210
+ }
211
+ }
@@ -0,0 +1,51 @@
1
+ import { PolicyEngine } from '../policy/policy-engine.js';
2
+ import { RateLimiter } from '../policy/rate-limiter.js';
3
+ import { AuditLog } from '../audit/audit-log.js';
4
+ import { WorkspaceManager } from '../workspace/workspace-manager.js';
5
+ import { ProcessManager } from '../managers/process-manager.js';
6
+ import { ChildMcpRegistry } from '../adapters/child-mcp/registry.js';
7
+ import { DbManager } from '../managers/db-manager.js';
8
+ import { LspManager } from '../managers/lsp-manager.js';
9
+ /**
10
+ * Dependency container shared by every tool handler.
11
+ */
12
+ export class Container {
13
+ config;
14
+ policy;
15
+ rateLimiter;
16
+ audit;
17
+ workspace;
18
+ processes;
19
+ adapters;
20
+ db;
21
+ lsp;
22
+ /**
23
+ * The tool registry. Assigned by `buildRegistry` right after construction so
24
+ * that routing tools (e.g. `workspace_route`) can adjust the active tool set.
25
+ */
26
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
27
+ registry = null;
28
+ constructor(config) {
29
+ this.config = config;
30
+ this.policy = new PolicyEngine(config);
31
+ this.rateLimiter = new RateLimiter(config.rateLimit);
32
+ this.workspace = new WorkspaceManager(config.workspace.allowedDirectories);
33
+ this.audit = new AuditLog(config.workspace.defaultProject);
34
+ this.processes = new ProcessManager();
35
+ this.adapters = new ChildMcpRegistry(config.adapters);
36
+ this.db = new DbManager();
37
+ this.lsp = new LspManager(config.lsp);
38
+ // Auto-activate default project if it exists.
39
+ if (config.workspace.defaultProject) {
40
+ try {
41
+ this.workspace.activate(config.workspace.defaultProject);
42
+ }
43
+ catch {
44
+ // Not fatal; the client can call workspace_activate later.
45
+ }
46
+ }
47
+ }
48
+ projectRoot() {
49
+ return this.workspace.projectRoot() ?? this.config.workspace.defaultProject;
50
+ }
51
+ }