@ikarmic/forge-core 5.0.0 → 5.1.1

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 (163) hide show
  1. package/README.md +1 -1
  2. package/dist/agent/agent.d.ts +8 -0
  3. package/dist/agent/agent.js +148 -24
  4. package/dist/agent/background.d.ts +58 -0
  5. package/dist/agent/background.js +139 -0
  6. package/dist/agent/enhance-prompt.d.ts +3 -1
  7. package/dist/agent/enhance-prompt.js +6 -2
  8. package/dist/agent/goal.d.ts +54 -0
  9. package/dist/agent/goal.js +65 -0
  10. package/dist/agent/prompts.d.ts +25 -1
  11. package/dist/agent/prompts.js +61 -17
  12. package/dist/agents/agent-files.d.ts +43 -0
  13. package/dist/agents/agent-files.js +109 -0
  14. package/dist/checkpoints/store.js +5 -1
  15. package/dist/commands/custom-commands.d.ts +9 -4
  16. package/dist/commands/custom-commands.js +13 -2
  17. package/dist/config-file.d.ts +43 -0
  18. package/dist/config-file.js +249 -5
  19. package/dist/config-merge.d.ts +13 -0
  20. package/dist/config-merge.js +67 -0
  21. package/dist/config-schema.d.ts +340 -0
  22. package/dist/config-schema.js +126 -0
  23. package/dist/config.js +2 -1
  24. package/dist/context/compaction.js +4 -0
  25. package/dist/context/mask.js +1 -1
  26. package/dist/custom-providers.d.ts +95 -0
  27. package/dist/custom-providers.js +120 -0
  28. package/dist/git/auto-commit.d.ts +2 -2
  29. package/dist/git/auto-commit.js +5 -4
  30. package/dist/github/github-tools.d.ts +2 -2
  31. package/dist/hooks/runner.js +3 -2
  32. package/dist/import/claude-code.d.ts +39 -0
  33. package/dist/import/claude-code.js +254 -0
  34. package/dist/index.d.ts +34 -5
  35. package/dist/index.js +27 -4
  36. package/dist/journey/answers-block.d.ts +17 -0
  37. package/dist/journey/answers-block.js +15 -0
  38. package/dist/journey/index.d.ts +9 -0
  39. package/dist/journey/index.js +8 -0
  40. package/dist/journey/journey.d.ts +13 -0
  41. package/dist/journey/journey.js +40 -0
  42. package/dist/journey/offer.d.ts +25 -0
  43. package/dist/journey/offer.js +48 -0
  44. package/dist/journey/questions.d.ts +23 -0
  45. package/dist/journey/questions.js +49 -0
  46. package/dist/journey/stages.d.ts +13 -0
  47. package/dist/journey/stages.js +107 -0
  48. package/dist/journey/survey.d.ts +10 -0
  49. package/dist/journey/survey.js +203 -0
  50. package/dist/journey/trigger.d.ts +19 -0
  51. package/dist/journey/trigger.js +79 -0
  52. package/dist/journey/types.d.ts +72 -0
  53. package/dist/journey/types.js +1 -0
  54. package/dist/lsp/client.d.ts +5 -0
  55. package/dist/lsp/client.js +39 -3
  56. package/dist/mcp/index.d.ts +5 -1
  57. package/dist/mcp/index.js +3 -1
  58. package/dist/mcp/manager.d.ts +17 -3
  59. package/dist/mcp/manager.js +27 -2
  60. package/dist/mcp/marketplace.d.ts +2 -0
  61. package/dist/mcp/marketplace.js +10 -4
  62. package/dist/mcp/mcp-client.d.ts +12 -2
  63. package/dist/mcp/mcp-client.js +74 -17
  64. package/dist/mcp/mcp-config.d.ts +2 -0
  65. package/dist/mcp/mcp-config.js +1 -0
  66. package/dist/mcp/oauth.d.ts +55 -0
  67. package/dist/mcp/oauth.js +197 -0
  68. package/dist/mcp/resource-tools.d.ts +8 -0
  69. package/dist/mcp/resource-tools.js +56 -0
  70. package/dist/memory/session-digest.d.ts +12 -0
  71. package/dist/memory/session-digest.js +47 -0
  72. package/dist/memory/stale.js +2 -1
  73. package/dist/model-alias.d.ts +18 -2
  74. package/dist/model-alias.js +27 -3
  75. package/dist/model-catalog.js +0 -1
  76. package/dist/model-list.js +2 -1
  77. package/dist/model-overrides.d.ts +30 -0
  78. package/dist/model-overrides.js +48 -0
  79. package/dist/orchestrator/specialists-registry.js +2 -3
  80. package/dist/orchestrator/specialists.js +4 -4
  81. package/dist/policy/ca-certs.d.ts +8 -0
  82. package/dist/policy/ca-certs.js +30 -0
  83. package/dist/policy/proxy.d.ts +10 -3
  84. package/dist/policy/proxy.js +33 -6
  85. package/dist/pricing.js +4 -0
  86. package/dist/providers.d.ts +7 -0
  87. package/dist/providers.js +39 -5
  88. package/dist/semantic/embedder.d.ts +15 -0
  89. package/dist/semantic/embedder.js +25 -0
  90. package/dist/semantic/semantic-index.d.ts +10 -2
  91. package/dist/semantic/semantic-index.js +16 -7
  92. package/dist/sessions/session-store.js +2 -1
  93. package/dist/skills/builtin.d.ts +2 -0
  94. package/dist/skills/builtin.js +68 -0
  95. package/dist/skills/index.d.ts +2 -0
  96. package/dist/skills/index.js +2 -0
  97. package/dist/skills/loader.d.ts +10 -3
  98. package/dist/skills/loader.js +20 -7
  99. package/dist/skills/marketplace.js +2 -1
  100. package/dist/skills/remote.d.ts +14 -0
  101. package/dist/skills/remote.js +131 -0
  102. package/dist/skills/types.d.ts +10 -0
  103. package/dist/specs/generate.d.ts +18 -9
  104. package/dist/specs/generate.js +174 -14
  105. package/dist/specs/index.d.ts +2 -2
  106. package/dist/specs/index.js +1 -1
  107. package/dist/specs/store.d.ts +14 -2
  108. package/dist/specs/store.js +26 -1
  109. package/dist/specs/types.d.ts +11 -0
  110. package/dist/steering/extra-instructions.d.ts +8 -0
  111. package/dist/steering/extra-instructions.js +104 -0
  112. package/dist/steering/folder-instructions.d.ts +8 -0
  113. package/dist/steering/folder-instructions.js +28 -0
  114. package/dist/steering/select.js +2 -1
  115. package/dist/timed-fetch.d.ts +12 -0
  116. package/dist/timed-fetch.js +83 -0
  117. package/dist/tools/apply-patch.d.ts +52 -0
  118. package/dist/tools/apply-patch.js +197 -0
  119. package/dist/tools/ask-user.d.ts +27 -0
  120. package/dist/tools/ask-user.js +60 -0
  121. package/dist/tools/browser-tools.d.ts +6 -6
  122. package/dist/tools/browser-tools.js +3 -13
  123. package/dist/tools/codebase-search.d.ts +4 -2
  124. package/dist/tools/codebase-search.js +2 -2
  125. package/dist/tools/edit-file.js +5 -3
  126. package/dist/tools/fetch-url.d.ts +7 -0
  127. package/dist/tools/fetch-url.js +52 -19
  128. package/dist/tools/find-symbol.d.ts +2 -2
  129. package/dist/tools/ignore-rules.js +2 -1
  130. package/dist/tools/index.d.ts +5 -1
  131. package/dist/tools/index.js +10 -1
  132. package/dist/tools/memory.d.ts +6 -6
  133. package/dist/tools/notebook.d.ts +2 -2
  134. package/dist/tools/permission-rules.d.ts +32 -0
  135. package/dist/tools/permission-rules.js +182 -0
  136. package/dist/tools/read-file.d.ts +5 -0
  137. package/dist/tools/read-file.js +12 -2
  138. package/dist/tools/read-only.d.ts +1 -1
  139. package/dist/tools/read-only.js +2 -0
  140. package/dist/tools/run-command.d.ts +4 -2
  141. package/dist/tools/run-command.js +12 -8
  142. package/dist/tools/sandbox-loader.js +3 -13
  143. package/dist/tools/search-files.d.ts +8 -2
  144. package/dist/tools/search-files.js +43 -0
  145. package/dist/tools/shared.d.ts +5 -0
  146. package/dist/tools/shared.js +30 -2
  147. package/dist/tools/shell-env.d.ts +7 -0
  148. package/dist/tools/shell-env.js +9 -0
  149. package/dist/tools/sub-agent-wait.d.ts +29 -0
  150. package/dist/tools/sub-agent-wait.js +60 -0
  151. package/dist/tools/sub-agent.d.ts +50 -4
  152. package/dist/tools/sub-agent.js +213 -125
  153. package/dist/tools/text-encoding.d.ts +10 -0
  154. package/dist/tools/text-encoding.js +40 -0
  155. package/dist/tools/todo.d.ts +36 -0
  156. package/dist/tools/todo.js +42 -0
  157. package/dist/tools/write-file.js +15 -9
  158. package/dist/tools/write-tools.d.ts +12 -0
  159. package/dist/tools/write-tools.js +18 -0
  160. package/dist/types.d.ts +98 -0
  161. package/dist/worktree/worktree.d.ts +3 -0
  162. package/dist/worktree/worktree.js +22 -0
  163. package/package.json +1 -1
