@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.
- package/README.md +181 -0
- package/dist/adapters/child-mcp/client.js +114 -0
- package/dist/adapters/child-mcp/registry.js +66 -0
- package/dist/audit/audit-log.js +45 -0
- package/dist/audit/event-types.js +1 -0
- package/dist/core/config.js +211 -0
- package/dist/core/container.js +51 -0
- package/dist/core/errors.js +37 -0
- package/dist/core/logger.js +8 -0
- package/dist/core/types.js +4 -0
- package/dist/dashboard/server.js +191 -0
- package/dist/lsp/protocol.js +116 -0
- package/dist/main.js +190 -0
- package/dist/managers/db-manager.js +161 -0
- package/dist/managers/lsp-manager.js +269 -0
- package/dist/managers/process-manager.js +140 -0
- package/dist/policy/approvals.js +143 -0
- package/dist/policy/command-policy.js +99 -0
- package/dist/policy/glob-match.js +61 -0
- package/dist/policy/path-policy.js +73 -0
- package/dist/policy/policy-engine.js +156 -0
- package/dist/policy/rate-limiter.js +96 -0
- package/dist/policy/risk.js +112 -0
- package/dist/policy/secret-policy.js +132 -0
- package/dist/server/mcp-server.js +144 -0
- package/dist/server/transports/http.js +133 -0
- package/dist/server/transports/stdio.js +14 -0
- package/dist/tools/adapter-tools.js +62 -0
- package/dist/tools/browser-tools.js +76 -0
- package/dist/tools/build-tools.js +78 -0
- package/dist/tools/code-tools.js +250 -0
- package/dist/tools/coverage-tools.js +135 -0
- package/dist/tools/db-tools.js +130 -0
- package/dist/tools/diff-util.js +45 -0
- package/dist/tools/error-parser.js +57 -0
- package/dist/tools/file-tools.js +319 -0
- package/dist/tools/format-tools.js +118 -0
- package/dist/tools/git-tools.js +371 -0
- package/dist/tools/index.js +63 -0
- package/dist/tools/memory-tools.js +54 -0
- package/dist/tools/output-schemas.js +100 -0
- package/dist/tools/pagination.js +92 -0
- package/dist/tools/pkg-tools.js +260 -0
- package/dist/tools/process-tools.js +128 -0
- package/dist/tools/registry.js +194 -0
- package/dist/tools/schema-lock.js +152 -0
- package/dist/tools/search-tools.js +176 -0
- package/dist/tools/security-tools.js +147 -0
- package/dist/tools/terminal-tools.js +57 -0
- package/dist/tools/workspace-tools.js +186 -0
- package/dist/workspace/memory-store.js +67 -0
- package/dist/workspace/onboarding.js +46 -0
- package/dist/workspace/project-detector.js +95 -0
- package/dist/workspace/workspace-manager.js +106 -0
- package/docs/adapters.md +76 -0
- package/docs/architecture.md +66 -0
- package/docs/roadmap.md +172 -0
- package/docs/security.md +94 -0
- package/docs/tools.md +129 -0
- package/examples/claude-desktop.json +18 -0
- package/examples/codex.toml +18 -0
- package/examples/config.basic.yaml +37 -0
- package/examples/config.full.yaml +120 -0
- 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
|
+
}
|