@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
@@ -0,0 +1,143 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { mkdirSync, readFileSync, existsSync, appendFileSync, writeFileSync, renameSync } from 'node:fs';
3
+ import { dirname } from 'node:path';
4
+ import { logger } from '../core/logger.js';
5
+ /**
6
+ * Approval queue. The dashboard reads/resolves these.
7
+ *
8
+ * Session-scoped approvals remember the tool name so repeated calls pass within
9
+ * the same session. When constructed with a `persistPath`, every state change is
10
+ * appended to an append-only JSONL log and replayed on startup so pending and
11
+ * resolved approvals survive a restart.
12
+ */
13
+ export class ApprovalEngine {
14
+ requests = new Map();
15
+ sessionAllowed = new Set();
16
+ persistPath;
17
+ restoreSession;
18
+ constructor(opts = {}) {
19
+ this.persistPath = opts.persistPath;
20
+ this.restoreSession = opts.restoreSession ?? false;
21
+ if (this.persistPath)
22
+ this.load();
23
+ }
24
+ create(tool, args, risk, reason) {
25
+ const req = {
26
+ id: `appr_${randomUUID().slice(0, 8)}`,
27
+ tool,
28
+ args,
29
+ risk,
30
+ reason,
31
+ state: 'pending',
32
+ createdAt: Date.now(),
33
+ scope: 'once',
34
+ };
35
+ this.requests.set(req.id, req);
36
+ this.append(req);
37
+ return req;
38
+ }
39
+ isSessionAllowed(tool) {
40
+ return this.sessionAllowed.has(tool);
41
+ }
42
+ approve(id, scope = 'once') {
43
+ const req = this.requests.get(id);
44
+ if (!req || req.state !== 'pending')
45
+ return req;
46
+ req.state = 'approved';
47
+ req.scope = scope;
48
+ req.resolvedAt = Date.now();
49
+ if (scope === 'session')
50
+ this.sessionAllowed.add(req.tool);
51
+ this.append(req);
52
+ return req;
53
+ }
54
+ deny(id) {
55
+ const req = this.requests.get(id);
56
+ if (!req || req.state !== 'pending')
57
+ return req;
58
+ req.state = 'denied';
59
+ req.resolvedAt = Date.now();
60
+ this.append(req);
61
+ return req;
62
+ }
63
+ get(id) {
64
+ return this.requests.get(id);
65
+ }
66
+ pending() {
67
+ return [...this.requests.values()].filter((r) => r.state === 'pending');
68
+ }
69
+ all() {
70
+ return [...this.requests.values()].sort((a, b) => b.createdAt - a.createdAt);
71
+ }
72
+ /**
73
+ * Replay the persisted JSONL log. Each line is the latest snapshot of one
74
+ * request keyed by id, so later lines overwrite earlier ones. After loading
75
+ * we compact the file to one line per request to keep it from growing forever.
76
+ */
77
+ load() {
78
+ const path = this.persistPath;
79
+ if (!path || !existsSync(path))
80
+ return;
81
+ let lines;
82
+ try {
83
+ lines = readFileSync(path, 'utf8').split('\n');
84
+ }
85
+ catch (err) {
86
+ logger.warn({ path, err: String(err) }, 'Failed to read approvals store; starting empty');
87
+ return;
88
+ }
89
+ let loaded = 0;
90
+ for (const line of lines) {
91
+ const trimmed = line.trim();
92
+ if (!trimmed)
93
+ continue;
94
+ try {
95
+ const req = JSON.parse(trimmed);
96
+ if (req && typeof req.id === 'string') {
97
+ this.requests.set(req.id, req);
98
+ loaded++;
99
+ }
100
+ }
101
+ catch {
102
+ // Skip corrupt lines rather than failing startup.
103
+ }
104
+ }
105
+ if (this.restoreSession) {
106
+ for (const req of this.requests.values()) {
107
+ if (req.state === 'approved' && req.scope === 'session') {
108
+ this.sessionAllowed.add(req.tool);
109
+ }
110
+ }
111
+ }
112
+ logger.info({ path, loaded }, 'Loaded persisted approvals');
113
+ this.compact();
114
+ }
115
+ /** Append the current snapshot of one request to the JSONL log. */
116
+ append(req) {
117
+ const path = this.persistPath;
118
+ if (!path)
119
+ return;
120
+ try {
121
+ mkdirSync(dirname(path), { recursive: true });
122
+ appendFileSync(path, JSON.stringify(req) + '\n', 'utf8');
123
+ }
124
+ catch (err) {
125
+ logger.warn({ path, id: req.id, err: String(err) }, 'Failed to persist approval');
126
+ }
127
+ }
128
+ /** Rewrite the log with exactly one current line per request (atomic). */
129
+ compact() {
130
+ const path = this.persistPath;
131
+ if (!path)
132
+ return;
133
+ try {
134
+ const body = [...this.requests.values()].map((r) => JSON.stringify(r)).join('\n');
135
+ const tmp = `${path}.tmp`;
136
+ writeFileSync(tmp, body ? body + '\n' : '', 'utf8');
137
+ renameSync(tmp, path);
138
+ }
139
+ catch (err) {
140
+ logger.warn({ path, err: String(err) }, 'Failed to compact approvals store');
141
+ }
142
+ }
143
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Patterns that are always blocked (CRITICAL), regardless of policy mode.
3
+ */
4
+ const CRITICAL_PATTERNS = [
5
+ /\brm\s+-rf?\s+\/(?:\s|$)/,
6
+ /\brm\s+-rf?\s+~(?:\/|\s|$)/,
7
+ /\bsudo\b/,
8
+ /\bmkfs\b/,
9
+ /\bdd\s+if=/,
10
+ /\bchmod\s+-R\s+777\s+\//,
11
+ /\bchown\s+-R\b/,
12
+ /\bcurl\b[^|]*\|\s*(?:bash|sh)\b/,
13
+ /\bwget\b[^|]*\|\s*(?:bash|sh)\b/,
14
+ /\bgit\s+reset\s+--hard\b/,
15
+ /\bgit\s+clean\s+-[a-z]*f/,
16
+ /\bgit\s+push\b[^&|;]*--force\b/,
17
+ /\bdocker\s+system\s+prune\b/,
18
+ /\bkubectl\s+delete\b/,
19
+ /\bterraform\s+apply\b/,
20
+ /\bmv\s+\S+\s+\/dev\/null\b/,
21
+ /:\(\)\s*\{.*\|.*&\s*\}\s*;/, // fork bomb
22
+ ];
23
+ /**
24
+ * Patterns considered HIGH risk (require approval).
25
+ */
26
+ const HIGH_PATTERNS = [
27
+ /\bgit\s+push\b/,
28
+ /\bgit\s+reset\b/,
29
+ /\bdocker\s+compose\s+down\b/,
30
+ /\bdocker\s+rm\b/,
31
+ /\bnpm\s+publish\b/,
32
+ /\brm\s+-rf?\b/,
33
+ ];
34
+ /**
35
+ * Patterns considered MEDIUM risk (allowed in safe/dev, audited).
36
+ */
37
+ const MEDIUM_PATTERNS = [
38
+ /\bnpm\s+(install|i|ci|add)\b/,
39
+ /\bpnpm\s+(install|add)\b/,
40
+ /\byarn\s+(add|install)\b/,
41
+ /\bpip\s+install\b/,
42
+ /\bdocker\s+compose\s+up\b/,
43
+ /\bmake\b/,
44
+ /\b(npm|pnpm|yarn)\s+run\s+build\b/,
45
+ ];
46
+ export class CommandPolicy {
47
+ blocked;
48
+ constructor(blockedCommands) {
49
+ this.blocked = blockedCommands;
50
+ }
51
+ /** Substring/glob-ish blocklist from config, on top of the built-in regex set. */
52
+ matchesConfigBlocklist(command) {
53
+ const cmd = command.toLowerCase();
54
+ for (const entry of this.blocked) {
55
+ const pat = entry.toLowerCase();
56
+ if (pat.includes('*')) {
57
+ // very loose wildcard: split on '*' and check ordered substrings
58
+ const parts = pat.split('*').filter(Boolean);
59
+ let idx = 0;
60
+ let ok = true;
61
+ for (const p of parts) {
62
+ const found = cmd.indexOf(p.trim(), idx);
63
+ if (found === -1) {
64
+ ok = false;
65
+ break;
66
+ }
67
+ idx = found + p.length;
68
+ }
69
+ if (ok)
70
+ return entry;
71
+ }
72
+ else if (cmd.includes(pat)) {
73
+ return entry;
74
+ }
75
+ }
76
+ return undefined;
77
+ }
78
+ classify(command) {
79
+ const trimmed = command.trim();
80
+ for (const re of CRITICAL_PATTERNS) {
81
+ if (re.test(trimmed)) {
82
+ return { risk: 'CRITICAL', blockedReason: `Matches destructive pattern: ${re}`, matched: re.source };
83
+ }
84
+ }
85
+ const cfgHit = this.matchesConfigBlocklist(trimmed);
86
+ if (cfgHit) {
87
+ return { risk: 'CRITICAL', blockedReason: `Matches blocked command rule: ${cfgHit}`, matched: cfgHit };
88
+ }
89
+ for (const re of HIGH_PATTERNS) {
90
+ if (re.test(trimmed))
91
+ return { risk: 'HIGH', matched: re.source };
92
+ }
93
+ for (const re of MEDIUM_PATTERNS) {
94
+ if (re.test(trimmed))
95
+ return { risk: 'MEDIUM', matched: re.source };
96
+ }
97
+ return { risk: 'LOW' };
98
+ }
99
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Minimal, dependency-free glob matcher supporting:
3
+ * * -> any chars except '/'
4
+ * ** -> any chars including '/'
5
+ * ? -> single char except '/'
6
+ * {a,b} -> alternation
7
+ * . -> literal dot
8
+ * Patterns are matched against forward-slash paths.
9
+ */
10
+ function globToRegExp(glob) {
11
+ let re = '';
12
+ for (let i = 0; i < glob.length; i++) {
13
+ const c = glob[i];
14
+ if (c === '*') {
15
+ if (glob[i + 1] === '*') {
16
+ // ** (optionally followed by /)
17
+ if (glob[i + 2] === '/') {
18
+ re += '(?:.*/)?';
19
+ i += 2;
20
+ }
21
+ else {
22
+ re += '.*';
23
+ i += 1;
24
+ }
25
+ }
26
+ else {
27
+ re += '[^/]*';
28
+ }
29
+ }
30
+ else if (c === '?') {
31
+ re += '[^/]';
32
+ }
33
+ else if (c === '{') {
34
+ const end = glob.indexOf('}', i);
35
+ if (end > -1) {
36
+ const parts = glob.slice(i + 1, end).split(',').map(escapeLiteral);
37
+ re += `(?:${parts.join('|')})`;
38
+ i = end;
39
+ }
40
+ else {
41
+ re += '\\{';
42
+ }
43
+ }
44
+ else {
45
+ re += escapeLiteral(c ?? '');
46
+ }
47
+ }
48
+ return new RegExp(`^${re}$`);
49
+ }
50
+ function escapeLiteral(s) {
51
+ return s.replace(/[.+^${}()|[\]\\]/g, '\\$&');
52
+ }
53
+ const cache = new Map();
54
+ export default function picomatchLite(pattern, value) {
55
+ let re = cache.get(pattern);
56
+ if (!re) {
57
+ re = globToRegExp(pattern);
58
+ cache.set(pattern, re);
59
+ }
60
+ return re.test(value);
61
+ }
@@ -0,0 +1,73 @@
1
+ import { realpathSync, existsSync } from 'node:fs';
2
+ import { resolve, relative, isAbsolute, sep, dirname } from 'node:path';
3
+ import picomatchLite from './glob-match.js';
4
+ import { PathEscapeError } from '../core/errors.js';
5
+ /**
6
+ * PathPolicy enforces the workspace boundary:
7
+ * - every path must resolve inside an allowed directory
8
+ * - symlink escapes are rejected
9
+ * - denied globs (secrets, node_modules, git internals) are blocked
10
+ */
11
+ export class PathPolicy {
12
+ allowed;
13
+ deniedGlobs;
14
+ constructor(allowedDirectories, deniedGlobs) {
15
+ this.allowed = allowedDirectories.map((d) => resolve(d));
16
+ this.deniedGlobs = deniedGlobs;
17
+ }
18
+ /** Resolve a (possibly relative) path against the project root and validate it. */
19
+ resolveSafe(inputPath, projectRoot) {
20
+ const abs = isAbsolute(inputPath) ? resolve(inputPath) : resolve(projectRoot, inputPath);
21
+ this.assertInsideAllowed(abs);
22
+ this.assertNotDenied(abs, projectRoot);
23
+ this.assertNoSymlinkEscape(abs);
24
+ return abs;
25
+ }
26
+ isInsideAllowed(abs) {
27
+ return this.allowed.some((root) => {
28
+ const rel = relative(root, abs);
29
+ return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
30
+ });
31
+ }
32
+ assertInsideAllowed(abs) {
33
+ if (!this.isInsideAllowed(abs)) {
34
+ throw new PathEscapeError(`Path is outside allowed directories: ${abs}`);
35
+ }
36
+ }
37
+ isDenied(abs, projectRoot) {
38
+ const rel = relative(projectRoot, abs).split(sep).join('/');
39
+ const candidates = [rel, abs.split(sep).join('/')];
40
+ return this.deniedGlobs.some((g) => candidates.some((c) => picomatchLite(g, c)));
41
+ }
42
+ assertNotDenied(abs, projectRoot) {
43
+ if (this.isDenied(abs, projectRoot)) {
44
+ throw new PathEscapeError(`Path is denied by policy (secret/ignored): ${abs}`);
45
+ }
46
+ // Extra hard guards for sensitive home folders.
47
+ const lowered = abs.toLowerCase();
48
+ const sensitive = ['/.ssh/', '/.aws/', '/.gnupg/', '/.config/gcloud/', '/.kube/'];
49
+ if (sensitive.some((s) => lowered.includes(s))) {
50
+ throw new PathEscapeError(`Path touches a protected credential folder: ${abs}`);
51
+ }
52
+ }
53
+ /** Resolve symlinks on the nearest existing ancestor and re-check the boundary. */
54
+ assertNoSymlinkEscape(abs) {
55
+ let probe = abs;
56
+ while (!existsSync(probe)) {
57
+ const parent = dirname(probe);
58
+ if (parent === probe)
59
+ return; // reached root, nothing exists yet
60
+ probe = parent;
61
+ }
62
+ let real;
63
+ try {
64
+ real = realpathSync(probe);
65
+ }
66
+ catch {
67
+ return;
68
+ }
69
+ if (!this.isInsideAllowed(real)) {
70
+ throw new PathEscapeError(`Symlink escapes the workspace boundary: ${abs} -> ${real}`);
71
+ }
72
+ }
73
+ }
@@ -0,0 +1,156 @@
1
+ import { PathPolicy } from './path-policy.js';
2
+ import { CommandPolicy } from './command-policy.js';
3
+ import { SecretPolicy } from './secret-policy.js';
4
+ import { ApprovalEngine } from './approvals.js';
5
+ import { RISK_ORDER } from './risk.js';
6
+ import { PolicyDeniedError, ApprovalRequiredError } from '../core/errors.js';
7
+ import { resolve } from 'node:path';
8
+ /**
9
+ * The PolicyEngine ties together path, command, secret policies, the risk
10
+ * model, the policy mode, and the approval queue into a single decision point.
11
+ */
12
+ export class PolicyEngine {
13
+ config;
14
+ path;
15
+ command;
16
+ secret;
17
+ approvals;
18
+ mode;
19
+ requireApproval;
20
+ constructor(config) {
21
+ this.config = config;
22
+ this.path = new PathPolicy(config.workspace.allowedDirectories, config.workspace.deniedGlobs);
23
+ this.command = new CommandPolicy(config.policy.blockedCommands);
24
+ this.secret = new SecretPolicy(config.secretScan);
25
+ // Persist approvals under the project's .folderforge dir so pending and
26
+ // resolved requests survive restarts. Falls back to in-memory if unset.
27
+ const persistPath = config.workspace.defaultProject
28
+ ? resolve(config.workspace.defaultProject, '.folderforge', 'approvals.jsonl')
29
+ : undefined;
30
+ this.approvals = new ApprovalEngine(persistPath ? { persistPath } : {});
31
+ this.mode = config.policy.defaultMode;
32
+ this.requireApproval = new Set(config.policy.requireApproval);
33
+ }
34
+ getMode() {
35
+ return this.mode;
36
+ }
37
+ setMode(mode) {
38
+ this.mode = mode;
39
+ }
40
+ describe() {
41
+ return {
42
+ mode: this.mode,
43
+ requireApproval: [...this.requireApproval],
44
+ blockedCommands: this.config.policy.blockedCommands,
45
+ allowedDirectories: this.config.workspace.allowedDirectories,
46
+ deniedGlobs: this.config.workspace.deniedGlobs,
47
+ };
48
+ }
49
+ /**
50
+ * Evaluate whether a tool call may proceed.
51
+ * @param toolName name of the tool
52
+ * @param risk computed risk for this specific call
53
+ * @param mutates whether the call mutates state
54
+ * @param args original args (recorded on approval requests)
55
+ */
56
+ evaluate(toolName, risk, mutates, args) {
57
+ // CRITICAL is denied in every mode except an explicit danger-mode approval.
58
+ if (risk === 'CRITICAL') {
59
+ if (this.mode !== 'danger') {
60
+ return { kind: 'deny', risk, reason: `CRITICAL action blocked in ${this.mode} mode.` };
61
+ }
62
+ // A session-scoped approval (pre-granted via the dashboard/approval tools)
63
+ // lets the tool through without re-prompting on every call.
64
+ if (this.approvals.isSessionAllowed(toolName)) {
65
+ return { kind: 'allow', risk };
66
+ }
67
+ return this.toApproval(toolName, risk, args, 'CRITICAL action requires explicit approval.');
68
+ }
69
+ // readonly: any mutation is denied.
70
+ if (this.mode === 'readonly' && mutates) {
71
+ return { kind: 'deny', risk, reason: 'Workspace is in readonly mode; mutations are blocked.' };
72
+ }
73
+ // Explicit approval list or HIGH risk -> approval.
74
+ const needsApproval = this.requireApproval.has(toolName) || RISK_ORDER[risk] >= RISK_ORDER.HIGH;
75
+ if (needsApproval) {
76
+ // danger mode intentionally bypasses approval gating for non-CRITICAL
77
+ // actions: the operator has opted into an "anything goes" mode.
78
+ if (this.mode === 'danger') {
79
+ return { kind: 'allow', risk };
80
+ }
81
+ if (this.approvals.isSessionAllowed(toolName)) {
82
+ return { kind: 'allow', risk };
83
+ }
84
+ return this.toApproval(toolName, risk, args, `${toolName} (${risk}) requires approval.`);
85
+ }
86
+ // MEDIUM allowed in safe/dev/danger; audited by caller.
87
+ return { kind: 'allow', risk };
88
+ }
89
+ toApproval(toolName, risk, args, reason) {
90
+ const req = this.approvals.create(toolName, args, risk, reason);
91
+ return { kind: 'approval', risk, approvalId: req.id, reason };
92
+ }
93
+ /**
94
+ * Dry-run a tool call and explain the decision WITHOUT side effects.
95
+ *
96
+ * Unlike {@link evaluate}, this never creates an approval request, never
97
+ * records audit events, and never mutates session state. It mirrors the same
98
+ * decision logic and returns a structured, human-readable explanation so an
99
+ * agent can reason about whether a call would be allowed, denied, or gated.
100
+ */
101
+ explain(toolName, risk, mutates, args = {}) {
102
+ const factors = [];
103
+ let decision;
104
+ let reason;
105
+ if (risk === 'CRITICAL') {
106
+ factors.push('risk is CRITICAL');
107
+ if (this.mode !== 'danger') {
108
+ decision = 'deny';
109
+ reason = `CRITICAL action blocked in ${this.mode} mode.`;
110
+ }
111
+ else {
112
+ decision = 'approval';
113
+ reason = 'CRITICAL action requires explicit approval.';
114
+ factors.push('mode is danger (CRITICAL allowed only via approval)');
115
+ }
116
+ }
117
+ else if (this.mode === 'readonly' && mutates) {
118
+ factors.push('mode is readonly and the tool mutates state');
119
+ decision = 'deny';
120
+ reason = 'Workspace is in readonly mode; mutations are blocked.';
121
+ }
122
+ else {
123
+ const onApprovalList = this.requireApproval.has(toolName);
124
+ const highRisk = RISK_ORDER[risk] >= RISK_ORDER.HIGH;
125
+ if (onApprovalList)
126
+ factors.push(`tool is in requireApproval list`);
127
+ if (highRisk)
128
+ factors.push(`risk is ${risk} (>= HIGH)`);
129
+ if (onApprovalList || highRisk) {
130
+ if (this.approvals.isSessionAllowed(toolName)) {
131
+ factors.push('a session-scoped approval is already active for this tool');
132
+ decision = 'allow';
133
+ reason = `${toolName} is allowed for this session.`;
134
+ }
135
+ else {
136
+ decision = 'approval';
137
+ reason = `${toolName} (${risk}) requires approval.`;
138
+ }
139
+ }
140
+ else {
141
+ factors.push(`risk is ${risk} and allowed in ${this.mode} mode`);
142
+ decision = 'allow';
143
+ reason = `${toolName} (${risk}) is allowed.`;
144
+ }
145
+ }
146
+ return { decision, risk, reason, mode: this.mode, mutates, factors };
147
+ }
148
+ /** Convenience: throws unless the decision is allow. */
149
+ enforce(decision) {
150
+ if (decision.kind === 'deny')
151
+ throw new PolicyDeniedError(decision.reason);
152
+ if (decision.kind === 'approval') {
153
+ throw new ApprovalRequiredError(decision.reason, decision.approvalId);
154
+ }
155
+ }
156
+ }
@@ -0,0 +1,96 @@
1
+ const DAY_MS = 24 * 60 * 60 * 1000;
2
+ /**
3
+ * Sliding-window per-tool rate limiter with an optional rolling daily quota.
4
+ *
5
+ * The limiter is intentionally in-memory and per-process: it guards a single
6
+ * running server against runaway agents, not a distributed cluster. Each tool
7
+ * gets its own bucket; the effective rule is `overrides[tool]` falling back to
8
+ * `default`.
9
+ *
10
+ * Call {@link check} to evaluate without recording, or {@link hit} to evaluate
11
+ * and record an accepted call. The registry uses {@link hit} so that denied
12
+ * calls do not consume quota.
13
+ */
14
+ export class RateLimiter {
15
+ config;
16
+ now;
17
+ buckets = new Map();
18
+ constructor(config, now = Date.now) {
19
+ this.config = config;
20
+ this.now = now;
21
+ }
22
+ ruleFor(tool) {
23
+ return this.config.overrides[tool] ?? this.config.default;
24
+ }
25
+ /** Evaluate without recording a call. */
26
+ check(tool) {
27
+ return this.evaluate(tool, false);
28
+ }
29
+ /** Evaluate and, if allowed, record the call against the bucket. */
30
+ hit(tool) {
31
+ return this.evaluate(tool, true);
32
+ }
33
+ evaluate(tool, record) {
34
+ if (!this.config.enabled) {
35
+ return { allowed: true, windowCount: 0, dailyCount: 0 };
36
+ }
37
+ const rule = this.ruleFor(tool);
38
+ const t = this.now();
39
+ const bucket = this.buckets.get(tool) ?? { hits: [] };
40
+ // Drop anything older than the longest horizon we care about.
41
+ const horizon = Math.max(rule.windowMs, rule.dailyQuota ? DAY_MS : 0);
42
+ bucket.hits = bucket.hits.filter((ts) => t - ts < horizon);
43
+ const windowStart = t - rule.windowMs;
44
+ const windowHits = bucket.hits.filter((ts) => ts >= windowStart);
45
+ const dayHits = rule.dailyQuota ? bucket.hits.filter((ts) => ts >= t - DAY_MS) : [];
46
+ const windowCount = windowHits.length;
47
+ const dailyCount = dayHits.length;
48
+ if (windowCount >= rule.maxCalls) {
49
+ const oldest = windowHits[0] ?? t;
50
+ const retryAfterMs = Math.max(0, oldest + rule.windowMs - t);
51
+ return {
52
+ allowed: false,
53
+ reason: `Rate limit: ${tool} allows ${rule.maxCalls} calls per ${rule.windowMs}ms.`,
54
+ retryAfterMs,
55
+ windowCount,
56
+ dailyCount,
57
+ };
58
+ }
59
+ if (rule.dailyQuota !== undefined && dailyCount >= rule.dailyQuota) {
60
+ const oldest = dayHits[0] ?? t;
61
+ const retryAfterMs = Math.max(0, oldest + DAY_MS - t);
62
+ return {
63
+ allowed: false,
64
+ reason: `Daily quota reached: ${tool} allows ${rule.dailyQuota} calls per 24h.`,
65
+ retryAfterMs,
66
+ windowCount,
67
+ dailyCount,
68
+ };
69
+ }
70
+ if (record) {
71
+ bucket.hits.push(t);
72
+ this.buckets.set(tool, bucket);
73
+ return { allowed: true, windowCount: windowCount + 1, dailyCount: dailyCount + 1 };
74
+ }
75
+ this.buckets.set(tool, bucket);
76
+ return { allowed: true, windowCount, dailyCount };
77
+ }
78
+ /** Snapshot of current usage per tool, for the dashboard and tooling. */
79
+ snapshot() {
80
+ const t = this.now();
81
+ const out = [];
82
+ for (const [tool, bucket] of this.buckets) {
83
+ const rule = this.ruleFor(tool);
84
+ const windowCount = bucket.hits.filter((ts) => ts >= t - rule.windowMs).length;
85
+ const dailyCount = bucket.hits.filter((ts) => ts >= t - DAY_MS).length;
86
+ out.push({ tool, windowCount, dailyCount, rule });
87
+ }
88
+ return out;
89
+ }
90
+ reset(tool) {
91
+ if (tool)
92
+ this.buckets.delete(tool);
93
+ else
94
+ this.buckets.clear();
95
+ }
96
+ }