@ikarmic/forge-core 5.0.0 → 5.1.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 (135) hide show
  1. package/dist/agent/agent.d.ts +8 -0
  2. package/dist/agent/agent.js +123 -22
  3. package/dist/agent/background.d.ts +58 -0
  4. package/dist/agent/background.js +139 -0
  5. package/dist/agent/enhance-prompt.d.ts +3 -1
  6. package/dist/agent/enhance-prompt.js +6 -2
  7. package/dist/agent/goal.d.ts +54 -0
  8. package/dist/agent/goal.js +65 -0
  9. package/dist/agents/agent-files.d.ts +43 -0
  10. package/dist/agents/agent-files.js +109 -0
  11. package/dist/checkpoints/store.js +5 -1
  12. package/dist/commands/custom-commands.d.ts +9 -4
  13. package/dist/commands/custom-commands.js +13 -2
  14. package/dist/config-file.d.ts +43 -0
  15. package/dist/config-file.js +249 -5
  16. package/dist/config-merge.d.ts +13 -0
  17. package/dist/config-merge.js +67 -0
  18. package/dist/config-schema.d.ts +340 -0
  19. package/dist/config-schema.js +126 -0
  20. package/dist/config.js +2 -1
  21. package/dist/context/compaction.js +4 -0
  22. package/dist/context/mask.js +1 -1
  23. package/dist/custom-providers.d.ts +95 -0
  24. package/dist/custom-providers.js +120 -0
  25. package/dist/git/auto-commit.d.ts +2 -2
  26. package/dist/git/auto-commit.js +5 -4
  27. package/dist/github/github-tools.d.ts +2 -2
  28. package/dist/hooks/runner.js +3 -2
  29. package/dist/import/claude-code.d.ts +39 -0
  30. package/dist/import/claude-code.js +254 -0
  31. package/dist/index.d.ts +32 -4
  32. package/dist/index.js +24 -3
  33. package/dist/lsp/client.d.ts +5 -0
  34. package/dist/lsp/client.js +39 -3
  35. package/dist/mcp/index.d.ts +5 -1
  36. package/dist/mcp/index.js +3 -1
  37. package/dist/mcp/manager.d.ts +17 -3
  38. package/dist/mcp/manager.js +27 -2
  39. package/dist/mcp/marketplace.d.ts +2 -0
  40. package/dist/mcp/marketplace.js +10 -4
  41. package/dist/mcp/mcp-client.d.ts +12 -2
  42. package/dist/mcp/mcp-client.js +74 -17
  43. package/dist/mcp/mcp-config.d.ts +2 -0
  44. package/dist/mcp/mcp-config.js +1 -0
  45. package/dist/mcp/oauth.d.ts +55 -0
  46. package/dist/mcp/oauth.js +197 -0
  47. package/dist/mcp/resource-tools.d.ts +8 -0
  48. package/dist/mcp/resource-tools.js +56 -0
  49. package/dist/memory/session-digest.d.ts +12 -0
  50. package/dist/memory/session-digest.js +47 -0
  51. package/dist/memory/stale.js +2 -1
  52. package/dist/model-alias.d.ts +18 -2
  53. package/dist/model-alias.js +27 -3
  54. package/dist/model-catalog.js +0 -1
  55. package/dist/model-list.js +2 -1
  56. package/dist/model-overrides.d.ts +30 -0
  57. package/dist/model-overrides.js +48 -0
  58. package/dist/orchestrator/specialists-registry.js +2 -3
  59. package/dist/orchestrator/specialists.js +4 -4
  60. package/dist/policy/ca-certs.d.ts +8 -0
  61. package/dist/policy/ca-certs.js +30 -0
  62. package/dist/policy/proxy.d.ts +10 -3
  63. package/dist/policy/proxy.js +33 -6
  64. package/dist/pricing.js +4 -0
  65. package/dist/providers.d.ts +7 -0
  66. package/dist/providers.js +39 -5
  67. package/dist/semantic/embedder.d.ts +15 -0
  68. package/dist/semantic/embedder.js +25 -0
  69. package/dist/semantic/semantic-index.d.ts +10 -2
  70. package/dist/semantic/semantic-index.js +16 -7
  71. package/dist/sessions/session-store.js +2 -1
  72. package/dist/skills/builtin.d.ts +2 -0
  73. package/dist/skills/builtin.js +68 -0
  74. package/dist/skills/index.d.ts +2 -0
  75. package/dist/skills/index.js +2 -0
  76. package/dist/skills/loader.d.ts +10 -3
  77. package/dist/skills/loader.js +20 -7
  78. package/dist/skills/marketplace.js +2 -1
  79. package/dist/skills/remote.d.ts +14 -0
  80. package/dist/skills/remote.js +131 -0
  81. package/dist/skills/types.d.ts +10 -0
  82. package/dist/steering/extra-instructions.d.ts +8 -0
  83. package/dist/steering/extra-instructions.js +104 -0
  84. package/dist/steering/folder-instructions.d.ts +8 -0
  85. package/dist/steering/folder-instructions.js +28 -0
  86. package/dist/steering/select.js +2 -1
  87. package/dist/timed-fetch.d.ts +12 -0
  88. package/dist/timed-fetch.js +83 -0
  89. package/dist/tools/apply-patch.d.ts +52 -0
  90. package/dist/tools/apply-patch.js +197 -0
  91. package/dist/tools/ask-user.d.ts +27 -0
  92. package/dist/tools/ask-user.js +32 -0
  93. package/dist/tools/browser-tools.d.ts +6 -6
  94. package/dist/tools/browser-tools.js +3 -13
  95. package/dist/tools/codebase-search.d.ts +4 -2
  96. package/dist/tools/codebase-search.js +2 -2
  97. package/dist/tools/edit-file.js +5 -3
  98. package/dist/tools/fetch-url.d.ts +7 -0
  99. package/dist/tools/fetch-url.js +52 -19
  100. package/dist/tools/find-symbol.d.ts +2 -2
  101. package/dist/tools/ignore-rules.js +2 -1
  102. package/dist/tools/index.d.ts +5 -1
  103. package/dist/tools/index.js +10 -1
  104. package/dist/tools/memory.d.ts +6 -6
  105. package/dist/tools/notebook.d.ts +2 -2
  106. package/dist/tools/permission-rules.d.ts +32 -0
  107. package/dist/tools/permission-rules.js +182 -0
  108. package/dist/tools/read-file.d.ts +5 -0
  109. package/dist/tools/read-file.js +7 -2
  110. package/dist/tools/read-only.d.ts +1 -1
  111. package/dist/tools/read-only.js +2 -0
  112. package/dist/tools/run-command.d.ts +4 -2
  113. package/dist/tools/run-command.js +12 -8
  114. package/dist/tools/sandbox-loader.js +3 -13
  115. package/dist/tools/search-files.d.ts +8 -2
  116. package/dist/tools/search-files.js +43 -0
  117. package/dist/tools/shared.d.ts +5 -0
  118. package/dist/tools/shared.js +30 -2
  119. package/dist/tools/shell-env.d.ts +7 -0
  120. package/dist/tools/shell-env.js +9 -0
  121. package/dist/tools/sub-agent-wait.d.ts +29 -0
  122. package/dist/tools/sub-agent-wait.js +60 -0
  123. package/dist/tools/sub-agent.d.ts +50 -4
  124. package/dist/tools/sub-agent.js +210 -125
  125. package/dist/tools/text-encoding.d.ts +10 -0
  126. package/dist/tools/text-encoding.js +40 -0
  127. package/dist/tools/todo.d.ts +36 -0
  128. package/dist/tools/todo.js +42 -0
  129. package/dist/tools/write-file.js +15 -9
  130. package/dist/tools/write-tools.d.ts +12 -0
  131. package/dist/tools/write-tools.js +18 -0
  132. package/dist/types.d.ts +73 -0
  133. package/dist/worktree/worktree.d.ts +3 -0
  134. package/dist/worktree/worktree.js +22 -0
  135. package/package.json +1 -1