@@ -7,10 +7,10 @@ declare const observeParams: z.ZodObject<{
7
7
  action: z.ZodEnum<["open", "screenshot", "text", "console", "close"]>;
8
8
  url: z.ZodOptional<z.ZodString>;
9
9
  }, "strip", z.ZodTypeAny, {
10
- action: "close" | "open" | "text" | "screenshot" | "console";
10
+ action: "close" | "text" | "open" | "screenshot" | "console";
11
11
  url?: string | undefined;
12
12
  }, {
13
- action: "close" | "open" | "text" | "screenshot" | "console";
13
+ action: "close" | "text" | "open" | "screenshot" | "console";
14
14
  url?: string | undefined;
15
15
  }>;
16
16
  export type BrowserResult = {
@@ -55,23 +55,23 @@ declare const actParams: z.ZodEffects<z.ZodObject<{
55
55
  expression: z.ZodOptional<z.ZodString>;
56
56
  }, "strip", z.ZodTypeAny, {
57
57
  action: "type" | "click" | "evaluate";
58
- expression?: string | undefined;
59
58
  text?: string | undefined;
59
+ expression?: string | undefined;
60
60
  selector?: string | undefined;
61
61
  }, {
62
62
  action: "type" | "click" | "evaluate";
63
- expression?: string | undefined;
64
63
  text?: string | undefined;
64
+ expression?: string | undefined;
65
65
  selector?: string | undefined;
66
66
  }>, {
67
67
  action: "type" | "click" | "evaluate";
68
- expression?: string | undefined;
69
68
  text?: string | undefined;
69
+ expression?: string | undefined;
70
70
  selector?: string | undefined;
71
71
  }, {
72
72
  action: "type" | "click" | "evaluate";
73
- expression?: string | undefined;
74
73
  text?: string | undefined;
74
+ expression?: string | undefined;
75
75
  selector?: string | undefined;
76
76
  }>;
77
77
  export {};
@@ -13,6 +13,7 @@ import { lookup } from 'node:dns/promises';
13
13
  import net from 'node:net';
14
14
  import { z } from 'zod';
15
15
  import { redact } from './redact.js';
16
+ import { isBlockedAddress } from './fetch-url.js';
16
17
  import { checkPolicyViolation } from './policy.js';
17
18
  function isLoopback(ip) {
18
19
  if (net.isIPv4(ip))
@@ -20,20 +21,9 @@ function isLoopback(ip) {
20
21
  const lower = ip.toLowerCase();
21
22
  return lower === '::1' || lower === '::ffff:127.0.0.1';
22
23
  }
24
+ /** Private, link-local or otherwise internal: the same list as `fetch_url`, minus loopback (a local dev server is the point here). */
23
25
  function isPrivateOrLinkLocal(ip) {
24
- if (net.isIPv4(ip)) {
25
- const [a, b] = ip.split('.').map(Number);
26
- return a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || (a === 169 && b === 254) || a === 0;
27
- }
28
- if (net.isIPv6(ip)) {
29
- const lower = ip.toLowerCase();
30
- const mapped = lower.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
31
- if (mapped)
32
- return isPrivateOrLinkLocal(mapped[1]);
33
- const first = parseInt(lower.split(':')[0] ?? '', 16);
34
- return (!Number.isNaN(first) && first >= 0xfe80 && first <= 0xfebf) || lower.startsWith('fc') || lower.startsWith('fd');
35
- }
36
- return false;
26
+ return isBlockedAddress(ip) && !isLoopback(ip);
37
27
  }
38
28
  /** Throws unless `rawUrl` is an http(s) URL the browser may visit. */
39
29
  export async function assertBrowsableUrl(rawUrl, policy) {
@@ -1,5 +1,5 @@
1
1
  import { z } from 'zod';
2
- import { type SemanticHit } from '../semantic/semantic-index.js';
2
+ import { type SemanticHit, type SemanticOptions } from '../semantic/semantic-index.js';
3
3
  import type { Embedder } from '../semantic/embedder.js';
4
4
  import type { ToolDefinition } from '../types.js';
5
5
  declare const params: z.ZodObject<{
@@ -18,5 +18,7 @@ export interface CodebaseSearchResult {
18
18
  results: SemanticHit[];
19
19
  note: string;
20
20
  }
21
- export declare function createCodebaseSearchTool(embedder: Embedder): ToolDefinition<z.infer<typeof params>, CodebaseSearchResult>;
21
+ export declare function createCodebaseSearchTool(embedder: Embedder, options?: SemanticOptions & {
22
+ defaultLimit?: number;
23
+ }): ToolDefinition<z.infer<typeof params>, CodebaseSearchResult>;
22
24
  export {};
@@ -4,7 +4,7 @@ const params = z.object({
4
4
  query: z.string().describe('What you are looking for, in natural language or identifiers (e.g. "where passwords are hashed").'),
5
5
  limit: z.number().int().min(1).max(20).optional().describe('Max results. Default 8.'),
6
6
  });
7
- export function createCodebaseSearchTool(embedder) {
7
+ export function createCodebaseSearchTool(embedder, options = {}) {
8
8
  return {
9
9
  name: 'codebase_search',
10
10
  description: 'Semantic search over the codebase: returns the most relevant code chunks (path, line range, snippet) for a description. Use it to find where something is implemented when you do not know the exact name; use find_symbol/search_files when you do.',
@@ -12,7 +12,7 @@ export function createCodebaseSearchTool(embedder) {
12
12
  execute: async (args, ctx) => ({
13
13
  query: args.query,
14
14
  embedder: embedder.id,
15
- results: await searchSemantic(ctx.cwd, embedder, args.query, args.limit ?? 8),
15
+ results: await searchSemantic(ctx.cwd, embedder, args.query, args.limit ?? options.defaultLimit ?? 8, options),
16
16
  note: embedder.id.startsWith('hashing')
17
17
  ? 'Ranking is by shared vocabulary (offline embedder). Set FORGE_EMBED_PROVIDER=openai|ollama for meaning-based ranking.'
18
18
  : 'Ranked by embedding similarity.',
@@ -1,6 +1,7 @@
1
1
  import { readFile, writeFile } from 'node:fs/promises';
2
2
  import { z } from 'zod';
3
3
  import { resolveAndCheckPath } from './shared.js';
4
+ import { decodeText, encodeText } from './text-encoding.js';
4
5
  const params = z.object({
5
6
  path: z.string().describe('Path of the file to edit, relative to the working directory or absolute.'),
6
7
  old_text: z.string().describe('Exact text to find in the file. Must match exactly (whitespace-sensitive).'),
@@ -18,7 +19,7 @@ export const editFileTool = {
18
19
  destructive: false,
19
20
  execute: async (args, ctx) => {
20
21
  const resolved = resolveAndCheckPath(args.path, ctx);
21
- const original = await readFile(resolved, 'utf8').catch((err) => {
22
+ const { text: original, encoding } = await readFile(resolved).then(decodeText).catch((err) => {
22
23
  if (err.code === 'ENOENT')
23
24
  throw new Error(`File not found: ${args.path}`);
24
25
  throw err;
@@ -37,11 +38,12 @@ export const editFileTool = {
37
38
  // string 2nd arg to String.prototype.replace still honors special
38
39
  // patterns like $&, $1, $$ even though old_text isn't a regex.
39
40
  : original.replace(args.old_text, () => args.new_text);
40
- await writeFile(resolved, updated, 'utf8');
41
+ const bytes = encodeText(updated, encoding);
42
+ await writeFile(resolved, bytes);
41
43
  return {
42
44
  path: resolved,
43
45
  replacements: replaceAll ? occurrences : 1,
44
- bytes: Buffer.byteLength(updated, 'utf8'),
46
+ bytes: bytes.length,
45
47
  };
46
48
  },
47
49
  };
@@ -37,5 +37,12 @@ export interface FetchUrlResult {
37
37
  /** True when the response was truncated to fit `max_bytes` or `excerpt` size. */
38
38
  truncated: boolean;
39
39
  }
40
+ /**
41
+ * SSRF protection: true for an IP literal that is loopback, link-local,
42
+ * or RFC1918 private. `fetch_url` refuses these hosts by default (see
43
+ * {@link assertUrlNotBlocked}) so an agent-supplied URL can't reach
44
+ * loopback services or cloud metadata endpoints (e.g. 169.254.169.254).
45
+ */
46
+ export declare function isBlockedAddress(ip: string): boolean;
40
47
  export declare const fetchUrlTool: ToolDefinition<z.infer<typeof params>, FetchUrlResult>;
41
48
  export {};
@@ -1,6 +1,7 @@
1
1
  import { lookup } from 'node:dns/promises';
2
2
  import net from 'node:net';
3
3
  import { z } from 'zod';
4
+ import { installedFetch } from '../policy/proxy.js';
4
5
  import { checkPolicyViolation } from './policy.js';
5
6
  const params = z.object({
6
7
  url: z
@@ -37,19 +38,24 @@ const DEFAULT_MAX_BYTES = 200_000;
37
38
  const DEFAULT_TIMEOUT_MS = 10_000;
38
39
  const EXCERPT_MAX_CHARS = 8_000;
39
40
  const USER_AGENT = 'ikarmic-forge/0.1 (fetch_url)';
41
+ const MAX_REDIRECTS = 5;
40
42
  /**
41
43
  * SSRF protection: true for an IP literal that is loopback, link-local,
42
44
  * or RFC1918 private. `fetch_url` refuses these hosts by default (see
43
45
  * {@link assertUrlNotBlocked}) so an agent-supplied URL can't reach
44
46
  * loopback services or cloud metadata endpoints (e.g. 169.254.169.254).
45
47
  */
46
- function isBlockedAddress(ip) {
48
+ export function isBlockedAddress(ip) {
47
49
  if (net.isIPv4(ip)) {
48
50
  const parts = ip.split('.').map(Number);
49
51
  const a = parts[0];
50
52
  const b = parts[1];
53
+ if (a === 0)
54
+ return true; // 0.0.0.0/8: "this host" (0.0.0.0 reaches local services)
51
55
  if (a === 127)
52
56
  return true; // 127.0.0.0/8: loopback
57
+ if (a === 100 && b >= 64 && b <= 127)
58
+ return true; // 100.64.0.0/10: carrier-grade NAT / cloud-internal
53
59
  if (a === 10)
54
60
  return true; // 10.0.0.0/8: private
55
61
  if (a === 172 && b >= 16 && b <= 31)
@@ -62,16 +68,25 @@ function isBlockedAddress(ip) {
62
68
  }
63
69
  if (net.isIPv6(ip)) {
64
70
  const lower = ip.toLowerCase();
65
- if (lower === '::1')
66
- return true; // loopback
71
+ if (lower === '::1' || lower === '::')
72
+ return true; // loopback, unspecified
67
73
  // IPv4-mapped IPv6 (::ffff:a.b.c.d): check the embedded v4 address too,
68
74
  // otherwise it's a trivial bypass of the v4 checks above.
69
75
  const mapped = lower.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
70
76
  if (mapped)
71
77
  return isBlockedAddress(mapped[1]);
78
+ // The same mapped address written in hex (::ffff:7f00:1 is 127.0.0.1).
79
+ const mappedHex = lower.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
80
+ if (mappedHex) {
81
+ const hi = parseInt(mappedHex[1], 16);
82
+ const lo = parseInt(mappedHex[2], 16);
83
+ return isBlockedAddress(`${hi >> 8}.${hi & 255}.${lo >> 8}.${lo & 255}`);
84
+ }
72
85
  const firstGroup = parseInt(lower.split(':')[0] ?? '', 16);
73
86
  if (!Number.isNaN(firstGroup) && firstGroup >= 0xfe80 && firstGroup <= 0xfebf)
74
87
  return true; // fe80::/10: link-local
88
+ if (!Number.isNaN(firstGroup) && firstGroup >= 0xfc00 && firstGroup <= 0xfdff)
89
+ return true; // fc00::/7: unique local (private)
75
90
  return false;
76
91
  }
77
92
  return false;
@@ -140,21 +155,39 @@ export const fetchUrlTool = {
140
155
  ? setTimeout(() => controller.abort(), timeoutMs)
141
156
  : null;
142
157
  let response;
158
+ let finalUrl = args.url;
143
159
  try {
144
- // NOTE: the description above promises "follows up to 5 redirects",
145
- // but `redirect: 'follow'` delegates to the runtime's fetch with no
146
- // redirect-count cap of our own. Left as a doc/implementation
147
- // mismatch (low priority) rather than hand-rolling redirect
148
- // following here.
149
- response = await fetch(args.url, {
150
- method: 'GET',
151
- redirect: follow ? 'follow' : 'manual',
152
- signal: controller.signal,
153
- headers: {
154
- 'user-agent': USER_AGENT,
155
- accept: 'text/html, application/json, text/plain;q=0.9, */*;q=0.5',
156
- },
157
- });
160
+ // Redirects are followed here, one hop at a time, so every hop passes the same policy and SSRF checks as the
161
+ // first URL: a public page must not be able to bounce the agent to 169.254.169.254 or localhost.
162
+ // The installed fetch honours the proxy and the extra CA roots even where the host replaced the global one.
163
+ const doFetch = installedFetch() ?? fetch;
164
+ let current = args.url;
165
+ for (let hop = 0;; hop++) {
166
+ response = await doFetch(current, {
167
+ method: 'GET',
168
+ redirect: 'manual',
169
+ signal: controller.signal,
170
+ headers: {
171
+ 'user-agent': USER_AGENT,
172
+ accept: 'text/html, application/json, text/plain;q=0.9, */*;q=0.5',
173
+ },
174
+ });
175
+ const location = response.headers.get('location');
176
+ if (!follow || response.status < 300 || response.status >= 400 || !location)
177
+ break;
178
+ if (hop >= MAX_REDIRECTS)
179
+ throw new Error(`stopped after ${MAX_REDIRECTS} redirects`);
180
+ const next = new URL(location, current).href;
181
+ if (!/^https?:$/i.test(new URL(next).protocol))
182
+ throw new Error(`refusing a redirect to ${next}`);
183
+ const hopViolation = checkPolicyViolation(ctx.policy, { cwd: ctx.cwd, url: next });
184
+ if (hopViolation)
185
+ throw new Error(`[sandbox] ${hopViolation}`);
186
+ await assertUrlNotBlocked(next, ctx.policy);
187
+ await response.body?.cancel().catch(() => undefined);
188
+ current = next;
189
+ }
190
+ finalUrl = current;
158
191
  }
159
192
  catch (err) {
160
193
  if (timeoutHandle)
@@ -167,7 +200,7 @@ export const fetchUrlTool = {
167
200
  if (timeoutHandle)
168
201
  clearTimeout(timeoutHandle);
169
202
  if (response.status >= 400) {
170
- throw new Error(`fetch_url: HTTP ${response.status} ${response.statusText || ''} (${response.url})`);
203
+ throw new Error(`fetch_url: HTTP ${response.status} ${response.statusText || ''} (${finalUrl})`);
171
204
  }
172
205
  const contentType = response.headers.get('content-type')?.toLowerCase() ?? 'text/plain';
173
206
  const buffer = await readWithCap(response, maxBytes);
@@ -195,7 +228,7 @@ export const fetchUrlTool = {
195
228
  }
196
229
  const excerpt = body.length > EXCERPT_MAX_CHARS ? body.slice(0, EXCERPT_MAX_CHARS) + '\n\n…(truncated for display; raw response was ' + body.length + ' chars)' : body;
197
230
  return {
198
- url: response.url,
231
+ url: finalUrl,
199
232
  status: response.status,
200
233
  statusText: response.statusText,
201
234
  contentType,
@@ -6,10 +6,10 @@ declare const params: z.ZodObject<{
6
6
  kind: z.ZodOptional<z.ZodEnum<["function", "class", "interface", "type", "enum", "const", "method"]>>;
7
7
  }, "strip", z.ZodTypeAny, {
8
8
  name: string;
9
- kind?: "function" | "method" | "type" | "class" | "interface" | "enum" | "const" | undefined;
9
+ kind?: "function" | "class" | "interface" | "type" | "enum" | "const" | "method" | undefined;
10
10
  }, {
11
11
  name: string;
12
- kind?: "function" | "method" | "type" | "class" | "interface" | "enum" | "const" | undefined;
12
+ kind?: "function" | "class" | "interface" | "type" | "enum" | "const" | "method" | undefined;
13
13
  }>;
14
14
  export interface FindSymbolMatch {
15
15
  name: string;
@@ -7,6 +7,7 @@
7
7
  import { existsSync, readFileSync, statSync } from 'node:fs';
8
8
  import path from 'node:path';
9
9
  import ignore from 'ignore';
10
+ import { isOutsideRel } from './shared.js';
10
11
  /** Files read in every directory, in this order. `.forgeignore` is for paths only the agent should skip. */
11
12
  export const IGNORE_FILE_NAMES = ['.gitignore', '.forgeignore'];
12
13
  function readIfFile(file) {
@@ -62,7 +63,7 @@ export class IgnoreRules {
62
63
  /** Whether `absPath` is ignored. Paths outside the rule root are never ignored here. */
63
64
  isIgnored(absPath, isDir) {
64
65
  const rel = path.relative(this.top, path.resolve(absPath));
65
- if (!rel || rel.startsWith('..') || path.isAbsolute(rel))
66
+ if (!rel || isOutsideRel(rel))
66
67
  return false;
67
68
  const parts = rel.split(path.sep);
68
69
  let ignored = false;
@@ -1,3 +1,4 @@
1
+ import { applyPatchTool } from './apply-patch.js';
1
2
  import { editFileTool } from './edit-file.js';
2
3
  import { fetchUrlTool } from './fetch-url.js';
3
4
  import { findReferencesTool } from './find-references.js';
@@ -10,7 +11,10 @@ import { readFileTool } from './read-file.js';
10
11
  import { readImageTool } from './read-image.js';
11
12
  import { runCommandTool } from './run-command.js';
12
13
  import { searchFilesTool } from './search-files.js';
14
+ import { askUserTool } from './ask-user.js';
13
15
  import { subAgentTool } from './sub-agent.js';
16
+ import { subAgentWaitTool } from './sub-agent-wait.js';
17
+ import { todoReadTool, todoWriteTool } from './todo.js';
14
18
  import { writeFileTool } from './write-file.js';
15
19
  import type { ToolDefinition } from '../types.js';
16
20
  /**
@@ -26,7 +30,7 @@ type AnyToolDefinition = ToolDefinition<any, any>;
26
30
  * intentional: the model sees this order in its tool list.
27
31
  */
28
32
  export declare const BUILTIN_TOOLS: readonly AnyToolDefinition[];
29
- export { editFileTool, editNotebookTool, fetchUrlTool, findReferencesTool, findSymbolTool, listFilesTool, listSymbolsTool, memoryForgetTool, memoryReadTool, memoryWriteTool, readFileTool, readImageTool, runCommandTool, searchFilesTool, subAgentTool, writeFileTool, };
33
+ export { applyPatchTool, askUserTool, editFileTool, editNotebookTool, fetchUrlTool, findReferencesTool, findSymbolTool, listFilesTool, listSymbolsTool, memoryForgetTool, memoryReadTool, memoryWriteTool, readFileTool, readImageTool, runCommandTool, searchFilesTool, subAgentTool, subAgentWaitTool, todoReadTool, todoWriteTool, writeFileTool, };
30
34
  export type { ToolContext, ToolDefinition, ToolPolicy, ToolSandbox } from '../types.js';
31
35
  export { checkPolicyViolation, globToRegex, mergeToolSandbox, normalizePathPattern, pathMatchesAny } from './policy.js';
32
36
  export { redact, countRedactions, buildRules } from './redact.js';
@@ -1,3 +1,4 @@
1
+ import { applyPatchTool } from './apply-patch.js';
1
2
  import { editFileTool } from './edit-file.js';
2
3
  import { fetchUrlTool } from './fetch-url.js';
3
4
  import { findReferencesTool } from './find-references.js';
@@ -10,7 +11,10 @@ import { readFileTool } from './read-file.js';
10
11
  import { readImageTool } from './read-image.js';
11
12
  import { runCommandTool } from './run-command.js';
12
13
  import { searchFilesTool } from './search-files.js';
14
+ import { askUserTool } from './ask-user.js';
13
15
  import { subAgentTool } from './sub-agent.js';
16
+ import { subAgentWaitTool } from './sub-agent-wait.js';
17
+ import { todoReadTool, todoWriteTool } from './todo.js';
14
18
  import { writeFileTool } from './write-file.js';
15
19
  /**
16
20
  * The built-in tool set that ships with Forge. These cover the file system,
@@ -28,6 +32,7 @@ export const BUILTIN_TOOLS = [
28
32
  findReferencesTool,
29
33
  writeFileTool,
30
34
  editFileTool,
35
+ applyPatchTool,
31
36
  editNotebookTool,
32
37
  runCommandTool,
33
38
  fetchUrlTool,
@@ -35,8 +40,12 @@ export const BUILTIN_TOOLS = [
35
40
  memoryWriteTool,
36
41
  memoryForgetTool,
37
42
  subAgentTool,
43
+ subAgentWaitTool,
44
+ todoWriteTool,
45
+ todoReadTool,
46
+ askUserTool,
38
47
  ];
39
- export { editFileTool, editNotebookTool, fetchUrlTool, findReferencesTool, findSymbolTool, listFilesTool, listSymbolsTool, memoryForgetTool, memoryReadTool, memoryWriteTool, readFileTool, readImageTool, runCommandTool, searchFilesTool, subAgentTool, writeFileTool, };
48
+ export { applyPatchTool, askUserTool, editFileTool, editNotebookTool, fetchUrlTool, findReferencesTool, findSymbolTool, listFilesTool, listSymbolsTool, memoryForgetTool, memoryReadTool, memoryWriteTool, readFileTool, readImageTool, runCommandTool, searchFilesTool, subAgentTool, subAgentWaitTool, todoReadTool, todoWriteTool, writeFileTool, };
40
49
  export { checkPolicyViolation, globToRegex, mergeToolSandbox, normalizePathPattern, pathMatchesAny } from './policy.js';
41
50
  export { redact, countRedactions, buildRules } from './redact.js';
42
51
  export { ToolResultCache, buildCacheKey, cachedExecute } from './result-cache.js';
@@ -11,11 +11,11 @@ declare const readParams: z.ZodObject<{
11
11
  /** Optional heading to filter by. Returns just that section's body. */
12
12
  section: z.ZodOptional<z.ZodString>;
13
13
  }, "strip", z.ZodTypeAny, {
14
- scope?: "project" | "global" | "both" | undefined;
15
14
  section?: string | undefined;
16
- }, {
17
15
  scope?: "project" | "global" | "both" | undefined;
16
+ }, {
18
17
  section?: string | undefined;
18
+ scope?: "project" | "global" | "both" | undefined;
19
19
  }>;
20
20
  export interface MemoryReadResult {
21
21
  scope: 'project' | 'global' | 'both';
@@ -43,17 +43,17 @@ declare const writeParams: z.ZodObject<{
43
43
  }, "strip", z.ZodTypeAny, {
44
44
  body: string;
45
45
  heading: string;
46
- type?: "reference" | "convention" | "decision" | "correction" | undefined;
47
- scope?: "project" | "global" | undefined;
46
+ type?: "convention" | "decision" | "correction" | "reference" | undefined;
48
47
  refs?: string[] | undefined;
49
48
  pin?: boolean | undefined;
49
+ scope?: "project" | "global" | undefined;
50
50
  }, {
51
51
  body: string;
52
52
  heading: string;
53
- type?: "reference" | "convention" | "decision" | "correction" | undefined;
54
- scope?: "project" | "global" | undefined;
53
+ type?: "convention" | "decision" | "correction" | "reference" | undefined;
55
54
  refs?: string[] | undefined;
56
55
  pin?: boolean | undefined;
56
+ scope?: "project" | "global" | undefined;
57
57
  }>;
58
58
  export interface MemoryWriteResult {
59
59
  scope: 'project' | 'global';
@@ -46,16 +46,16 @@ declare const params: z.ZodObject<{
46
46
  cell_type: z.ZodOptional<z.ZodEnum<["code", "markdown", "raw"]>>;
47
47
  }, "strip", z.ZodTypeAny, {
48
48
  path: string;
49
+ mode?: "replace" | "delete" | "insert" | undefined;
49
50
  source?: string | undefined;
50
51
  cell_type?: "code" | "raw" | "markdown" | undefined;
51
- mode?: "replace" | "insert" | "delete" | undefined;
52
52
  cell_index?: number | undefined;
53
53
  cell_id?: string | undefined;
54
54
  }, {
55
55
  path: string;
56
+ mode?: "replace" | "delete" | "insert" | undefined;
56
57
  source?: string | undefined;
57
58
  cell_type?: "code" | "raw" | "markdown" | undefined;
58
- mode?: "replace" | "insert" | "delete" | undefined;
59
59
  cell_index?: number | undefined;
60
60
  cell_id?: string | undefined;
61
61
  }>;
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Ordered permission rules, as in Kilo's `permission` config: each permission (read, edit, bash, ...) maps to
3
+ * an action or to a pattern map, rules run in config order and the LAST match wins. Pure; the agent consults it
4
+ * in `requestApproval`.
5
+ */
6
+ export type PermissionAction = 'allow' | 'ask' | 'deny';
7
+ export type PermissionConfig = Record<string, PermissionAction | Record<string, PermissionAction>>;
8
+ export interface PermissionRule {
9
+ permission: string;
10
+ pattern: string;
11
+ action: PermissionAction;
12
+ }
13
+ /** Flatten a config object into ordered rules; entries with an unknown action are dropped. */
14
+ export declare function compilePermissionRules(config: PermissionConfig | undefined): PermissionRule[];
15
+ /** The action of the last rule matching `permission` and `target`, or undefined when none does. */
16
+ export declare function evaluatePermission(rules: readonly PermissionRule[], permission: string, target: string): PermissionAction | undefined;
17
+ /**
18
+ * Split a shell command into the commands a permission rule must each allow: `&&`, `||`, `;`, `|` and newlines
19
+ * outside quotes separate commands; the inside of `$(...)` and backticks is added as extra commands, and an
20
+ * unquoted `>` redirect adds a `redirect:` entry, so an allow rule for `git status *` cannot cover
21
+ * `git status $(rm -rf x)` or `git status > file`.
22
+ */
23
+ export declare function splitShellCommands(command: string): string[];
24
+ /** `.env` and `.env.*` hold secrets; `.env.example` and `.env.sample` are documentation. */
25
+ export declare function isSensitiveFile(relPath: string): boolean;
26
+ /** True when a token of the command points outside `cwd`: `~`, a `..` escape, or an absolute path elsewhere. */
27
+ export declare function touchesOutsideCwd(command: string, cwd: string): boolean;
28
+ /** Which permission a tool call falls under and the strings the rules are matched against; undefined = no rule applies. */
29
+ export declare function permissionFor(toolName: string, args: unknown, cwd: string): {
30
+ permission: string;
31
+ targets: string[];
32
+ } | undefined;
@@ -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
+ }