@akira-tl/forgerelay 0.2.0 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -0
- package/dist/apply-patch.js +21 -6
- package/dist/artifact-tools.js +9 -4
- package/dist/config.js +8 -6
- package/dist/file-mutations.js +50 -0
- package/dist/hooks.js +4 -1
- package/dist/logger.js +157 -15
- package/dist/mcp/server-instructions.js +13 -9
- package/dist/pi-tools.js +14 -13
- package/dist/roots.js +30 -1
- package/dist/server.js +210 -54
- package/dist/ui/.vite/manifest.json +280 -280
- package/dist/ui/assets/{angular-html-CovppXt5.js → angular-html-CQmCUfkv.js} +1 -1
- package/dist/ui/assets/{angular-ts-0Y9l6YXu.js → angular-ts-BSbfxpMS.js} +1 -1
- package/dist/ui/assets/{apl-BmBhiklA.js → apl-CWCaH-E3.js} +1 -1
- package/dist/ui/assets/{astro-BAmqcqyV.js → astro-R_p4G0xA.js} +1 -1
- package/dist/ui/assets/{blade-BjFcmRJo.js → blade-irHensDY.js} +1 -1
- package/dist/ui/assets/{c-CaOJn4x0.js → c-_XEQUoq6.js} +1 -1
- package/dist/ui/assets/{cobol-DR4zWFMt.js → cobol-Css6VQjJ.js} +1 -1
- package/dist/ui/assets/{coffee-ReGSThmM.js → coffee-CpXUkyDm.js} +1 -1
- package/dist/ui/assets/{cpp-CnlQh6MO.js → cpp-BQHsPWwI.js} +1 -1
- package/dist/ui/assets/{crystal-BrpPbH1U.js → crystal-Cbv8J9Fm.js} +1 -1
- package/dist/ui/assets/{css-DRBEvyPU.js → css-D8DkrS3U.js} +1 -1
- package/dist/ui/assets/{edge-cfwpdvl9.js → edge-bBL_XDAG.js} +1 -1
- package/dist/ui/assets/{elixir-7owrJxb6.js → elixir-CQjsM9sA.js} +1 -1
- package/dist/ui/assets/{elm-udiOjODk.js → elm-DLFl-jEZ.js} +1 -1
- package/dist/ui/assets/{erb-8ctXHMkS.js → erb-BJjsdB_Q.js} +1 -1
- package/dist/ui/assets/{git-rebase-P0V_qK1w.js → git-rebase-CqP5A2s0.js} +1 -1
- package/dist/ui/assets/{glimmer-js-CZ14ED7U.js → glimmer-js-B5Hh_7xe.js} +1 -1
- package/dist/ui/assets/{glimmer-ts-DtBSFP-o.js → glimmer-ts-oaeSAC8s.js} +1 -1
- package/dist/ui/assets/{glsl-BX2BkZJK.js → glsl-BylkOpcF.js} +1 -1
- package/dist/ui/assets/{graphql-Dj_cPv4w.js → graphql-DsVRvP6S.js} +1 -1
- package/dist/ui/assets/{hack-DGRkd8xn.js → hack-D4N5abT_.js} +1 -1
- package/dist/ui/assets/{haml-BAPSvujW.js → haml-CIAJQIox.js} +1 -1
- package/dist/ui/assets/{handlebars-PYksdULG.js → handlebars-DDEZyNXR.js} +1 -1
- package/dist/ui/assets/{heavy-payload-BjE0LSUi.js → heavy-payload-DafM8yjB.js} +1 -1
- package/dist/ui/assets/{html-derivative-C6q86ZrS.js → html-derivative-CZ91Y2d5.js} +1 -1
- package/dist/ui/assets/{html-DloAPGZ1.js → html-zDy-3lvy.js} +1 -1
- package/dist/ui/assets/{http-fPvYDvHA.js → http-ezaodZRf.js} +1 -1
- package/dist/ui/assets/{hurl-Dbir4WvE.js → hurl-BmL0o7TO.js} +1 -1
- package/dist/ui/assets/{java-DvkqXg3z.js → java-Q2C0vwbH.js} +1 -1
- package/dist/ui/assets/{javascript-TONTV6Ty.js → javascript-UDhBkHE3.js} +1 -1
- package/dist/ui/assets/{jinja-OM1F5Gct.js → jinja-BWRskcYS.js} +1 -1
- package/dist/ui/assets/{jison-CBttugts.js → jison-B18BY9Ds.js} +1 -1
- package/dist/ui/assets/{json-Db5oY-7J.js → json-DNj7MpRr.js} +1 -1
- package/dist/ui/assets/{jsx-DKeJCgsc.js → jsx-CVpHJ6cr.js} +1 -1
- package/dist/ui/assets/{julia-NlIPhXaL.js → julia-CJBNBS9c.js} +1 -1
- package/dist/ui/assets/{just-BXBJB-mX.js → just-Bmu2JCQT.js} +1 -1
- package/dist/ui/assets/{latex-CGK-6yDy.js → latex-BB7F93KY.js} +1 -1
- package/dist/ui/assets/{liquid-D7waQ0MY.js → liquid-Btnomc6E.js} +1 -1
- package/dist/ui/assets/{lua-C_kkt4h1.js → lua-D0FYT9Vo.js} +1 -1
- package/dist/ui/assets/{marko-BokXuCts.js → marko-MHLw0ZlK.js} +1 -1
- package/dist/ui/assets/{mdc-Bk0nnUJA.js → mdc-CSr-suGy.js} +1 -1
- package/dist/ui/assets/{nginx-jM6NF9TV.js → nginx-ur1l6E-g.js} +1 -1
- package/dist/ui/assets/{nim-hYsL-WKb.js → nim-B2k0IugA.js} +1 -1
- package/dist/ui/assets/{perl-DBI_cUHJ.js → perl-BqOHxFDn.js} +1 -1
- package/dist/ui/assets/{php-BX_aQhFl.js → php-DzPK6O1_.js} +1 -1
- package/dist/ui/assets/{pug-DwFAYy1e.js → pug-D169OZkr.js} +1 -1
- package/dist/ui/assets/{qml-eLSwQUKt.js → qml-BtemJTC4.js} +1 -1
- package/dist/ui/assets/{r-BMnQsMLf.js → r-BGVHu0bd.js} +1 -1
- package/dist/ui/assets/{razor-CgciiMyw.js → razor-Diisjg1d.js} +1 -1
- package/dist/ui/assets/{regexp-ZrURNrn5.js → regexp-B2F5vMZ6.js} +1 -1
- package/dist/ui/assets/{review-payload-HGkpJjjm.js → review-payload-B-3I3Oj_.js} +1 -1
- package/dist/ui/assets/{rst-EoAOr3M6.js → rst-BxpeZwuT.js} +1 -1
- package/dist/ui/assets/{ruby-Dqx5_Rqw.js → ruby-DrCq4riN.js} +1 -1
- package/dist/ui/assets/{sas-Bf6xPLSX.js → sas-CI_2W-1n.js} +1 -1
- package/dist/ui/assets/{scrollbar-DX4Ujotb.js → scrollbar-DHsDF5w-.js} +3 -3
- package/dist/ui/assets/{scss-DDpW3yTd.js → scss-BxtyHZ1p.js} +1 -1
- package/dist/ui/assets/{shellscript-DayAQ-W9.js → shellscript-BY_YYrHT.js} +1 -1
- package/dist/ui/assets/{shellsession-jPr_lSnA.js → shellsession-C6uBrLmA.js} +1 -1
- package/dist/ui/assets/{soy-X9adks8G.js → soy-BHM9gdPh.js} +1 -1
- package/dist/ui/assets/{sql-Bmt09AIR.js → sql-XmhfEGz_.js} +1 -1
- package/dist/ui/assets/{stata-DVkNpH8r.js → stata-BbHY7NEo.js} +1 -1
- package/dist/ui/assets/{surrealql-eEXzMb8j.js → surrealql-CD49DZ3E.js} +1 -1
- package/dist/ui/assets/{svelte-CFoBxZF3.js → svelte-CbYsEYJY.js} +1 -1
- package/dist/ui/assets/{templ-Da_324JF.js → templ-B5JoUxEB.js} +1 -1
- package/dist/ui/assets/{tex-HAzlmKJ0.js → tex-DZcOfTZK.js} +1 -1
- package/dist/ui/assets/{ts-tags-7iLrNH8p.js → ts-tags-BeU-xqc0.js} +1 -1
- package/dist/ui/assets/{tsx-gZaXf8PJ.js → tsx-V7W1xGl3.js} +1 -1
- package/dist/ui/assets/{twig-CLpQ7p1N.js → twig-MIaJo5BU.js} +1 -1
- package/dist/ui/assets/{typescript-B2WWCRI2.js → typescript-C0WESuVc.js} +1 -1
- package/dist/ui/assets/{vue-No_8Jygd.js → vue-eJHwc9ra.js} +1 -1
- package/dist/ui/assets/{vue-html-kHJg98Kk.js → vue-html-CLmBujTU.js} +1 -1
- package/dist/ui/assets/{vue-vine-Df4cEYaN.js → vue-vine-BP5kjIGb.js} +1 -1
- package/dist/ui/assets/{workspace-app-C_wsC6VL.js → workspace-app-C1qR5cAY.js} +3 -3
- package/dist/ui/assets/{xml-DUZ05dpG.js → xml-j7pmyP48.js} +1 -1
- package/dist/ui/assets/{xsl-Bwp3w2OF.js → xsl-5VFHbg6o.js} +1 -1
- package/dist/ui/assets/{yaml-IA_-TEM1.js → yaml-DxFAt-KU.js} +1 -1
- package/dist/ui/workspace-app.html +1 -1
- package/dist/workspaces.js +19 -6
- package/docs/chatgpt-coding-workflow.md +4 -1
- package/docs/configuration.md +16 -8
- package/docs/debugging.md +6 -5
- package/docs/security.md +18 -7
- package/package.json +2 -2
- package/scripts/debug/accept.mjs +65 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,30 @@ All notable ForgeRelay changes are documented here.
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.2.2] - 2026-08-09
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- First-class `rename` and `delete` MCP tools for files and directories in workspace or OS-temp file roots, including direct availability in minimal, full, and Codex tool modes.
|
|
12
|
+
|
|
13
|
+
### Security
|
|
14
|
+
|
|
15
|
+
- `rename` validates both source and destination canonical roots and refuses existing destinations; `delete` refuses allowed roots themselves and requires explicit `recursive: true` for non-empty directory trees.
|
|
16
|
+
|
|
17
|
+
## [0.2.1] - 2026-08-09
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
|
|
21
|
+
- Local console logging now defaults to a compact Loguru-style `pretty` format focused on Agent operations: short timestamps, workspace/session context, tool or Hook action, target, and `ok`/`error` or shell exit status. HTTP request logging is off by default in human mode, while explicit `json` mode keeps the previous request-on and shell-command-off machine defaults.
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
|
|
25
|
+
- File and search tools can access and modify files in the operating system temporary directory without making that directory an implicit workspace root; Codex `apply_patch` supports absolute OS-temp paths while workspace patch paths remain relative.
|
|
26
|
+
|
|
27
|
+
### Security
|
|
28
|
+
|
|
29
|
+
- 文件工具在 workspace 与 OS temp 边界内都会校验 canonical path,阻止通过 symlink 跳转到任意文件系统位置;shell cwd 与 workspace-open allowed roots 不随 temp 文件访问而扩大。
|
|
30
|
+
|
|
7
31
|
## [0.2.0] - 2026-08-09
|
|
8
32
|
|
|
9
33
|
### Added
|
package/dist/apply-patch.js
CHANGED
|
@@ -4,6 +4,7 @@ import { access, lstat, mkdir, readFile, realpath, rename, rm, stat, writeFile }
|
|
|
4
4
|
import { dirname, isAbsolute, relative, resolve } from "node:path";
|
|
5
5
|
import { TextDecoder } from "node:util";
|
|
6
6
|
import { createTwoFilesPatch, FILE_HEADERS_ONLY } from "diff";
|
|
7
|
+
import { AccessDeniedError, resolveCanonicalAllowedPath } from "./roots.js";
|
|
7
8
|
function patchError(message) {
|
|
8
9
|
return new Error(`Invalid patch: ${message}`);
|
|
9
10
|
}
|
|
@@ -137,10 +138,24 @@ function isInside(root, path) {
|
|
|
137
138
|
const rel = relative(root, path);
|
|
138
139
|
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
|
|
139
140
|
}
|
|
140
|
-
async function resolveConfinedPath(root, input) {
|
|
141
|
-
if (!input || input.includes("\0")
|
|
141
|
+
async function resolveConfinedPath(root, input, auxiliaryRoots = []) {
|
|
142
|
+
if (!input || input.includes("\0")) {
|
|
142
143
|
throw patchError(`path must be relative to the workspace: ${input}`);
|
|
143
144
|
}
|
|
145
|
+
if (isAbsolute(input)) {
|
|
146
|
+
if (auxiliaryRoots.length === 0) {
|
|
147
|
+
throw patchError(`path must be relative to the workspace: ${input}`);
|
|
148
|
+
}
|
|
149
|
+
try {
|
|
150
|
+
return await resolveCanonicalAllowedPath(input, root, auxiliaryRoots);
|
|
151
|
+
}
|
|
152
|
+
catch (error) {
|
|
153
|
+
if (error instanceof AccessDeniedError) {
|
|
154
|
+
throw patchError(`path is outside allowed auxiliary roots: ${input}`);
|
|
155
|
+
}
|
|
156
|
+
throw error;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
144
159
|
const rootPath = await realpath(root);
|
|
145
160
|
const target = resolve(rootPath, input);
|
|
146
161
|
if (!isInside(rootPath, target)) {
|
|
@@ -269,7 +284,7 @@ export async function isSamePatchFile(source, destination, readIdentity = lstat)
|
|
|
269
284
|
throw error;
|
|
270
285
|
}
|
|
271
286
|
}
|
|
272
|
-
export async function applyPatch(root, patch) {
|
|
287
|
+
export async function applyPatch(root, patch, auxiliaryRoots = []) {
|
|
273
288
|
const actions = parsePatch(patch);
|
|
274
289
|
const results = [];
|
|
275
290
|
const patches = [];
|
|
@@ -289,14 +304,14 @@ export async function applyPatch(root, patch) {
|
|
|
289
304
|
};
|
|
290
305
|
for (const action of actions) {
|
|
291
306
|
if (action.kind === "add") {
|
|
292
|
-
const absolute = await resolveConfinedPath(root, action.path);
|
|
307
|
+
const absolute = await resolveConfinedPath(root, action.path, auxiliaryRoots);
|
|
293
308
|
const original = await readStagedOptional(absolute, action.path);
|
|
294
309
|
staged.set(absolute, { content: action.content, mode: original?.mode });
|
|
295
310
|
patches.push(unifiedFilePatch(action.path, action.path, original?.content ?? null, action.content));
|
|
296
311
|
results.push({ path: action.path, operation: original ? "update" : "add" });
|
|
297
312
|
continue;
|
|
298
313
|
}
|
|
299
|
-
const absolute = await resolveConfinedPath(root, action.path);
|
|
314
|
+
const absolute = await resolveConfinedPath(root, action.path, auxiliaryRoots);
|
|
300
315
|
const file = await readStagedRequired(absolute, action.path);
|
|
301
316
|
if (action.kind === "delete") {
|
|
302
317
|
staged.set(absolute, null);
|
|
@@ -306,7 +321,7 @@ export async function applyPatch(root, patch) {
|
|
|
306
321
|
}
|
|
307
322
|
const updated = applyHunks(action.path, file.content, action.hunks);
|
|
308
323
|
if (action.moveTo) {
|
|
309
|
-
const destination = await resolveConfinedPath(root, action.moveTo);
|
|
324
|
+
const destination = await resolveConfinedPath(root, action.moveTo, auxiliaryRoots);
|
|
310
325
|
const samePatchFile = await isSamePatchFile(absolute, destination);
|
|
311
326
|
if (!samePatchFile)
|
|
312
327
|
await readStagedOptional(destination, action.moveTo);
|
package/dist/artifact-tools.js
CHANGED
|
@@ -7,7 +7,7 @@ import * as z from "zod/v4";
|
|
|
7
7
|
import { ArtifactError } from "./artifact-error.js";
|
|
8
8
|
import { runToolWithHooks } from "./hooks.js";
|
|
9
9
|
import { describeIncomingArtifactValue, IncomingArtifactAdapterRegistry, } from "./incoming-artifacts.js";
|
|
10
|
-
import { logEvent } from "./logger.js";
|
|
10
|
+
import { logEvent, sessionIdPrefix, workspaceLogLabel } from "./logger.js";
|
|
11
11
|
const ARTIFACT_WRITE_ANNOTATIONS = {
|
|
12
12
|
readOnlyHint: false,
|
|
13
13
|
destructiveHint: false,
|
|
@@ -47,7 +47,7 @@ export function registerArtifactTools(server, { config, workspaces, hooks, incom
|
|
|
47
47
|
},
|
|
48
48
|
_meta: { "openai/fileParams": ["file"] },
|
|
49
49
|
annotations: ARTIFACT_WRITE_ANNOTATIONS,
|
|
50
|
-
}, async (input) => {
|
|
50
|
+
}, async (input, extra) => {
|
|
51
51
|
const workspace = workspaces.getWorkspace(input.workspaceId);
|
|
52
52
|
return runToolWithHooks(hooks, {
|
|
53
53
|
tool: "download_artifact",
|
|
@@ -59,7 +59,10 @@ export function registerArtifactTools(server, { config, workspaces, hooks, incom
|
|
|
59
59
|
},
|
|
60
60
|
payload: { path: input.path },
|
|
61
61
|
changedPaths: (result) => [result.structuredContent.path],
|
|
62
|
-
operation: () => executeArtifactTool(config, input,
|
|
62
|
+
operation: () => executeArtifactTool(config, input, {
|
|
63
|
+
workspace: workspaceLogLabel(workspace.root, workspace.id),
|
|
64
|
+
session: sessionIdPrefix(extra?.sessionId),
|
|
65
|
+
}, async () => {
|
|
63
66
|
const downloaded = await downloadIncomingArtifact({
|
|
64
67
|
registry: incomingRegistry,
|
|
65
68
|
workspaceId: workspace.id,
|
|
@@ -165,7 +168,7 @@ export function artifactToolLogFields(input) {
|
|
|
165
168
|
path: input.path,
|
|
166
169
|
};
|
|
167
170
|
}
|
|
168
|
-
async function executeArtifactTool(config, input, operation) {
|
|
171
|
+
async function executeArtifactTool(config, input, logContext, operation) {
|
|
169
172
|
const startedAt = performance.now();
|
|
170
173
|
try {
|
|
171
174
|
const { publicResult, logResult } = await operation();
|
|
@@ -173,6 +176,7 @@ async function executeArtifactTool(config, input, operation) {
|
|
|
173
176
|
logEvent(config.logging, "info", "artifact_tool_call", {
|
|
174
177
|
tool: "download_artifact",
|
|
175
178
|
...artifactToolLogFields(input),
|
|
179
|
+
...logContext,
|
|
176
180
|
path: logResult.path,
|
|
177
181
|
size: logResult.size,
|
|
178
182
|
sha256: logResult.sha256,
|
|
@@ -187,6 +191,7 @@ async function executeArtifactTool(config, input, operation) {
|
|
|
187
191
|
logEvent(config.logging, "warn", "artifact_tool_call", {
|
|
188
192
|
tool: "download_artifact",
|
|
189
193
|
...artifactToolLogFields(input),
|
|
194
|
+
...logContext,
|
|
190
195
|
success: false,
|
|
191
196
|
errorCode: error instanceof ArtifactError ? error.code : "internal_error",
|
|
192
197
|
durationMs: Math.round(performance.now() - startedAt),
|
package/dist/config.js
CHANGED
|
@@ -81,10 +81,10 @@ function parseLogLevel(value) {
|
|
|
81
81
|
throw new Error(`Invalid FORGERELAY_LOG_LEVEL: ${value}`);
|
|
82
82
|
}
|
|
83
83
|
function parseLogFormat(value) {
|
|
84
|
-
if (!value || value === "
|
|
85
|
-
return "json";
|
|
86
|
-
if (value === "pretty")
|
|
84
|
+
if (!value || value === "pretty")
|
|
87
85
|
return "pretty";
|
|
86
|
+
if (value === "json")
|
|
87
|
+
return "json";
|
|
88
88
|
throw new Error(`Invalid FORGERELAY_LOG_FORMAT: ${value}`);
|
|
89
89
|
}
|
|
90
90
|
function parsePathList(value) {
|
|
@@ -110,15 +110,17 @@ function parsePositiveInteger(value, fallback, name, max = Number.MAX_SAFE_INTEG
|
|
|
110
110
|
return parsed;
|
|
111
111
|
}
|
|
112
112
|
function parseLoggingConfig(env) {
|
|
113
|
+
const format = parseLogFormat(productEnv(env, "LOG_FORMAT"));
|
|
113
114
|
const requests = productEnv(env, "LOG_REQUESTS");
|
|
114
115
|
const toolCalls = productEnv(env, "LOG_TOOL_CALLS");
|
|
116
|
+
const shellCommands = productEnv(env, "LOG_SHELL_COMMANDS");
|
|
115
117
|
return {
|
|
116
118
|
level: parseLogLevel(productEnv(env, "LOG_LEVEL")),
|
|
117
|
-
format
|
|
118
|
-
requests: requests === undefined ?
|
|
119
|
+
format,
|
|
120
|
+
requests: requests === undefined ? format === "json" : parseBoolean(requests),
|
|
119
121
|
assets: parseBoolean(productEnv(env, "LOG_ASSETS")),
|
|
120
122
|
toolCalls: toolCalls === undefined ? true : parseBoolean(toolCalls),
|
|
121
|
-
shellCommands:
|
|
123
|
+
shellCommands: shellCommands === undefined ? format === "pretty" : parseBoolean(shellCommands),
|
|
122
124
|
trustProxy: parseBoolean(productEnv(env, "TRUST_PROXY")),
|
|
123
125
|
};
|
|
124
126
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { lstat, realpath, rename, rm, rmdir, unlink } from "node:fs/promises";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { expandHomePath, resolveCanonicalAllowedPath } from "./roots.js";
|
|
4
|
+
export async function renamePath(input, context) {
|
|
5
|
+
const source = await resolveCanonicalAllowedPath(input.path, context.cwd, context.allowedRoots);
|
|
6
|
+
await assertNotAllowedRootItself(source, context.allowedRoots, input.path);
|
|
7
|
+
const destination = await resolveCanonicalAllowedPath(input.newPath, context.cwd, context.allowedRoots);
|
|
8
|
+
await assertDestinationMissing(destination, input.newPath);
|
|
9
|
+
await lstat(source);
|
|
10
|
+
await rename(source, destination);
|
|
11
|
+
return { path: input.path, newPath: input.newPath };
|
|
12
|
+
}
|
|
13
|
+
export async function deletePath(input, context) {
|
|
14
|
+
const path = await resolveCanonicalAllowedPath(input.path, context.cwd, context.allowedRoots);
|
|
15
|
+
await assertNotAllowedRootItself(path, context.allowedRoots, input.path);
|
|
16
|
+
const entry = await lstat(path);
|
|
17
|
+
const recursive = input.recursive ?? false;
|
|
18
|
+
if (entry.isDirectory()) {
|
|
19
|
+
if (recursive) {
|
|
20
|
+
await rm(path, { recursive: true, force: false });
|
|
21
|
+
}
|
|
22
|
+
else {
|
|
23
|
+
await rmdir(path);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
else {
|
|
27
|
+
await unlink(path);
|
|
28
|
+
}
|
|
29
|
+
return { path: input.path, recursive };
|
|
30
|
+
}
|
|
31
|
+
async function assertDestinationMissing(path, inputPath) {
|
|
32
|
+
try {
|
|
33
|
+
await lstat(path);
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
if (error.code === "ENOENT")
|
|
37
|
+
return;
|
|
38
|
+
throw error;
|
|
39
|
+
}
|
|
40
|
+
throw new Error(`Rename destination already exists: ${inputPath}`);
|
|
41
|
+
}
|
|
42
|
+
async function assertNotAllowedRootItself(path, allowedRoots, inputPath) {
|
|
43
|
+
const canonicalPath = await realpath(path);
|
|
44
|
+
for (const root of allowedRoots) {
|
|
45
|
+
const canonicalRoot = await realpath(resolve(expandHomePath(root)));
|
|
46
|
+
if (canonicalPath === canonicalRoot) {
|
|
47
|
+
throw new Error(`Cannot rename or delete an allowed root itself: ${inputPath}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
package/dist/hooks.js
CHANGED
|
@@ -2,7 +2,7 @@ import { spawn } from "node:child_process";
|
|
|
2
2
|
import { readFile, readdir } from "node:fs/promises";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { performance } from "node:perf_hooks";
|
|
5
|
-
import { commandPreview, logEvent } from "./logger.js";
|
|
5
|
+
import { commandPreview, logEvent, workspaceLogLabel } from "./logger.js";
|
|
6
6
|
import { resolveShellCommand, terminateProcessTree } from "./process-platform.js";
|
|
7
7
|
export const HOOK_EVENTS = [
|
|
8
8
|
"WorkspaceOpen",
|
|
@@ -224,6 +224,9 @@ export class HookRunner {
|
|
|
224
224
|
hookName: execution.name,
|
|
225
225
|
hookScope: execution.scope,
|
|
226
226
|
workspaceId: invocation.workspaceId,
|
|
227
|
+
workspace: invocation.workspaceId
|
|
228
|
+
? workspaceLogLabel(invocation.workspaceRoot, invocation.workspaceId)
|
|
229
|
+
: invocation.workspaceRoot,
|
|
227
230
|
success: execution.status === "passed",
|
|
228
231
|
durationMs: execution.durationMs,
|
|
229
232
|
error: execution.error,
|
package/dist/logger.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { basename } from "node:path";
|
|
2
|
+
import { styleText } from "node:util";
|
|
1
3
|
const LEVEL_WEIGHT = {
|
|
2
4
|
silent: 0,
|
|
3
5
|
error: 1,
|
|
@@ -5,6 +7,12 @@ const LEVEL_WEIGHT = {
|
|
|
5
7
|
info: 3,
|
|
6
8
|
debug: 4,
|
|
7
9
|
};
|
|
10
|
+
const LEVEL_STYLE = {
|
|
11
|
+
error: "red",
|
|
12
|
+
warn: "yellow",
|
|
13
|
+
info: "green",
|
|
14
|
+
debug: "gray",
|
|
15
|
+
};
|
|
8
16
|
export function shouldLog(config, level) {
|
|
9
17
|
return LEVEL_WEIGHT[config.level] >= LEVEL_WEIGHT[level];
|
|
10
18
|
}
|
|
@@ -17,7 +25,10 @@ export function logEvent(config, level, event, fields = {}) {
|
|
|
17
25
|
event,
|
|
18
26
|
...fields,
|
|
19
27
|
};
|
|
20
|
-
const
|
|
28
|
+
const stream = level === "error" || level === "warn" ? process.stderr : process.stdout;
|
|
29
|
+
const line = config.format === "pretty"
|
|
30
|
+
? formatPrettyLogEntry(entry, { colorize: true, stream })
|
|
31
|
+
: JSON.stringify(entry);
|
|
21
32
|
if (level === "error") {
|
|
22
33
|
console.error(line);
|
|
23
34
|
}
|
|
@@ -45,25 +56,156 @@ export function requestPath(req) {
|
|
|
45
56
|
export function sessionIdPrefix(sessionId) {
|
|
46
57
|
return sessionId ? sessionId.slice(0, 8) : undefined;
|
|
47
58
|
}
|
|
59
|
+
export function workspaceLogLabel(root, workspaceId) {
|
|
60
|
+
const shortWorkspaceId = workspaceId.startsWith("ws_")
|
|
61
|
+
? `ws_${workspaceId.slice(3, 11)}`
|
|
62
|
+
: workspaceId.slice(0, 8);
|
|
63
|
+
return `${basename(root)}/${shortWorkspaceId}`;
|
|
64
|
+
}
|
|
48
65
|
export function commandPreview(command) {
|
|
49
66
|
const normalized = command.replace(/\s+/g, " ").trim();
|
|
50
67
|
return normalized.length > 120 ? `${normalized.slice(0, 117)}...` : normalized;
|
|
51
68
|
}
|
|
69
|
+
export function formatPrettyLogEntry(entry, options = {}) {
|
|
70
|
+
const level = logLevel(entry.level);
|
|
71
|
+
const time = formatTimestamp(entry.ts);
|
|
72
|
+
const source = stringField(entry.workspace) ?? stringField(entry.workspaceId) ?? "forgerelay";
|
|
73
|
+
const session = stringField(entry.session) ?? stringField(entry.sessionIdPrefix);
|
|
74
|
+
const prefix = [
|
|
75
|
+
style("gray", time, options),
|
|
76
|
+
`[${style(LEVEL_STYLE[level], level.toUpperCase(), options)}]`,
|
|
77
|
+
style(["cyan", "underline"], source, options),
|
|
78
|
+
session ? style("gray", `session:${session}`, options) : undefined,
|
|
79
|
+
style("gray", "|", options),
|
|
80
|
+
].filter((value) => Boolean(value)).join(" ");
|
|
81
|
+
return `${prefix} ${formatPrettyMessage(entry, options)}`;
|
|
82
|
+
}
|
|
52
83
|
function firstHeaderValue(value) {
|
|
53
84
|
return value?.split(",")[0]?.trim() || undefined;
|
|
54
85
|
}
|
|
55
|
-
function
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
86
|
+
function formatPrettyMessage(entry, options) {
|
|
87
|
+
switch (String(entry.event)) {
|
|
88
|
+
case "tool_call":
|
|
89
|
+
case "artifact_tool_call":
|
|
90
|
+
return formatToolMessage(entry, options);
|
|
91
|
+
case "hook_call":
|
|
92
|
+
return formatHookMessage(entry, options);
|
|
93
|
+
case "http_request":
|
|
94
|
+
return formatHttpMessage(entry, options);
|
|
95
|
+
case "mcp_session_created":
|
|
96
|
+
return `session ${stringField(entry.sessionIdPrefix) ?? "unknown"} created`;
|
|
97
|
+
case "mcp_session_closed":
|
|
98
|
+
return `session ${stringField(entry.sessionIdPrefix) ?? "unknown"} closed`;
|
|
99
|
+
case "mcp_session_close_failed":
|
|
100
|
+
return `session ${stringField(entry.sessionIdPrefix) ?? "unknown"} close -> ${style("red", "error", options)}`;
|
|
101
|
+
case "auth_denied":
|
|
102
|
+
return `auth denied${entry.reason ? `: ${String(entry.reason)}` : ""}`;
|
|
103
|
+
case "mcp_request_error":
|
|
104
|
+
return `mcp request -> ${style("red", `error${entry.error ? `: ${String(entry.error)}` : ""}`, options)}`;
|
|
105
|
+
default:
|
|
106
|
+
return formatGenericMessage(entry);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
function formatToolMessage(entry, options) {
|
|
110
|
+
const tool = stringField(entry.tool) ?? String(entry.event ?? "tool");
|
|
111
|
+
const target = toolTarget(entry, tool);
|
|
112
|
+
const operation = target ? `${tool} ${target}` : tool;
|
|
113
|
+
const result = toolResult(entry, tool, options);
|
|
114
|
+
return `${operation} -> ${result}`;
|
|
115
|
+
}
|
|
116
|
+
function toolTarget(entry, tool) {
|
|
117
|
+
if (isShellTool(tool)) {
|
|
118
|
+
return stringField(entry.commandPreview) ?? stringField(entry.workingDirectory);
|
|
119
|
+
}
|
|
120
|
+
return stringField(entry.path) ?? stringField(entry.workingDirectory);
|
|
121
|
+
}
|
|
122
|
+
function toolResult(entry, tool, options) {
|
|
123
|
+
if (entry.running === true) {
|
|
124
|
+
const processSessionId = entry.processSessionId;
|
|
125
|
+
return style("yellow", processSessionId === undefined ? "running" : `running process:${String(processSessionId)}`, options);
|
|
126
|
+
}
|
|
127
|
+
const exitCode = numberField(entry.exitCode) ?? exitCodeFromError(entry.error);
|
|
128
|
+
if (isShellTool(tool)) {
|
|
129
|
+
if (exitCode !== undefined) {
|
|
130
|
+
return style(exitCode === 0 ? "green" : "red", `exit=${exitCode}`, options);
|
|
131
|
+
}
|
|
132
|
+
if (entry.success === true)
|
|
133
|
+
return style("green", "exit=0", options);
|
|
134
|
+
}
|
|
135
|
+
if (entry.success === false) {
|
|
136
|
+
const error = stringField(entry.error);
|
|
137
|
+
return style("red", error ? `error: ${error}` : "error", options);
|
|
138
|
+
}
|
|
139
|
+
if (entry.success === true)
|
|
140
|
+
return style("green", "ok", options);
|
|
141
|
+
return "done";
|
|
142
|
+
}
|
|
143
|
+
function formatHookMessage(entry, options) {
|
|
144
|
+
const name = stringField(entry.hookName) ?? "hook";
|
|
145
|
+
const event = stringField(entry.hookEvent);
|
|
146
|
+
const operation = event ? `hook ${name} ${event}` : `hook ${name}`;
|
|
147
|
+
const exitCode = exitCodeFromHookError(entry.error);
|
|
148
|
+
if (entry.success === false) {
|
|
149
|
+
if (exitCode !== undefined)
|
|
150
|
+
return `${operation} -> ${style("red", `exit=${exitCode}`, options)}`;
|
|
151
|
+
const error = stringField(entry.error);
|
|
152
|
+
return `${operation} -> ${style("red", error ? `error: ${error}` : "error", options)}`;
|
|
153
|
+
}
|
|
154
|
+
return `${operation} -> ${style("green", "exit=0", options)}`;
|
|
155
|
+
}
|
|
156
|
+
function formatHttpMessage(entry, options) {
|
|
157
|
+
const method = stringField(entry.method) ?? "HTTP";
|
|
158
|
+
const path = stringField(entry.path) ?? "/";
|
|
159
|
+
const status = numberField(entry.status);
|
|
160
|
+
const statusText = status === undefined ? "done" : String(status);
|
|
161
|
+
const statusStyle = status !== undefined && status >= 400 ? "red" : "green";
|
|
162
|
+
return `http ${method} ${path} -> ${style(statusStyle, statusText, options)}`;
|
|
163
|
+
}
|
|
164
|
+
function formatGenericMessage(entry) {
|
|
165
|
+
const event = String(entry.event ?? "log");
|
|
166
|
+
const detail = [entry.reason, entry.error]
|
|
167
|
+
.map((value) => stringField(value))
|
|
168
|
+
.find((value) => value !== undefined);
|
|
169
|
+
return detail ? `${event}: ${detail}` : event;
|
|
170
|
+
}
|
|
171
|
+
function formatTimestamp(value) {
|
|
172
|
+
const date = new Date(String(value));
|
|
173
|
+
if (Number.isNaN(date.getTime()))
|
|
174
|
+
return String(value ?? "");
|
|
175
|
+
const two = (part) => String(part).padStart(2, "0");
|
|
176
|
+
return `${two(date.getMonth() + 1)}-${two(date.getDate())} ${two(date.getHours())}:${two(date.getMinutes())}:${two(date.getSeconds())}`;
|
|
177
|
+
}
|
|
178
|
+
function logLevel(value) {
|
|
179
|
+
return value === "error" || value === "warn" || value === "debug" ? value : "info";
|
|
180
|
+
}
|
|
181
|
+
function stringField(value) {
|
|
182
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
183
|
+
}
|
|
184
|
+
function numberField(value) {
|
|
185
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
186
|
+
}
|
|
187
|
+
function exitCodeFromError(value) {
|
|
188
|
+
const error = stringField(value);
|
|
189
|
+
if (!error)
|
|
190
|
+
return undefined;
|
|
191
|
+
const match = error.match(/Command exited with code (-?\d+)/i);
|
|
192
|
+
return match ? Number(match[1]) : undefined;
|
|
193
|
+
}
|
|
194
|
+
function exitCodeFromHookError(value) {
|
|
195
|
+
const error = stringField(value);
|
|
196
|
+
if (!error)
|
|
197
|
+
return undefined;
|
|
198
|
+
const match = error.match(/exited with code (-?\d+)/i);
|
|
199
|
+
return match ? Number(match[1]) : undefined;
|
|
200
|
+
}
|
|
201
|
+
function isShellTool(tool) {
|
|
202
|
+
return tool === "bash" || tool === "exec_command" || tool === "write_stdin";
|
|
203
|
+
}
|
|
204
|
+
function style(format, text, options) {
|
|
205
|
+
if (options.colorize !== true)
|
|
206
|
+
return text;
|
|
207
|
+
return styleText(format, text, {
|
|
208
|
+
validateStream: options.validateStream ?? true,
|
|
209
|
+
stream: options.stream ?? process.stdout,
|
|
210
|
+
});
|
|
69
211
|
}
|
|
@@ -4,6 +4,8 @@ export const toolNames = {
|
|
|
4
4
|
read: "read",
|
|
5
5
|
write: "write",
|
|
6
6
|
edit: "edit",
|
|
7
|
+
rename: "rename",
|
|
8
|
+
delete: "delete",
|
|
7
9
|
grep: "grep",
|
|
8
10
|
glob: "glob",
|
|
9
11
|
ls: "ls",
|
|
@@ -20,18 +22,20 @@ export function buildToolDescriptions(config) {
|
|
|
20
22
|
? ` In minimal tool mode, ${toolNames.grep}, ${toolNames.glob}, and ${toolNames.ls} are disabled, so shell commands may be used for equivalent search and directory inspection.`
|
|
21
23
|
: "";
|
|
22
24
|
return {
|
|
23
|
-
read: `Read a file inside an open workspace. Instruction files returned by ${toolNames.openWorkspace} and advertised skill files are also readable when applicable.${skillCapability} Call ${toolNames.openWorkspace} first and pass workspaceId.`,
|
|
24
|
-
write: `Create or completely overwrite a file inside an open workspace.
|
|
25
|
-
edit: `Edit one file inside an open workspace by replacing exact text blocks. Each oldText must match a unique, non-overlapping region of the original file. Call ${toolNames.openWorkspace} first and pass workspaceId.`,
|
|
26
|
-
|
|
27
|
-
|
|
25
|
+
read: `Read a file inside an open workspace or the OS temp directory. Instruction files returned by ${toolNames.openWorkspace} and advertised skill files are also readable when applicable.${skillCapability} Call ${toolNames.openWorkspace} first and pass workspaceId.`,
|
|
26
|
+
write: `Create or completely overwrite a file inside an open workspace or the OS temp directory. Workspace paths may be relative; OS temp paths may be absolute. Call ${toolNames.openWorkspace} first and pass workspaceId.`,
|
|
27
|
+
edit: `Edit one file inside an open workspace or the OS temp directory by replacing exact text blocks. Each oldText must match a unique, non-overlapping region of the original file. Workspace paths may be relative; OS temp paths may be absolute. Call ${toolNames.openWorkspace} first and pass workspaceId.`,
|
|
28
|
+
rename: `Rename or move one file or directory inside an open workspace or the OS temp directory without overwriting an existing destination. Source and destination must both remain inside the permitted file roots. Call ${toolNames.openWorkspace} first and pass workspaceId.`,
|
|
29
|
+
delete: `Delete one file or directory inside an open workspace or the OS temp directory. Non-empty directories require recursive=true. An allowed root itself cannot be deleted. Call ${toolNames.openWorkspace} first and pass workspaceId.`,
|
|
30
|
+
applyPatch: `Apply one Codex-style patch inside an open workspace or the OS temp directory. Supports adding, overwriting, updating, deleting, and moving files. Workspace paths must remain relative; absolute paths are accepted only inside the OS temp directory. Call ${toolNames.openWorkspace} first and pass workspaceId.`,
|
|
31
|
+
shell: `Run a shell command inside an open workspace.${shellSurface} Commands execute with the local user's authority; workspace filesystem containment does not make shell execution a sandbox. Do not use ${toolNames.shell} to create, move, rename, or delete project files; use ${toolNames.edit}, ${toolNames.write}, ${toolNames.rename}, or ${toolNames.delete} for file changes. Call ${toolNames.openWorkspace} first and pass workspaceId. This capability should only be exposed behind strong authentication.`,
|
|
28
32
|
shellCommand: "Shell command to run with the local user's authority.",
|
|
29
33
|
};
|
|
30
34
|
}
|
|
31
35
|
function capabilityContractInstructions(config, context) {
|
|
32
36
|
const workspaceLifecycle = config.toolMode === "codex"
|
|
33
37
|
? `Use ForgeRelay as a local coding workspace. Default to the user's existing checkout and reuse its workspaceId. Only open mode=\"worktree\" when the user explicitly asks for isolated or parallel work. Managed worktrees use dedicated forgerelay/* branches, not detached HEADs. When work in a managed worktree is complete and verified, call ${toolNames.closeWorktree}; it commits remaining worktree changes, fast-forwards the original target branch only when safe, then removes the worktree and its branch. If the target branch diverged or the source checkout is dirty, closing is refused and the worktree is preserved.`
|
|
34
|
-
: `Use ForgeRelay as a local coding workspace. Default to the user's existing checkout and reuse its workspaceId for later file, search, edit, write, show-changes, and shell tools. Only open mode=\"worktree\" when the user explicitly asks for isolated or parallel work. Managed worktrees use dedicated forgerelay/* branches, not detached HEADs. When work in a managed worktree is complete and verified, call ${toolNames.closeWorktree}; it commits remaining worktree changes, fast-forwards the original target branch only when safe, then removes the worktree and its branch. If the target branch diverged or the source checkout is dirty, closing is refused and the worktree is preserved.`;
|
|
38
|
+
: `Use ForgeRelay as a local coding workspace. Default to the user's existing checkout and reuse its workspaceId for later file, search, edit, write, rename, delete, show-changes, and shell tools. Only open mode=\"worktree\" when the user explicitly asks for isolated or parallel work. Managed worktrees use dedicated forgerelay/* branches, not detached HEADs. When work in a managed worktree is complete and verified, call ${toolNames.closeWorktree}; it commits remaining worktree changes, fast-forwards the original target branch only when safe, then removes the worktree and its branch. If the target branch diverged or the source checkout is dirty, closing is refused and the worktree is preserved.`;
|
|
35
39
|
const agents = `Follow instructions returned by ${toolNames.openWorkspace}. Before working under a path listed in availableAgentsFiles, use ${toolNames.read} to inspect that instruction file and follow it.`;
|
|
36
40
|
const skills = config.skillsEnabled
|
|
37
41
|
? `When ${toolNames.openWorkspace} returns available skills and a task matches a skill, use ${toolNames.read} to read that skill's path before proceeding. Skill paths may be outside the workspace, but ${toolNames.read} only permits advertised SKILL.md files and files under already-loaded skill directories.`
|
|
@@ -48,7 +52,7 @@ function capabilityContractInstructions(config, context) {
|
|
|
48
52
|
}
|
|
49
53
|
function toolSurfaceInstructions(config) {
|
|
50
54
|
if (config.toolMode === "codex") {
|
|
51
|
-
return `In codex tool mode, workspace file and command operations use ${toolNames.read}, apply_patch, exec_command, and write_stdin.`;
|
|
55
|
+
return `In codex tool mode, workspace file and command operations use ${toolNames.read}, ${toolNames.rename}, ${toolNames.delete}, apply_patch, exec_command, and write_stdin.`;
|
|
52
56
|
}
|
|
53
57
|
if (config.toolMode === "full") {
|
|
54
58
|
return `In full tool mode, dedicated ${toolNames.grep}, ${toolNames.glob}, and ${toolNames.ls} inspection tools are available alongside the core workspace tools.`;
|
|
@@ -64,12 +68,12 @@ function selectedWorkflowInstructions(config) {
|
|
|
64
68
|
}
|
|
65
69
|
function defaultWorkflowInstructions(config) {
|
|
66
70
|
if (config.toolMode === "codex") {
|
|
67
|
-
return `Use ${toolNames.read} for direct file reads, apply_patch for
|
|
71
|
+
return `Use ${toolNames.read} for direct file reads, ${toolNames.rename} and ${toolNames.delete} for direct path moves or removals, apply_patch for content modifications, exec_command for inspection, tests, builds, and other commands, and write_stdin to poll or interact with running processes.`;
|
|
68
72
|
}
|
|
69
73
|
const inspection = config.toolMode === "full"
|
|
70
74
|
? `Prefer ${toolNames.read}, ${toolNames.grep}, ${toolNames.glob}, and ${toolNames.ls} for file inspection.`
|
|
71
75
|
: `Use ${toolNames.shell} with command-line tools such as grep, rg, find, ls, and tree for search and directory inspection.`;
|
|
72
|
-
return joinInstructions(inspection, `Prefer ${toolNames.edit} for targeted modifications, ${toolNames.write} only for new files or complete rewrites, and ${toolNames.shell} for tests, builds, git inspection, package scripts, and commands that are better executed by the shell.`, `Do not create or modify files with ${toolNames.shell}; avoid shell redirection, heredocs, tee, sed -i, perl -i, node/python/ruby scripts, or any command whose purpose is to write project files.`);
|
|
76
|
+
return joinInstructions(inspection, `Prefer ${toolNames.edit} for targeted content modifications, ${toolNames.write} only for new files or complete rewrites, ${toolNames.rename} for path moves, ${toolNames.delete} for removals, and ${toolNames.shell} for tests, builds, git inspection, package scripts, and commands that are better executed by the shell.`, `Do not create or modify files with ${toolNames.shell}; avoid shell redirection, heredocs, tee, sed -i, perl -i, node/python/ruby scripts, or any command whose purpose is to write project files.`);
|
|
73
77
|
}
|
|
74
78
|
function joinInstructions(...parts) {
|
|
75
79
|
return parts
|
package/dist/pi-tools.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createBashTool, createEditTool, createFindTool, createGrepTool, createLsTool, createReadTool, createWriteTool, } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import {
|
|
2
|
+
import { resolveCanonicalAllowedPath } from "./roots.js";
|
|
3
3
|
function toMcpContent(result) {
|
|
4
4
|
return result.content.map((content) => {
|
|
5
5
|
if (content.type === "text") {
|
|
@@ -29,7 +29,7 @@ async function runTool(execute, input, context) {
|
|
|
29
29
|
}
|
|
30
30
|
}
|
|
31
31
|
export async function readFileTool(input, context) {
|
|
32
|
-
const path =
|
|
32
|
+
const path = await resolveCanonicalAllowedPath(input.path, context.cwd, context.readRoots ?? [context.root]);
|
|
33
33
|
const tool = createReadTool(context.cwd);
|
|
34
34
|
return runTool((params) => tool.execute("read_file", params), {
|
|
35
35
|
path,
|
|
@@ -38,7 +38,7 @@ export async function readFileTool(input, context) {
|
|
|
38
38
|
}, context);
|
|
39
39
|
}
|
|
40
40
|
export async function writeFileTool(input, context) {
|
|
41
|
-
const path =
|
|
41
|
+
const path = await resolveCanonicalAllowedPath(input.path, context.cwd, context.fileRoots ?? [context.root]);
|
|
42
42
|
const tool = createWriteTool(context.cwd);
|
|
43
43
|
return runTool((params) => tool.execute("write_file", params), {
|
|
44
44
|
path,
|
|
@@ -46,7 +46,7 @@ export async function writeFileTool(input, context) {
|
|
|
46
46
|
}, context);
|
|
47
47
|
}
|
|
48
48
|
export async function editFileTool(input, context) {
|
|
49
|
-
const path =
|
|
49
|
+
const path = await resolveCanonicalAllowedPath(input.path, context.cwd, context.fileRoots ?? [context.root]);
|
|
50
50
|
const tool = createEditTool(context.cwd);
|
|
51
51
|
return runTool((params) => tool.execute("edit_file", params), {
|
|
52
52
|
path,
|
|
@@ -54,22 +54,23 @@ export async function editFileTool(input, context) {
|
|
|
54
54
|
}, context);
|
|
55
55
|
}
|
|
56
56
|
export async function grepFilesTool(input, context) {
|
|
57
|
-
|
|
58
|
-
|
|
57
|
+
const path = input.path
|
|
58
|
+
? await resolveCanonicalAllowedPath(input.path, context.cwd, context.fileRoots ?? [context.root])
|
|
59
|
+
: undefined;
|
|
59
60
|
const tool = createGrepTool(context.cwd);
|
|
60
|
-
return runTool((params) => tool.execute("grep_files", params), input, context);
|
|
61
|
+
return runTool((params) => tool.execute("grep_files", params), { ...input, path }, context);
|
|
61
62
|
}
|
|
62
63
|
export async function findFilesTool(input, context) {
|
|
63
|
-
|
|
64
|
-
|
|
64
|
+
const path = input.path
|
|
65
|
+
? await resolveCanonicalAllowedPath(input.path, context.cwd, context.fileRoots ?? [context.root])
|
|
66
|
+
: undefined;
|
|
65
67
|
const tool = createFindTool(context.cwd);
|
|
66
|
-
return runTool((params) => tool.execute("find_files", params), input, context);
|
|
68
|
+
return runTool((params) => tool.execute("find_files", params), { ...input, path }, context);
|
|
67
69
|
}
|
|
68
70
|
export async function listDirectoryTool(input, context) {
|
|
69
|
-
|
|
70
|
-
resolveAllowedPath(input.path, context.cwd, [context.root]);
|
|
71
|
+
const path = await resolveCanonicalAllowedPath(input.path ?? ".", context.cwd, context.fileRoots ?? [context.root]);
|
|
71
72
|
const tool = createLsTool(context.cwd);
|
|
72
|
-
return runTool((params) => tool.execute("list_directory", params), input, context);
|
|
73
|
+
return runTool((params) => tool.execute("list_directory", params), { ...input, path }, context);
|
|
73
74
|
}
|
|
74
75
|
export async function runShellTool(input, context) {
|
|
75
76
|
const tool = createBashTool(context.cwd);
|
package/dist/roots.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { realpath } from "node:fs/promises";
|
|
1
2
|
import { homedir } from "node:os";
|
|
2
|
-
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
3
|
+
import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
3
4
|
export class AccessDeniedError extends Error {
|
|
4
5
|
constructor(message) {
|
|
5
6
|
super(message);
|
|
@@ -35,3 +36,31 @@ export function resolveAllowedPath(inputPath, cwd, allowedRoots) {
|
|
|
35
36
|
const absolutePath = resolve(cwd, inputPath);
|
|
36
37
|
return assertAllowedPath(absolutePath, allowedRoots);
|
|
37
38
|
}
|
|
39
|
+
export async function resolveCanonicalAllowedPath(inputPath, cwd, allowedRoots) {
|
|
40
|
+
const absolutePath = resolveAllowedPath(inputPath, cwd, allowedRoots);
|
|
41
|
+
const canonicalPath = await canonicalizePath(absolutePath);
|
|
42
|
+
const canonicalRoots = await Promise.all(allowedRoots.map((root) => canonicalizePath(resolve(expandHomePath(root)))));
|
|
43
|
+
if (canonicalRoots.some((root) => isPathInsideRoot(canonicalPath, root))) {
|
|
44
|
+
return absolutePath;
|
|
45
|
+
}
|
|
46
|
+
throw new AccessDeniedError(`Path is outside allowed roots: ${inputPath}`);
|
|
47
|
+
}
|
|
48
|
+
async function canonicalizePath(path) {
|
|
49
|
+
const missingSegments = [];
|
|
50
|
+
let candidate = path;
|
|
51
|
+
while (true) {
|
|
52
|
+
try {
|
|
53
|
+
return resolve(await realpath(candidate), ...missingSegments.slice().reverse());
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
const code = error.code;
|
|
57
|
+
if (code !== "ENOENT" && code !== "ENOTDIR")
|
|
58
|
+
throw error;
|
|
59
|
+
const parent = dirname(candidate);
|
|
60
|
+
if (parent === candidate)
|
|
61
|
+
return resolve(path);
|
|
62
|
+
missingSegments.push(basename(candidate));
|
|
63
|
+
candidate = parent;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|