@@ -0,0 +1,182 @@
1
+ import path from 'node:path';
2
+ import { patchedPaths } from './apply-patch.js';
3
+ import { isOutsideRel } from './shared.js';
4
+ const ACTIONS = new Set(['allow', 'ask', 'deny']);
5
+ /** Flatten a config object into ordered rules; entries with an unknown action are dropped. */
6
+ export function compilePermissionRules(config) {
7
+ const rules = [];
8
+ if (!config || typeof config !== 'object')
9
+ return rules;
10
+ for (const [permission, value] of Object.entries(config)) {
11
+ if (typeof value === 'string') {
12
+ if (ACTIONS.has(value))
13
+ rules.push({ permission, pattern: '*', action: value });
14
+ }
15
+ else if (value && typeof value === 'object') {
16
+ for (const [pattern, action] of Object.entries(value)) {
17
+ if (ACTIONS.has(action))
18
+ rules.push({ permission, pattern, action: action });
19
+ }
20
+ }
21
+ }
22
+ return rules;
23
+ }
24
+ const ci = process.platform === 'win32' ? 'i' : '';
25
+ /**
26
+ * Wildcard match used by permission patterns: `*` and `**` match any run of characters (separators included),
27
+ * `?` one character, `{a,b}` alternatives. A trailing ` *` is optional so `git status *` also matches `git status`.
28
+ */
29
+ function patternToRegex(pattern) {
30
+ let p = pattern;
31
+ let optionalTail = false;
32
+ if (p.endsWith(' *')) {
33
+ p = p.slice(0, -2);
34
+ optionalTail = true;
35
+ }
36
+ let re = '';
37
+ for (let i = 0; i < p.length; i++) {
38
+ const c = p[i];
39
+ if (c === '*') {
40
+ while (p[i + 1] === '*')
41
+ i++;
42
+ re += '.*';
43
+ }
44
+ else if (c === '?')
45
+ re += '.';
46
+ else if (c === '{') {
47
+ const close = p.indexOf('}', i);
48
+ if (close > i) {
49
+ re += '(?:' + p.slice(i + 1, close).split(',').map(escapeRegex).join('|') + ')';
50
+ i = close;
51
+ }
52
+ else
53
+ re += '\\{';
54
+ }
55
+ else
56
+ re += escapeRegex(c);
57
+ }
58
+ return new RegExp('^' + re + (optionalTail ? '(?: .*)?' : '') + '$', ci + 's');
59
+ }
60
+ function escapeRegex(s) {
61
+ return s.replace(/[.+^${}()|[\]\\]/g, '\\$&');
62
+ }
63
+ /** The action of the last rule matching `permission` and `target`, or undefined when none does. */
64
+ export function evaluatePermission(rules, permission, target) {
65
+ const norm = target.replace(/\\/g, '/');
66
+ let result;
67
+ for (const rule of rules) {
68
+ if (rule.permission !== '*' && rule.permission !== permission)
69
+ continue;
70
+ if (patternToRegex(rule.pattern.replace(/\\/g, '/')).test(norm))
71
+ result = rule.action;
72
+ }
73
+ return result;
74
+ }
75
+ /**
76
+ * Split a shell command into the commands a permission rule must each allow: `&&`, `||`, `;`, `|` and newlines
77
+ * outside quotes separate commands; the inside of `$(...)` and backticks is added as extra commands, and an
78
+ * unquoted `>` redirect adds a `redirect:` entry, so an allow rule for `git status *` cannot cover
79
+ * `git status $(rm -rf x)` or `git status > file`.
80
+ */
81
+ export function splitShellCommands(command) {
82
+ const out = [];
83
+ let cur = '';
84
+ let quote = null;
85
+ const push = () => {
86
+ const t = cur.trim();
87
+ if (t)
88
+ out.push(t);
89
+ cur = '';
90
+ };
91
+ for (let i = 0; i < command.length; i++) {
92
+ const c = command[i];
93
+ if (quote) {
94
+ cur += c;
95
+ if (c === quote)
96
+ quote = null;
97
+ continue;
98
+ }
99
+ if (c === '"' || c === "'") {
100
+ quote = c;
101
+ cur += c;
102
+ continue;
103
+ }
104
+ if ((c === '&' && command[i + 1] === '&') || (c === '|' && command[i + 1] === '|')) {
105
+ push();
106
+ i++;
107
+ continue;
108
+ }
109
+ if (c === ';' || c === '|' || c === '\n') {
110
+ push();
111
+ continue;
112
+ }
113
+ cur += c;
114
+ }
115
+ push();
116
+ const extra = [];
117
+ for (const m of command.matchAll(/\$\(([^()]*)\)|`([^`]*)`/g)) {
118
+ for (const inner of splitShellCommands(m[1] ?? m[2] ?? ''))
119
+ extra.push(inner);
120
+ }
121
+ if (/(^|[^>&0-9])>{1,2}(?!&)/.test(stripQuoted(command)))
122
+ extra.push(`redirect: ${command.trim()}`);
123
+ return [...out, ...extra];
124
+ }
125
+ function stripQuoted(s) {
126
+ return s.replace(/"[^"]*"|'[^']*'/g, '""');
127
+ }
128
+ /** `.env` and `.env.*` hold secrets; `.env.example` and `.env.sample` are documentation. */
129
+ export function isSensitiveFile(relPath) {
130
+ const base = relPath.replace(/\\/g, '/').split('/').pop() ?? '';
131
+ if (base === '.env.example' || base === '.env.sample')
132
+ return false;
133
+ return base === '.env' || base.startsWith('.env.');
134
+ }
135
+ /** True when a token of the command points outside `cwd`: `~`, a `..` escape, or an absolute path elsewhere. */
136
+ export function touchesOutsideCwd(command, cwd) {
137
+ const root = path.resolve(cwd);
138
+ for (const raw of command.split(/\s+/)) {
139
+ const tok = raw.replace(/^["']|["']$/g, '');
140
+ if (!tok || tok.startsWith('-') || tok === '/dev/null')
141
+ continue;
142
+ if (tok === '~' || tok.startsWith('~/') || tok.startsWith('~\\'))
143
+ return true;
144
+ const absolute = tok.startsWith('/') || /^[A-Za-z]:[\\/]/.test(tok);
145
+ const escapes = tok.split(/[\\/]/).includes('..');
146
+ if (!absolute && !escapes)
147
+ continue;
148
+ const rel = path.relative(root, path.resolve(root, tok));
149
+ if (isOutsideRel(rel))
150
+ return true;
151
+ }
152
+ return false;
153
+ }
154
+ const READ_TOOLS = new Set(['read_file', 'read_image', 'list_files', 'search_files', 'list_symbols']);
155
+ const EDIT_TOOLS = new Set(['write_file', 'edit_file', 'edit_notebook', 'apply_patch']);
156
+ function relTarget(p, cwd) {
157
+ const raw = typeof p === 'string' && p ? p : '.';
158
+ const abs = path.resolve(cwd, raw);
159
+ const rel = path.relative(path.resolve(cwd), abs).replace(/\\/g, '/');
160
+ return rel === '' ? '.' : isOutsideRel(rel) ? abs.replace(/\\/g, '/') : rel;
161
+ }
162
+ /** Which permission a tool call falls under and the strings the rules are matched against; undefined = no rule applies. */
163
+ export function permissionFor(toolName, args, cwd) {
164
+ const a = (args && typeof args === 'object' ? args : {});
165
+ if (READ_TOOLS.has(toolName))
166
+ return { permission: 'read', targets: [relTarget(a.path, cwd)] };
167
+ if (toolName === 'apply_patch') {
168
+ const paths = patchedPaths(typeof a.patch === 'string' ? a.patch : '');
169
+ return { permission: 'edit', targets: (paths.length ? paths : ['.']).map((p) => relTarget(p, cwd)) };
170
+ }
171
+ if (EDIT_TOOLS.has(toolName))
172
+ return { permission: 'edit', targets: [relTarget(a.path, cwd)] };
173
+ if (toolName === 'run_command')
174
+ return { permission: 'bash', targets: splitShellCommands(typeof a.command === 'string' ? a.command : '') };
175
+ if (toolName === 'fetch_url')
176
+ return { permission: 'webfetch', targets: [typeof a.url === 'string' ? a.url : ''] };
177
+ if (toolName === 'web_search')
178
+ return { permission: 'websearch', targets: [typeof a.query === 'string' ? a.query : ''] };
179
+ if (toolName === 'sub_agent')
180
+ return { permission: 'task', targets: ['sub_agent'] };
181
+ return undefined;
182
+ }
@@ -21,6 +21,11 @@ export interface ReadFileResult {
21
21
  offset: number;
22
22
  content: string;
23
23
  truncated: boolean;
24
+ /** `AGENTS.md` files of the folders this file sits in, shown the first time a file there is read. */
25
+ folderInstructions?: Array<{
26
+ file: string;
27
+ text: string;
28
+ }>;
24
29
  }
25
30
  /** A PDF comes back whole; the agent attaches it so document-capable models read it natively. */
26
31
  export interface ReadPdfResult {
@@ -2,7 +2,9 @@ import { readFile } from 'node:fs/promises';
2
2
  import { z } from 'zod';
3
3
  import { isNotebookPath, parseNotebook, renderNotebook } from './notebook.js';
4
4
  import { isPdf, MAX_ATTACHMENT_BYTES } from '../attachments.js';
5
+ import { folderInstructionsFor } from '../steering/folder-instructions.js';
5
6
  import { resolveAndCheckPath, truncateByLines } from './shared.js';
7
+ import { decodeText } from './text-encoding.js';
6
8
  const params = z.object({
7
9
  path: z.string().describe('Path to the file to read, relative to the working directory or absolute.'),
8
10
  /** 1-based line number to start reading from. Omit to start at the beginning. */
@@ -47,22 +49,25 @@ export const readFileTool = {
47
49
  return { path: resolved, mime: 'application/pdf', bytes: raw.length, dataUrl: `data:application/pdf;base64,${raw.toString('base64')}` };
48
50
  }
49
51
  // Reject obvious binary content.
50
- if (looksBinary(raw)) {
52
+ const decoded = decodeText(raw);
53
+ if (decoded.encoding === 'utf8' && looksBinary(raw)) {
51
54
  throw new Error(`Refusing to read binary file: ${args.path} (${raw.length} bytes)`);
52
55
  }
53
- const text = raw.toString('utf8');
56
+ const text = decoded.text;
54
57
  const lines = text.split(/\r?\n/);
55
58
  const offset = args.offset ?? 1;
56
59
  const maxLines = args.max_lines ?? 2000;
57
60
  const slice = lines.slice(offset - 1, offset - 1 + maxLines);
58
61
  const endIdx = offset - 1 + maxLines;
59
62
  const truncated = endIdx < lines.length;
63
+ const folderInstructions = ctx.shownFolderInstructions ? await folderInstructionsFor(resolved, ctx.cwd, ctx.shownFolderInstructions) : undefined;
60
64
  return {
61
65
  path: resolved,
62
66
  totalLines: lines.length,
63
67
  offset,
64
68
  content: truncateByLines(slice.map((l, i) => `${String(offset + i).padStart(6)}\t${l}`).join('\n'), maxLines),
65
69
  truncated,
70
+ ...(folderInstructions ? { folderInstructions } : {}),
66
71
  };
67
72
  },
68
73
  };
@@ -3,6 +3,6 @@
3
3
  * anything. Asking before each of them is noise, so they are approved without a prompt by default;
4
4
  * edits, commands, web access and sub-agents still ask.
5
5
  */
6
- export declare const READ_ONLY_TOOLS: readonly ["read_file", "list_files", "search_files", "find_symbol", "list_symbols", "find_references", "codebase_search", "memory_read"];
6
+ export declare const READ_ONLY_TOOLS: readonly ["read_file", "list_files", "search_files", "find_symbol", "list_symbols", "find_references", "codebase_search", "memory_read", "sub_agent_wait"];
7
7
  /** The tools that run without a prompt: the user's list, or the read-only tools when none is set. */
8
8
  export declare function resolveAutoApprove(configured?: readonly string[]): string[];
@@ -12,6 +12,8 @@ export const READ_ONLY_TOOLS = [
12
12
  'find_references',
13
13
  'codebase_search',
14
14
  'memory_read',
15
+ // Only observes the sub-agents the person already let start.
16
+ 'sub_agent_wait',
15
17
  ];
16
18
  /** The tools that run without a prompt: the user's list, or the read-only tools when none is set. */
17
19
  export function resolveAutoApprove(configured) {
@@ -14,12 +14,12 @@ declare const params: z.ZodObject<{
14
14
  cwd: z.ZodOptional<z.ZodString>;
15
15
  }, "strip", z.ZodTypeAny, {
16
16
  command: string;
17
- cwd?: string | undefined;
18
17
  timeout_ms?: number | undefined;
18
+ cwd?: string | undefined;
19
19
  }, {
20
20
  command: string;
21
- cwd?: string | undefined;
22
21
  timeout_ms?: number | undefined;
22
+ cwd?: string | undefined;
23
23
  }>;
24
24
  export interface RunCommandResult {
25
25
  command: string;
@@ -31,4 +31,6 @@ export interface RunCommandResult {
31
31
  timedOut: boolean;
32
32
  }
33
33
  export declare const runCommandTool: ToolDefinition<z.infer<typeof params>, RunCommandResult>;
34
+ /** Cut a command's output to `max` characters, keeping the head and the tail. */
35
+ export declare function clampOutput(text: string, max?: number): string;
34
36
  export {};
@@ -2,6 +2,7 @@ import { spawn } from 'node:child_process';
2
2
  import { z } from 'zod';
3
3
  import { checkPolicyViolation } from './policy.js';
4
4
  import { resolveAndCheckPath } from './shared.js';
5
+ import { shellEnv } from './shell-env.js';
5
6
  /**
6
7
  * Kill a child process and its whole tree. Windows: `taskkill /T`. POSIX: the child is started as the leader of its
7
8
  * own process group (`detached`), and the signal goes to the group (`-pid`); signalling only the shell left whatever
@@ -77,8 +78,8 @@ export const runCommandTool = {
77
78
  command: args.command,
78
79
  exitCode: out.exitCode,
79
80
  signal: out.signal ?? null,
80
- stdout: clampOutput(out.stdout),
81
- stderr: clampOutput(out.stderr),
81
+ stdout: clampOutput(out.stdout, ctx.maxOutputChars),
82
+ stderr: clampOutput(out.stderr, ctx.maxOutputChars),
82
83
  durationMs: Date.now() - started,
83
84
  timedOut: out.timedOut ?? false,
84
85
  };
@@ -93,6 +94,8 @@ export const runCommandTool = {
93
94
  const child = spawn(args.command, {
94
95
  shell: true,
95
96
  cwd,
97
+ // Windows: never run a same-named program the repository ships in its root (see shell-env.ts).
98
+ env: shellEnv(process.env),
96
99
  stdio: ['ignore', 'pipe', 'pipe'],
97
100
  windowsHide: true,
98
101
  // POSIX: own process group, so a kill reaches everything the shell started (see killTree).
@@ -128,8 +131,8 @@ export const runCommandTool = {
128
131
  command: args.command,
129
132
  exitCode: code,
130
133
  signal: signal,
131
- stdout: clampOutput(stdout),
132
- stderr: clampOutput(stderr),
134
+ stdout: clampOutput(stdout, ctx.maxOutputChars),
135
+ stderr: clampOutput(stderr, ctx.maxOutputChars),
133
136
  durationMs: Date.now() - started,
134
137
  timedOut,
135
138
  });
@@ -137,11 +140,12 @@ export const runCommandTool = {
137
140
  });
138
141
  },
139
142
  };
140
- function clampOutput(text) {
141
- if (text.length <= MAX_OUTPUT_CHARS)
143
+ /** Cut a command's output to `max` characters, keeping the head and the tail. */
144
+ export function clampOutput(text, max = MAX_OUTPUT_CHARS) {
145
+ if (text.length <= max)
142
146
  return text;
143
- const half = Math.floor(MAX_OUTPUT_CHARS / 2);
147
+ const half = Math.floor(max / 2);
144
148
  return (text.slice(0, half) +
145
- `\n\n... (${text.length - MAX_OUTPUT_CHARS} characters truncated) ...\n\n` +
149
+ `\n\n... (${text.length - max} characters truncated) ...\n\n` +
146
150
  text.slice(text.length - half));
147
151
  }
@@ -1,7 +1,6 @@
1
- import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
- import path from 'node:path';
3
1
  import { globalSettingsPath, projectSettingsPath, readSettings } from '../hooks/loader.js';
4
2
  import { validateToolSandboxConfig } from '../hooks/config.js';
3
+ import { updateJsonFile } from '../config-file.js';
5
4
  const REGEX_FIELDS = ['allowedCommands', 'deniedCommands', 'allowedUrls', 'deniedUrls'];
6
5
  const PATH_FIELDS = ['allowedPaths', 'deniedPaths'];
7
6
  /** Compile a single policy's regex-string fields to RegExp, dropping any pattern that fails to compile. */
@@ -118,15 +117,6 @@ export async function loadToolSandboxConfig(cwd, opts, globalPathOverride) {
118
117
  */
119
118
  export async function saveToolSandboxConfig(sandbox, settingsPath) {
120
119
  const filePath = settingsPath ?? globalSettingsPath();
121
- let existing = {};
122
- try {
123
- const raw = await readFile(filePath, 'utf8');
124
- existing = JSON.parse(raw);
125
- }
126
- catch {
127
- // Missing or malformed: start fresh rather than failing the save.
128
- }
129
- const merged = { ...existing, toolSandbox: sandbox };
130
- await mkdir(path.dirname(filePath), { recursive: true });
131
- await writeFile(filePath, JSON.stringify(merged, null, 2) + '\n', 'utf8');
120
+ // A malformed file is refused, not replaced: it holds hooks and other settings this save does not own.
121
+ await updateJsonFile(filePath, (existing) => ({ ...existing, toolSandbox: sandbox }));
132
122
  }
@@ -12,15 +12,15 @@ declare const params: z.ZodObject<{
12
12
  }, "strip", z.ZodTypeAny, {
13
13
  pattern: string;
14
14
  path?: string | undefined;
15
- max_results?: number | undefined;
16
15
  include?: string | undefined;
16
+ max_results?: number | undefined;
17
17
  include_ignored?: boolean | undefined;
18
18
  context_lines?: number | undefined;
19
19
  }, {
20
20
  pattern: string;
21
21
  path?: string | undefined;
22
- max_results?: number | undefined;
23
22
  include?: string | undefined;
23
+ max_results?: number | undefined;
24
24
  include_ignored?: boolean | undefined;
25
25
  context_lines?: number | undefined;
26
26
  }>;
@@ -36,4 +36,10 @@ export interface SearchFilesResult {
36
36
  filesScanned: number;
37
37
  }
38
38
  export declare const searchFilesTool: ToolDefinition<z.infer<typeof params>, SearchFilesResult>;
39
+ /**
40
+ * True for a group that contains a repetition and is itself repeated (`(a+)+`, `(\w+\s?)*`, `(x*){2,}`): the shape that
41
+ * makes a backtracking engine take exponential time on a line that almost matches. JavaScript cannot stop a running
42
+ * regex, so such a pattern would freeze the editor; it is refused before it runs.
43
+ */
44
+ export declare function hasNestedRepetition(source: string): boolean;
39
45
  export {};
@@ -121,7 +121,50 @@ export const searchFilesTool = {
121
121
  return { pattern: args.pattern, matches, truncated, filesScanned };
122
122
  },
123
123
  };
124
+ /**
125
+ * True for a group that contains a repetition and is itself repeated (`(a+)+`, `(\w+\s?)*`, `(x*){2,}`): the shape that
126
+ * makes a backtracking engine take exponential time on a line that almost matches. JavaScript cannot stop a running
127
+ * regex, so such a pattern would freeze the editor; it is refused before it runs.
128
+ */
129
+ export function hasNestedRepetition(source) {
130
+ const groups = []; // per open group: does it contain a repetition?
131
+ let inClass = false;
132
+ const isRepeat = (i) => source[i] === '+' || source[i] === '*' || (source[i] === '{' && /^\{\d*,?\d*\}/.test(source.slice(i)) && !/^\{\d+\}/.test(source.slice(i)));
133
+ for (let i = 0; i < source.length; i++) {
134
+ const c = source[i];
135
+ if (c === '\\') {
136
+ i++;
137
+ continue;
138
+ }
139
+ if (inClass) {
140
+ if (c === ']')
141
+ inClass = false;
142
+ continue;
143
+ }
144
+ if (c === '[') {
145
+ inClass = true;
146
+ }
147
+ else if (c === '(') {
148
+ groups.push(false);
149
+ }
150
+ else if (c === ')') {
151
+ const inner = groups.pop() ?? false;
152
+ if (inner && isRepeat(i + 1))
153
+ return true;
154
+ // A repeated group counts as a repetition inside the group around it.
155
+ if ((inner || isRepeat(i + 1)) && groups.length)
156
+ groups[groups.length - 1] = true;
157
+ }
158
+ else if (isRepeat(i) && groups.length) {
159
+ groups[groups.length - 1] = true;
160
+ }
161
+ }
162
+ return false;
163
+ }
124
164
  function safeRegex(source) {
165
+ if (hasNestedRepetition(source)) {
166
+ throw new Error('Pattern has a repeated group that repeats again (like (a+)+), which can take exponential time on some lines. Simplify it, e.g. a+ instead of (a+)+.');
167
+ }
125
168
  try {
126
169
  return new RegExp(source, 'gm');
127
170
  }
@@ -9,6 +9,11 @@ import type { ToolContext } from '../types.js';
9
9
  export declare function resolveSafePath(inputPath: string, ctx: ToolContext, options?: {
10
10
  allowOutside?: boolean;
11
11
  }): string;
12
+ /**
13
+ * For a `path.relative(root, target)` result: does it lead out of `root`? `..` itself or `../…` does; a name that merely
14
+ * starts with two dots (`..notes.md`) does not. Use this instead of `rel.startsWith('..')`.
15
+ */
16
+ export declare function isOutsideRel(rel: string): boolean;
12
17
  /**
13
18
  * Convenience: run `resolveSafePath` then enforce the per-tool path policy
14
19
  * (allowedPaths / deniedPaths). Throws on either kind of violation. File
@@ -1,3 +1,4 @@
1
+ import { realpathSync } from 'node:fs';
1
2
  import path from 'node:path';
2
3
  import { checkPolicyViolation } from './policy.js';
3
4
  /**
@@ -12,13 +13,40 @@ export function resolveSafePath(inputPath, ctx, options = {}) {
12
13
  ? path.normalize(inputPath)
13
14
  : path.resolve(ctx.cwd, inputPath);
14
15
  if (!options.allowOutside) {
15
- const rel = path.relative(ctx.cwd, resolved);
16
- if (rel.startsWith('..') || path.isAbsolute(rel)) {
16
+ // Lexically, then for real: a symlink or junction inside the workspace can point anywhere on disk.
17
+ if (escapes(ctx.cwd, resolved) || escapes(realPath(ctx.cwd), realPath(resolved))) {
17
18
  throw new Error(`Path "${inputPath}" resolves outside of the working directory (${ctx.cwd}).`);
18
19
  }
19
20
  }
20
21
  return resolved;
21
22
  }
23
+ /**
24
+ * For a `path.relative(root, target)` result: does it lead out of `root`? `..` itself or `../…` does; a name that merely
25
+ * starts with two dots (`..notes.md`) does not. Use this instead of `rel.startsWith('..')`.
26
+ */
27
+ export function isOutsideRel(rel) {
28
+ return rel === '..' || rel.startsWith(`..${path.sep}`) || rel.startsWith('../') || path.isAbsolute(rel);
29
+ }
30
+ function escapes(root, target) {
31
+ return isOutsideRel(path.relative(root, target));
32
+ }
33
+ /** The real location of `p`: links resolved on the longest part that exists, the rest (a file not yet written) appended. */
34
+ function realPath(p) {
35
+ let existing = p;
36
+ const rest = [];
37
+ for (;;) {
38
+ try {
39
+ return path.join(realpathSync.native(existing), ...rest);
40
+ }
41
+ catch {
42
+ const parent = path.dirname(existing);
43
+ if (parent === existing)
44
+ return p;
45
+ rest.unshift(path.basename(existing));
46
+ existing = parent;
47
+ }
48
+ }
49
+ }
22
50
  /**
23
51
  * Convenience: run `resolveSafePath` then enforce the per-tool path policy
24
52
  * (allowedPaths / deniedPaths). Throws on either kind of violation. File
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Environment for a command run through a shell. On Windows, cmd.exe looks for a bare program name in the current
3
+ * directory before PATH, and Forge runs commands in the workspace: a repository could ship `npm.cmd` or `node.cmd`
4
+ * and have it run instead of the real one when the person approves "npm test". `NoDefaultCurrentDirectoryInExePath`
5
+ * tells cmd.exe to skip the current directory; an explicit `.\script.cmd` still works.
6
+ */
7
+ export declare function shellEnv(env: NodeJS.ProcessEnv, platform?: NodeJS.Platform): NodeJS.ProcessEnv;
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Environment for a command run through a shell. On Windows, cmd.exe looks for a bare program name in the current
3
+ * directory before PATH, and Forge runs commands in the workspace: a repository could ship `npm.cmd` or `node.cmd`
4
+ * and have it run instead of the real one when the person approves "npm test". `NoDefaultCurrentDirectoryInExePath`
5
+ * tells cmd.exe to skip the current directory; an explicit `.\script.cmd` still works.
6
+ */
7
+ export function shellEnv(env, platform = process.platform) {
8
+ return platform === 'win32' ? { ...env, NoDefaultCurrentDirectoryInExePath: '1' } : { ...env };
9
+ }
@@ -0,0 +1,29 @@
1
+ import { z } from 'zod';
2
+ import type { ToolDefinition } from '../types.js';
3
+ declare const params: z.ZodObject<{
4
+ ids: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
5
+ timeout_seconds: z.ZodOptional<z.ZodNumber>;
6
+ }, "strip", z.ZodTypeAny, {
7
+ ids?: string[] | undefined;
8
+ timeout_seconds?: number | undefined;
9
+ }, {
10
+ ids?: string[] | undefined;
11
+ timeout_seconds?: number | undefined;
12
+ }>;
13
+ export interface SubAgentWaitResult {
14
+ agents: Array<{
15
+ id: string;
16
+ task: string;
17
+ state: string;
18
+ finalText?: string;
19
+ error?: string;
20
+ }>;
21
+ stillRunning: number;
22
+ message?: string;
23
+ }
24
+ /**
25
+ * Collect the answers of sub-agents started with `sub_agent { background: true }`. Waits until they finish or the
26
+ * timeout passes, whichever is first; a run that is still going is reported as running and can be waited for again.
27
+ */
28
+ export declare const subAgentWaitTool: ToolDefinition<z.infer<typeof params>, SubAgentWaitResult>;
29
+ export {};
@@ -0,0 +1,60 @@
1
+ import { z } from 'zod';
2
+ const params = z.object({
3
+ ids: z
4
+ .array(z.string().min(1).max(40))
5
+ .max(20)
6
+ .optional()
7
+ .describe('Ids from sub_agent { background: true } to wait for. Omit to wait for every background agent still running.'),
8
+ timeout_seconds: z
9
+ .number()
10
+ .min(0)
11
+ .max(900)
12
+ .optional()
13
+ .describe('How long to wait before giving up and reporting what is still running. Default 300. 0 only looks.'),
14
+ });
15
+ /**
16
+ * Collect the answers of sub-agents started with `sub_agent { background: true }`. Waits until they finish or the
17
+ * timeout passes, whichever is first; a run that is still going is reported as running and can be waited for again.
18
+ */
19
+ export const subAgentWaitTool = {
20
+ name: 'sub_agent_wait',
21
+ description: 'Wait for background sub-agents (started with sub_agent { background: true }) and return their answers. A run still going when the timeout passes is reported as running.',
22
+ parameters: params,
23
+ execute: async (args, ctx) => {
24
+ const bg = ctx.background;
25
+ if (!bg)
26
+ throw new Error('Background agents are not available here.');
27
+ const valid = params.parse(args);
28
+ const unknown = (valid.ids ?? []).filter((id) => !bg.get(id));
29
+ const known = (valid.ids ?? []).filter((id) => bg.get(id));
30
+ if (valid.ids && known.length === 0) {
31
+ return { agents: [], stillRunning: 0, message: `No background agent with id ${unknown.join(', ')}.` };
32
+ }
33
+ if (!valid.ids && bg.list().filter((a) => a.state === 'running').length === 0) {
34
+ // Nothing running: report what has finished, if anything has.
35
+ const finished = bg.takeUndelivered();
36
+ return {
37
+ agents: finished.map(view),
38
+ stillRunning: 0,
39
+ ...(finished.length === 0 ? { message: 'There are no background agents running.' } : {}),
40
+ };
41
+ }
42
+ const timeoutMs = (valid.timeout_seconds ?? 300) * 1000;
43
+ const got = await bg.wait(valid.ids ? known : undefined, { timeoutMs, ...(ctx.abortSignal ? { signal: ctx.abortSignal } : {}) });
44
+ bg.markDelivered(got.map((a) => a.id));
45
+ return {
46
+ agents: got.map(view),
47
+ stillRunning: got.filter((a) => a.state === 'running').length,
48
+ ...(unknown.length ? { message: `No background agent with id ${unknown.join(', ')}.` } : {}),
49
+ };
50
+ },
51
+ };
52
+ function view(a) {
53
+ return {
54
+ id: a.id,
55
+ task: a.task,
56
+ state: a.state,
57
+ ...(a.result?.finalText ? { finalText: a.result.finalText } : {}),
58
+ ...(a.error ? { error: a.error } : {}),
59
+ };
60
+ }