@akira-tl/forgerelay 0.2.0 → 0.2.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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,20 @@ All notable ForgeRelay changes are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.2.1] - 2026-08-09
8
+
9
+ ### Changed
10
+
11
+ - 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.
12
+
13
+ ### Fixed
14
+
15
+ - 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.
16
+
17
+ ### Security
18
+
19
+ - 文件工具在 workspace 与 OS temp 边界内都会校验 canonical path,阻止通过 symlink 跳转到任意文件系统位置;shell cwd 与 workspace-open allowed roots 不随 temp 文件访问而扩大。
20
+
7
21
  ## [0.2.0] - 2026-08-09
8
22
 
9
23
  ### Added
@@ -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") || isAbsolute(input)) {
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);
@@ -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, async () => {
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 === "json")
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: parseLogFormat(productEnv(env, "LOG_FORMAT")),
118
- requests: requests === undefined ? true : parseBoolean(requests),
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: parseBoolean(productEnv(env, "LOG_SHELL_COMMANDS")),
123
+ shellCommands: shellCommands === undefined ? format === "pretty" : parseBoolean(shellCommands),
122
124
  trustProxy: parseBoolean(productEnv(env, "TRUST_PROXY")),
123
125
  };
124
126
  }
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 line = config.format === "pretty" ? formatPretty(entry) : JSON.stringify(entry);
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 formatPretty(entry) {
56
- const ts = String(entry.ts);
57
- const level = String(entry.level).toUpperCase();
58
- const event = String(entry.event);
59
- const rest = Object.entries(entry)
60
- .filter(([key, value]) => !["ts", "level", "event"].includes(key) && value !== undefined)
61
- .map(([key, value]) => `${key}=${formatPrettyValue(value)}`)
62
- .join(" ");
63
- return rest ? `${ts} ${level} ${event} ${rest}` : `${ts} ${level} ${event}`;
64
- }
65
- function formatPrettyValue(value) {
66
- if (typeof value === "string")
67
- return JSON.stringify(value);
68
- return JSON.stringify(value);
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
  }
@@ -20,10 +20,10 @@ export function buildToolDescriptions(config) {
20
20
  ? ` 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
21
  : "";
22
22
  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. The path is resolved within the workspace filesystem boundary. Call ${toolNames.openWorkspace} first and pass workspaceId.`,
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
- applyPatch: `Apply one Codex-style patch inside an open workspace. Supports adding, overwriting, updating, deleting, and moving files. Paths must be relative to the workspace. Call ${toolNames.openWorkspace} first and pass workspaceId.`,
23
+ 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.`,
24
+ 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.`,
25
+ 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.`,
26
+ 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.`,
27
27
  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 or modify project files; use ${toolNames.edit} or ${toolNames.write} for file changes. Call ${toolNames.openWorkspace} first and pass workspaceId. This capability should only be exposed behind strong authentication.`,
28
28
  shellCommand: "Shell command to run with the local user's authority.",
29
29
  };
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 { resolveAllowedPath } from "./roots.js";
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 = resolveAllowedPath(input.path, context.cwd, context.readRoots ?? [context.root]);
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 = resolveAllowedPath(input.path, context.cwd, [context.root]);
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 = resolveAllowedPath(input.path, context.cwd, [context.root]);
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
- if (input.path)
58
- resolveAllowedPath(input.path, context.cwd, [context.root]);
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
- if (input.path)
64
- resolveAllowedPath(input.path, context.cwd, [context.root]);
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
- if (input.path)
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
+ }
package/dist/server.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { readFileSync } from "node:fs";
3
3
  import { access, realpath } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
4
5
  import { fileURLToPath } from "node:url";
5
6
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
6
7
  import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
@@ -18,7 +19,7 @@ import { loadConfig } from "./config.js";
18
19
  import { attachHookReports, HookRunner, runToolWithHooks } from "./hooks.js";
19
20
  import { buildServerInstructions, buildToolDescriptions, toolNames, } from "./mcp/server-instructions.js";
20
21
  import { createOpenAIIncomingArtifactAdapter, } from "./incoming-artifacts.js";
21
- import { logEvent, requestIp, requestPath, commandPreview, sessionIdPrefix, } from "./logger.js";
22
+ import { logEvent, requestIp, requestPath, commandPreview, sessionIdPrefix, workspaceLogLabel, } from "./logger.js";
22
23
  import { editFileTool, findFilesTool, grepFilesTool, listDirectoryTool, readFileTool, runShellTool, writeFileTool, } from "./pi-tools.js";
23
24
  import { SingleUserOAuthProvider } from "./oauth-provider.js";
24
25
  import { McpSessionRegistry, } from "./mcp-sessions.js";
@@ -78,6 +79,13 @@ function toolWidgetDescriptorMeta(config, kind) {
78
79
  },
79
80
  };
80
81
  }
82
+ function workspaceLogContext(workspace, sessionId) {
83
+ return {
84
+ workspaceId: workspace.id,
85
+ workspace: workspaceLogLabel(workspace.root, workspace.id),
86
+ session: sessionIdPrefix(sessionId),
87
+ };
88
+ }
81
89
  function formatVisibleAgent(agent) {
82
90
  const model = agent.model ? `, model ${agent.model}` : "";
83
91
  const thinking = agent.thinking ? `, thinking ${agent.thinking}` : "";
@@ -393,7 +401,7 @@ function registerCodexProcessTools(server, config, workspaces, processSessions,
393
401
  outputSchema: processOutputSchema(),
394
402
  ...toolWidgetDescriptorMeta(config, "shell"),
395
403
  annotations: SHELL_TOOL_ANNOTATIONS,
396
- }, async ({ workspaceId, cmd, tty, columns, rows, workingDirectory, yieldTimeMs, maxOutputTokens }) => {
404
+ }, async ({ workspaceId, cmd, tty, columns, rows, workingDirectory, yieldTimeMs, maxOutputTokens }, extra) => {
397
405
  const workspace = workspaces.getWorkspace(workspaceId);
398
406
  return runToolWithHooks(hooks, {
399
407
  tool: "exec_command",
@@ -415,11 +423,14 @@ function registerCodexProcessTools(server, config, workspaces, processSessions,
415
423
  });
416
424
  logToolCall(config, {
417
425
  tool: "exec_command",
418
- workspaceId,
426
+ ...workspaceLogContext(workspace, extra.sessionId),
419
427
  workingDirectory: workingDirectory ?? ".",
420
428
  command: cmd,
421
429
  commandLength: cmd.length,
422
- success: true,
430
+ exitCode: snapshot.exitCode,
431
+ running: snapshot.running,
432
+ processSessionId: snapshot.sessionId,
433
+ success: snapshot.running || snapshot.exitCode === 0,
423
434
  durationMs: Math.round(performance.now() - startedAt),
424
435
  });
425
436
  return processToolResponse("exec_command", workspaceId, snapshot, {
@@ -459,7 +470,7 @@ function registerCodexProcessTools(server, config, workspaces, processSessions,
459
470
  outputSchema: processOutputSchema(),
460
471
  ...toolWidgetDescriptorMeta(config, "shell"),
461
472
  annotations: SHELL_TOOL_ANNOTATIONS,
462
- }, async ({ workspaceId, sessionId, chars, columns, rows, yieldTimeMs, maxOutputTokens }) => {
473
+ }, async ({ workspaceId, sessionId, chars, columns, rows, yieldTimeMs, maxOutputTokens }, extra) => {
463
474
  const workspace = workspaces.getWorkspace(workspaceId);
464
475
  return runToolWithHooks(hooks, {
465
476
  tool: "write_stdin",
@@ -483,8 +494,11 @@ function registerCodexProcessTools(server, config, workspaces, processSessions,
483
494
  });
484
495
  logToolCall(config, {
485
496
  tool: "write_stdin",
486
- workspaceId,
487
- success: true,
497
+ ...workspaceLogContext(workspace, extra.sessionId),
498
+ exitCode: snapshot.exitCode,
499
+ running: snapshot.running,
500
+ processSessionId: snapshot.sessionId,
501
+ success: snapshot.running || snapshot.exitCode === 0,
488
502
  durationMs: Math.round(performance.now() - startedAt),
489
503
  });
490
504
  return processToolResponse("write_stdin", workspaceId, snapshot, {
@@ -597,7 +611,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
597
611
  idempotentHint: false,
598
612
  openWorldHint: false,
599
613
  },
600
- }, async ({ path, mode, baseRef, newWorktree }, { _meta }) => {
614
+ }, async ({ path, mode, baseRef, newWorktree }, { _meta, sessionId }) => {
601
615
  const startedAt = performance.now();
602
616
  const { workspace, agentsFiles, availableAgentsFiles, hookReports, workspaceReused, includeBootstrapContext, } = await workspaces.openWorkspace({ path, mode, baseRef, newWorktree }, { conversationScopeId: openAiConversationScopeId(_meta) });
603
617
  const knownWorktrees = await workspaces.listKnownWorktrees(workspace);
@@ -692,7 +706,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
692
706
  ];
693
707
  logToolCall(config, {
694
708
  tool: "open_workspace",
695
- workspaceId: workspace.id,
709
+ ...workspaceLogContext(workspace, sessionId),
696
710
  path: workspace.root,
697
711
  success: true,
698
712
  durationMs: Math.round(performance.now() - startedAt),
@@ -772,7 +786,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
772
786
  }),
773
787
  _meta: {},
774
788
  annotations: WRITE_TOOL_ANNOTATIONS,
775
- }, async ({ workspaceId, commitMessage }) => {
789
+ }, async ({ workspaceId, commitMessage }, extra) => {
776
790
  const workspace = workspaces.getWorkspace(workspaceId);
777
791
  return runToolWithHooks(hooks, {
778
792
  tool: toolNames.closeWorktree,
@@ -793,7 +807,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
793
807
  ].join("\n");
794
808
  logToolCall(config, {
795
809
  tool: toolNames.closeWorktree,
796
- workspaceId,
810
+ ...workspaceLogContext(workspace, extra.sessionId),
797
811
  path: closed.sourceRoot,
798
812
  success: true,
799
813
  durationMs: Math.round(performance.now() - startedAt),
@@ -825,8 +839,8 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
825
839
  path: z
826
840
  .string()
827
841
  .describe(config.skillsEnabled
828
- ? "File path to read, relative to the workspace root. May also be an advertised skill path from open_workspace skills."
829
- : "File path to read, relative to the workspace root."),
842
+ ? "File path to read, relative to the workspace root or absolute inside the OS temp directory. May also be an advertised skill path from open_workspace skills."
843
+ : "File path to read, relative to the workspace root or absolute inside the OS temp directory."),
830
844
  offset: z
831
845
  .number()
832
846
  .int()
@@ -843,7 +857,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
843
857
  outputSchema: resultOutputSchema(),
844
858
  ...toolWidgetDescriptorMeta(config, "read"),
845
859
  annotations: { readOnlyHint: true },
846
- }, async ({ workspaceId, ...input }) => {
860
+ }, async ({ workspaceId, ...input }, extra) => {
847
861
  const workspace = workspaces.getWorkspace(workspaceId);
848
862
  return runToolWithHooks(hooks, {
849
863
  tool: toolNames.read,
@@ -861,7 +875,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
861
875
  if (response.isError) {
862
876
  logFailedToolResponse(config, {
863
877
  tool: toolNames.read,
864
- workspaceId,
878
+ ...workspaceLogContext(workspace, extra.sessionId),
865
879
  path: input.path,
866
880
  }, response.content, startedAt);
867
881
  return response;
@@ -874,7 +888,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
874
888
  };
875
889
  logToolCall(config, {
876
890
  tool: toolNames.read,
877
- workspaceId,
891
+ ...workspaceLogContext(workspace, extra.sessionId),
878
892
  path: input.path,
879
893
  success: true,
880
894
  durationMs: Math.round(performance.now() - startedAt),
@@ -907,13 +921,13 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
907
921
  .describe("Workspace identifier returned by open_workspace."),
908
922
  path: z
909
923
  .string()
910
- .describe("File path to write, relative to the workspace root."),
924
+ .describe("File path to write, relative to the workspace root or absolute inside the OS temp directory."),
911
925
  content: z.string().describe("Complete new file content."),
912
926
  },
913
927
  outputSchema: resultOutputSchema(),
914
928
  ...toolWidgetDescriptorMeta(config, "write"),
915
929
  annotations: WRITE_TOOL_ANNOTATIONS,
916
- }, async ({ workspaceId, ...input }) => {
930
+ }, async ({ workspaceId, ...input }, extra) => {
917
931
  const workspace = workspaces.getWorkspace(workspaceId);
918
932
  return runToolWithHooks(hooks, {
919
933
  tool: toolNames.write,
@@ -923,15 +937,15 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
923
937
  changedPaths: (result) => toolResultIsError(result) ? [] : [input.path],
924
938
  operation: async () => {
925
939
  const startedAt = performance.now();
926
- workspaces.resolvePath(workspace, input.path);
927
940
  const response = await writeFileTool(input, {
928
941
  cwd: workspace.root,
929
942
  root: workspace.root,
943
+ fileRoots: workspaces.fileToolRoots(workspace),
930
944
  });
931
945
  if (response.isError) {
932
946
  logFailedToolResponse(config, {
933
947
  tool: toolNames.write,
934
- workspaceId,
948
+ ...workspaceLogContext(workspace, extra.sessionId),
935
949
  path: input.path,
936
950
  }, response.content, startedAt);
937
951
  return response;
@@ -945,7 +959,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
945
959
  };
946
960
  logToolCall(config, {
947
961
  tool: toolNames.write,
948
- workspaceId,
962
+ ...workspaceLogContext(workspace, extra.sessionId),
949
963
  path: input.path,
950
964
  success: true,
951
965
  durationMs: Math.round(performance.now() - startedAt),
@@ -980,7 +994,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
980
994
  .describe("Workspace identifier returned by open_workspace."),
981
995
  path: z
982
996
  .string()
983
- .describe("File path to edit, relative to the workspace root."),
997
+ .describe("File path to edit, relative to the workspace root or absolute inside the OS temp directory."),
984
998
  edits: z
985
999
  .array(z.object({
986
1000
  oldText: z
@@ -995,7 +1009,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
995
1009
  }),
996
1010
  ...toolWidgetDescriptorMeta(config, "edit"),
997
1011
  annotations: EDIT_TOOL_ANNOTATIONS,
998
- }, async ({ workspaceId, ...input }) => {
1012
+ }, async ({ workspaceId, ...input }, extra) => {
999
1013
  const workspace = workspaces.getWorkspace(workspaceId);
1000
1014
  return runToolWithHooks(hooks, {
1001
1015
  tool: toolNames.edit,
@@ -1005,15 +1019,15 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1005
1019
  changedPaths: (result) => toolResultIsError(result) ? [] : [input.path],
1006
1020
  operation: async () => {
1007
1021
  const startedAt = performance.now();
1008
- workspaces.resolvePath(workspace, input.path);
1009
1022
  const response = await editFileTool(input, {
1010
1023
  cwd: workspace.root,
1011
1024
  root: workspace.root,
1025
+ fileRoots: workspaces.fileToolRoots(workspace),
1012
1026
  });
1013
1027
  if (response.isError) {
1014
1028
  logFailedToolResponse(config, {
1015
1029
  tool: toolNames.edit,
1016
- workspaceId,
1030
+ ...workspaceLogContext(workspace, extra.sessionId),
1017
1031
  path: input.path,
1018
1032
  }, response.content, startedAt);
1019
1033
  return response;
@@ -1027,7 +1041,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1027
1041
  const editContent = [textBlock(editResultText)];
1028
1042
  logToolCall(config, {
1029
1043
  tool: toolNames.edit,
1030
- workspaceId,
1044
+ ...workspaceLogContext(workspace, extra.sessionId),
1031
1045
  path: input.path,
1032
1046
  success: true,
1033
1047
  durationMs: Math.round(performance.now() - startedAt),
@@ -1078,7 +1092,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1078
1092
  }),
1079
1093
  ...toolWidgetDescriptorMeta(config, "edit"),
1080
1094
  annotations: EDIT_TOOL_ANNOTATIONS,
1081
- }, async ({ workspaceId, patch }) => {
1095
+ }, async ({ workspaceId, patch }, extra) => {
1082
1096
  const workspace = workspaces.getWorkspace(workspaceId);
1083
1097
  return runToolWithHooks(hooks, {
1084
1098
  tool: "apply_patch",
@@ -1088,7 +1102,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1088
1102
  .filter((path) => Boolean(path)))),
1089
1103
  operation: async () => {
1090
1104
  const startedAt = performance.now();
1091
- const applied = await applyPatch(workspace.root, patch);
1105
+ const applied = await applyPatch(workspace.root, patch, [tmpdir()]);
1092
1106
  const paths = applied.files.map((file) => file.path).join(", ");
1093
1107
  const result = `Applied patch to ${applied.files.length} file(s): ${paths}`;
1094
1108
  const content = [textBlock(result)];
@@ -1097,7 +1111,8 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1097
1111
  : `${applied.files.length} files`;
1098
1112
  logToolCall(config, {
1099
1113
  tool: "apply_patch",
1100
- workspaceId,
1114
+ ...workspaceLogContext(workspace, extra.sessionId),
1115
+ path: displayPath,
1101
1116
  success: true,
1102
1117
  durationMs: Math.round(performance.now() - startedAt),
1103
1118
  });
@@ -1140,7 +1155,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1140
1155
  outputSchema: resultOutputSchema(),
1141
1156
  ...toolWidgetDescriptorMeta(config, "show_changes"),
1142
1157
  annotations: { readOnlyHint: true },
1143
- }, async ({ workspaceId }) => {
1158
+ }, async ({ workspaceId }, extra) => {
1144
1159
  const workspace = workspaces.getWorkspace(workspaceId);
1145
1160
  return runToolWithHooks(hooks, {
1146
1161
  tool: "show_changes",
@@ -1155,7 +1170,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1155
1170
  const content = [textBlock(review.result)];
1156
1171
  logToolCall(config, {
1157
1172
  tool: "show_changes",
1158
- workspaceId,
1173
+ ...workspaceLogContext(workspace, extra.sessionId),
1159
1174
  success: true,
1160
1175
  durationMs: Math.round(performance.now() - startedAt),
1161
1176
  });
@@ -1183,7 +1198,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1183
1198
  if (config.toolMode === "full") {
1184
1199
  registerAppTool(server, toolNames.grep, {
1185
1200
  title: "Grep",
1186
- description: "Search file contents inside an open workspace. Use this before broad reads when looking for symbols, text, or usage sites. Respects project ignore rules. Call open_workspace first and pass workspaceId.",
1201
+ description: "Search file contents inside an open workspace or the OS temp directory. Use this before broad reads when looking for symbols, text, or usage sites. Respects project ignore rules. Call open_workspace first and pass workspaceId.",
1187
1202
  inputSchema: {
1188
1203
  workspaceId: z
1189
1204
  .string()
@@ -1192,13 +1207,13 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1192
1207
  path: z
1193
1208
  .string()
1194
1209
  .optional()
1195
- .describe("Optional path or glob scope relative to the workspace root."),
1210
+ .describe("Optional path or glob scope relative to the workspace root, or an absolute path inside the OS temp directory."),
1196
1211
  include: z.string().optional().describe("Optional include glob."),
1197
1212
  },
1198
1213
  outputSchema: resultOutputSchema(),
1199
1214
  ...toolWidgetDescriptorMeta(config, "search"),
1200
1215
  annotations: { readOnlyHint: true },
1201
- }, async ({ workspaceId, ...input }) => {
1216
+ }, async ({ workspaceId, ...input }, extra) => {
1202
1217
  const workspace = workspaces.getWorkspace(workspaceId);
1203
1218
  return runToolWithHooks(hooks, {
1204
1219
  tool: toolNames.grep,
@@ -1207,16 +1222,15 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1207
1222
  isFailure: toolResultIsError,
1208
1223
  operation: async () => {
1209
1224
  const startedAt = performance.now();
1210
- if (input.path)
1211
- workspaces.resolvePath(workspace, input.path);
1212
1225
  const response = await grepFilesTool(input, {
1213
1226
  cwd: workspace.root,
1214
1227
  root: workspace.root,
1228
+ fileRoots: workspaces.fileToolRoots(workspace),
1215
1229
  });
1216
1230
  if (response.isError) {
1217
1231
  logFailedToolResponse(config, {
1218
1232
  tool: toolNames.grep,
1219
- workspaceId,
1233
+ ...workspaceLogContext(workspace, extra.sessionId),
1220
1234
  path: input.path,
1221
1235
  }, response.content, startedAt);
1222
1236
  return response;
@@ -1228,7 +1242,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1228
1242
  };
1229
1243
  logToolCall(config, {
1230
1244
  tool: toolNames.grep,
1231
- workspaceId,
1245
+ ...workspaceLogContext(workspace, extra.sessionId),
1232
1246
  path: input.path,
1233
1247
  success: true,
1234
1248
  durationMs: Math.round(performance.now() - startedAt),
@@ -1253,7 +1267,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1253
1267
  });
1254
1268
  registerAppTool(server, toolNames.glob, {
1255
1269
  title: "Glob",
1256
- description: "Find files by glob pattern inside an open workspace. Use this to discover filenames or narrow file sets before reading. Respects project ignore rules. Call open_workspace first and pass workspaceId.",
1270
+ description: "Find files by glob pattern inside an open workspace or the OS temp directory. Use this to discover filenames or narrow file sets before reading. Respects project ignore rules. Call open_workspace first and pass workspaceId.",
1257
1271
  inputSchema: {
1258
1272
  workspaceId: z
1259
1273
  .string()
@@ -1262,12 +1276,12 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1262
1276
  path: z
1263
1277
  .string()
1264
1278
  .optional()
1265
- .describe("Optional path scope relative to the workspace root."),
1279
+ .describe("Optional path scope relative to the workspace root, or an absolute path inside the OS temp directory."),
1266
1280
  },
1267
1281
  outputSchema: resultOutputSchema(),
1268
1282
  ...toolWidgetDescriptorMeta(config, "search"),
1269
1283
  annotations: { readOnlyHint: true },
1270
- }, async ({ workspaceId, ...input }) => {
1284
+ }, async ({ workspaceId, ...input }, extra) => {
1271
1285
  const workspace = workspaces.getWorkspace(workspaceId);
1272
1286
  return runToolWithHooks(hooks, {
1273
1287
  tool: toolNames.glob,
@@ -1276,16 +1290,15 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1276
1290
  isFailure: toolResultIsError,
1277
1291
  operation: async () => {
1278
1292
  const startedAt = performance.now();
1279
- if (input.path)
1280
- workspaces.resolvePath(workspace, input.path);
1281
1293
  const response = await findFilesTool(input, {
1282
1294
  cwd: workspace.root,
1283
1295
  root: workspace.root,
1296
+ fileRoots: workspaces.fileToolRoots(workspace),
1284
1297
  });
1285
1298
  if (response.isError) {
1286
1299
  logFailedToolResponse(config, {
1287
1300
  tool: toolNames.glob,
1288
- workspaceId,
1301
+ ...workspaceLogContext(workspace, extra.sessionId),
1289
1302
  path: input.path,
1290
1303
  }, response.content, startedAt);
1291
1304
  return response;
@@ -1297,7 +1310,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1297
1310
  };
1298
1311
  logToolCall(config, {
1299
1312
  tool: toolNames.glob,
1300
- workspaceId,
1313
+ ...workspaceLogContext(workspace, extra.sessionId),
1301
1314
  path: input.path,
1302
1315
  success: true,
1303
1316
  durationMs: Math.round(performance.now() - startedAt),
@@ -1322,19 +1335,19 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1322
1335
  });
1323
1336
  registerAppTool(server, toolNames.ls, {
1324
1337
  title: "Ls",
1325
- description: "List a directory inside an open workspace. Use this for directory inspection before reading files. Call open_workspace first and pass workspaceId.",
1338
+ description: "List a directory inside an open workspace or the OS temp directory. Use this for directory inspection before reading files. Call open_workspace first and pass workspaceId.",
1326
1339
  inputSchema: {
1327
1340
  workspaceId: z
1328
1341
  .string()
1329
1342
  .describe("Workspace identifier returned by open_workspace."),
1330
1343
  path: z
1331
1344
  .string()
1332
- .describe("Directory path to list, relative to the workspace root."),
1345
+ .describe("Directory path to list, relative to the workspace root or absolute inside the OS temp directory."),
1333
1346
  },
1334
1347
  outputSchema: resultOutputSchema(),
1335
1348
  ...toolWidgetDescriptorMeta(config, "directory"),
1336
1349
  annotations: { readOnlyHint: true },
1337
- }, async ({ workspaceId, ...input }) => {
1350
+ }, async ({ workspaceId, ...input }, extra) => {
1338
1351
  const workspace = workspaces.getWorkspace(workspaceId);
1339
1352
  return runToolWithHooks(hooks, {
1340
1353
  tool: toolNames.ls,
@@ -1343,15 +1356,15 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1343
1356
  isFailure: toolResultIsError,
1344
1357
  operation: async () => {
1345
1358
  const startedAt = performance.now();
1346
- workspaces.resolvePath(workspace, input.path);
1347
1359
  const response = await listDirectoryTool(input, {
1348
1360
  cwd: workspace.root,
1349
1361
  root: workspace.root,
1362
+ fileRoots: workspaces.fileToolRoots(workspace),
1350
1363
  });
1351
1364
  if (response.isError) {
1352
1365
  logFailedToolResponse(config, {
1353
1366
  tool: toolNames.ls,
1354
- workspaceId,
1367
+ ...workspaceLogContext(workspace, extra.sessionId),
1355
1368
  path: input.path,
1356
1369
  }, response.content, startedAt);
1357
1370
  return response;
@@ -1359,7 +1372,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1359
1372
  const summary = textSummary(response.content);
1360
1373
  logToolCall(config, {
1361
1374
  tool: toolNames.ls,
1362
- workspaceId,
1375
+ ...workspaceLogContext(workspace, extra.sessionId),
1363
1376
  path: input.path,
1364
1377
  success: true,
1365
1378
  durationMs: Math.round(performance.now() - startedAt),
@@ -1408,7 +1421,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1408
1421
  outputSchema: resultOutputSchema(),
1409
1422
  ...toolWidgetDescriptorMeta(config, "shell"),
1410
1423
  annotations: SHELL_TOOL_ANNOTATIONS,
1411
- }, async ({ workspaceId, workingDirectory, ...input }) => {
1424
+ }, async ({ workspaceId, workingDirectory, ...input }, extra) => {
1412
1425
  const workspace = workspaces.getWorkspace(workspaceId);
1413
1426
  return runToolWithHooks(hooks, {
1414
1427
  tool: toolNames.shell,
@@ -1429,7 +1442,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1429
1442
  if (response.isError) {
1430
1443
  logFailedToolResponse(config, {
1431
1444
  tool: toolNames.shell,
1432
- workspaceId,
1445
+ ...workspaceLogContext(workspace, extra.sessionId),
1433
1446
  workingDirectory: workingDirectory ?? ".",
1434
1447
  command: input.command,
1435
1448
  commandLength: input.command.length,
@@ -1443,7 +1456,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1443
1456
  };
1444
1457
  logToolCall(config, {
1445
1458
  tool: toolNames.shell,
1446
- workspaceId,
1459
+ ...workspaceLogContext(workspace, extra.sessionId),
1447
1460
  workingDirectory: workingDirectory ?? ".",
1448
1461
  command: input.command,
1449
1462
  commandLength: input.command.length,
@@ -1,5 +1,6 @@
1
1
  import { randomBytes } from "node:crypto";
2
2
  import { mkdir, opendir, readFile, realpath, stat } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
3
4
  import { basename, dirname, join, relative, resolve, sep } from "node:path";
4
5
  import { HookRunner } from "./hooks.js";
5
6
  import { closeManagedWorktree, createManagedWorktree, resolveManagedWorktreeBase, } from "./git-worktrees.js";
@@ -305,6 +306,9 @@ export class WorkspaceRegistry {
305
306
  this.workspaces.set(restoredWorkspace.id, restoredWorkspace);
306
307
  return restoredWorkspace;
307
308
  }
309
+ fileToolRoots(workspace) {
310
+ return [workspace.root, tmpdir()];
311
+ }
308
312
  resolvePath(workspace, inputPath) {
309
313
  const absolutePath = resolveAllowedPath(inputPath, workspace.root, [workspace.root]);
310
314
  if (!isPathInsideRoot(absolutePath, workspace.root)) {
@@ -321,13 +325,22 @@ export class WorkspaceRegistry {
321
325
  }
322
326
  catch (workspaceError) {
323
327
  const skillRead = resolveSkillReadPath(workspace.skills, workspace.activatedSkillDirs, inputPath);
324
- if (!skillRead)
328
+ if (skillRead) {
329
+ return {
330
+ absolutePath: skillRead.absolutePath,
331
+ readRoots: [workspace.root, skillRead.skill.baseDir],
332
+ skillRead,
333
+ };
334
+ }
335
+ try {
336
+ return {
337
+ absolutePath: resolveAllowedPath(inputPath, workspace.root, [tmpdir()]),
338
+ readRoots: this.fileToolRoots(workspace),
339
+ };
340
+ }
341
+ catch {
325
342
  throw workspaceError;
326
- return {
327
- absolutePath: skillRead.absolutePath,
328
- readRoots: [workspace.root, skillRead.skill.baseDir],
329
- skillRead,
330
- };
343
+ }
331
344
  }
332
345
  }
333
346
  markReadPathLoaded(workspace, readPath) {
@@ -310,15 +310,23 @@ forgerelay agents show <id>
310
310
  | Variable | Default |
311
311
  | --- | --- |
312
312
  | `FORGERELAY_LOG_LEVEL` | `info` |
313
- | `FORGERELAY_LOG_FORMAT` | `json` |
314
- | `FORGERELAY_LOG_REQUESTS` | `1` |
313
+ | `FORGERELAY_LOG_FORMAT` | `pretty` |
314
+ | `FORGERELAY_LOG_REQUESTS` | `0` in `pretty`, `1` in `json` |
315
315
  | `FORGERELAY_LOG_ASSETS` | `0` |
316
316
  | `FORGERELAY_LOG_TOOL_CALLS` | `1` |
317
- | `FORGERELAY_LOG_SHELL_COMMANDS` | `0` |
317
+ | `FORGERELAY_LOG_SHELL_COMMANDS` | `1` in `pretty`, `0` in `json` |
318
318
  | `FORGERELAY_TRUST_PROXY` | `0` |
319
319
 
320
- Set `FORGERELAY_LOG_FORMAT=pretty` for local debugging. Enable shell command
321
- previews only when intentionally needed; they can reveal sensitive arguments.
320
+ `pretty` is the human-facing local console format. It uses terminal-aware color,
321
+ short timestamps, workspace/session context, and compact operation results while
322
+ keeping HTTP request records off by default. Shell command previews are enabled
323
+ in this mode and truncated to 120 characters; set
324
+ `FORGERELAY_LOG_SHELL_COMMANDS=0` when command arguments may contain secrets.
325
+
326
+ Set `FORGERELAY_LOG_FORMAT=json` for machine collection. Unless explicitly
327
+ overridden, JSON mode preserves request logging and omits shell command previews.
328
+ `FORGERELAY_LOG_REQUESTS` and `FORGERELAY_LOG_SHELL_COMMANDS` always override
329
+ these format-specific defaults when set.
322
330
 
323
331
  ## Environment-only example
324
332
 
package/docs/debugging.md CHANGED
@@ -61,10 +61,11 @@ The acceptance checks:
61
61
  5. MCP `initialize`, including package/server version consistency;
62
62
  6. `tools/list` for the full debug tool surface;
63
63
  7. a real checkout workspace with `write`, `read`, `bash`, and a deliberate failed `edit`;
64
- 8. a temporary Git repository with managed worktree creation, file modification, and `close_worktree`;
65
- 9. 本地 bare remote 上的 release-tag-push Hook:成功 Hook 必须先运行再允许 `v0.2.0` push,失败 Hook 必须在 remote mutation 前阻断 `v0.2.1`;
66
- 10. deterministic local subagent error path,不联系任何模型 provider;
67
- 11. debug hook recorder 覆盖全部九个 Hooks v1 lifecycle events。
64
+ 8. OS temp-directory `write` `read` `edit` over the same real MCP session, plus rejection of an arbitrary path outside the workspace/temp roots;
65
+ 9. a temporary Git repository with managed worktree creation, file modification, and `close_worktree`;
66
+ 10. 本地 bare remote 上的 release-tag-push Hook:成功 Hook 必须先运行再允许 `v0.2.0` push,失败 Hook 必须在 remote mutation 前阻断 `v0.2.1`;
67
+ 11. deterministic local subagent error path,不联系任何模型 provider;
68
+ 12. debug hook recorder 覆盖全部九个 Hooks v1 lifecycle events。
68
69
 
69
70
  `curl` must be available on `PATH` for this acceptance command. Node and Git are
70
71
  already normal ForgeRelay development prerequisites.
package/docs/security.md CHANGED
@@ -17,8 +17,16 @@ Example:
17
17
  Do not use your entire home directory unless that is intentionally the access
18
18
  boundary you want.
19
19
 
20
- Filesystem-oriented tools validate workspace-relative paths and reject paths
21
- that escape the opened workspace or configured roots.
20
+ Opening a workspace still requires the path to be inside the configured allowed
21
+ roots. File and search tools additionally accept paths inside the operating
22
+ system temporary directory (for example `/tmp` on Linux) without treating that
23
+ directory as an allowed workspace root. Workspace paths remain confined to the
24
+ opened workspace, and shell working directories are not expanded by this temp
25
+ access.
26
+
27
+ Filesystem-oriented tools canonicalize existing path segments before access so
28
+ symlinks inside either the workspace or OS temp directory cannot escape to
29
+ arbitrary filesystem locations.
22
30
 
23
31
  ## Owner-password OAuth
24
32
 
@@ -137,15 +145,18 @@ as owner-only files. See [Native File Download](artifact-exchange.md).
137
145
 
138
146
  ## Logging
139
147
 
140
- ForgeRelay logs requests/tool calls by default. Shell command previews are off
141
- unless explicitly enabled:
148
+ ForgeRelay's default human-facing `pretty` logs focus on tool and Hook
149
+ operations; HTTP request records are off by default. Pretty mode includes a
150
+ truncated shell command preview so local operators can see what the Agent ran.
151
+ Command arguments can contain secrets, so disable previews when necessary:
142
152
 
143
153
  ```bash
144
- FORGERELAY_LOG_SHELL_COMMANDS=1
154
+ FORGERELAY_LOG_SHELL_COMMANDS=0
145
155
  ```
146
156
 
147
- Command arguments can contain secrets, so only enable command-preview logging
148
- when necessary.
157
+ Explicit `json` mode is intended for machine collection. Its default preserves
158
+ HTTP request records and omits shell command previews unless those settings are
159
+ overridden explicitly.
149
160
 
150
161
  ## Package provenance
151
162
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akira-tl/forgerelay",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Local development control plane for MCP coding agents.",
5
5
  "type": "module",
6
6
  "homepage": "https://github.com/Akira-TL/forgerelay#readme",
@@ -41,7 +41,7 @@
41
41
  "debug:accept": "node scripts/debug/accept.mjs",
42
42
  "postinstall": "node scripts/fix-node-pty-permissions.mjs",
43
43
  "start": "node dist/cli.js serve",
44
- "test": "tsx src/config.test.ts && tsx src/hooks.test.ts && tsx src/mcp/server-instructions.test.ts && tsx src/request-meta.test.ts && tsx src/incoming-artifacts.test.ts && tsx src/artifact-download.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/workspace-conversation.test.ts && tsx src/review-checkpoints.test.ts && tsx src/server.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts",
44
+ "test": "tsx src/config.test.ts && tsx src/logger.test.ts && tsx src/hooks.test.ts && tsx src/mcp/server-instructions.test.ts && tsx src/request-meta.test.ts && tsx src/incoming-artifacts.test.ts && tsx src/artifact-download.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/workspace-conversation.test.ts && tsx src/review-checkpoints.test.ts && tsx src/server.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts",
45
45
  "typecheck": "tsc -p tsconfig.json --noEmit",
46
46
  "release:check": "node scripts/release-version.mjs check",
47
47
  "release:tag-check": "node scripts/release-version.mjs tag",
@@ -10,6 +10,7 @@ import {
10
10
  rmSync,
11
11
  writeFileSync,
12
12
  } from "node:fs";
13
+ import { homedir, tmpdir } from "node:os";
13
14
  import { join, resolve } from "node:path";
14
15
  import { setTimeout as delay } from "node:timers/promises";
15
16
  import {
@@ -30,6 +31,7 @@ const gitProject = resolve(acceptanceRoot, "git-project");
30
31
  const releaseProject = resolve(acceptanceRoot, "release-project");
31
32
  const releaseRemote = resolve(acceptanceRoot, "release-remote.git");
32
33
  const ownerToken = randomBytes(32).toString("base64url");
34
+ const tempAcceptanceRoot = resolve(tmpdir(), `forgerelay-debug-acceptance-${randomUUID()}`);
33
35
 
34
36
  assertCurlAvailable();
35
37
  await assertDebugPortFree();
@@ -144,6 +146,37 @@ try {
144
146
  assert.equal(failedEdit.isError, true);
145
147
  pass("failed tool path", "edit returned isError=true and triggered AfterToolFailure");
146
148
 
149
+ mkdirSync(tempAcceptanceRoot, { recursive: true });
150
+ const tempFile = join(tempAcceptanceRoot, "mcp-temp.txt");
151
+ const tempWritten = callTool(oauth.accessToken, sessionId, 70, "write", {
152
+ workspaceId,
153
+ path: tempFile,
154
+ content: "forgerelay temp before edit\n",
155
+ });
156
+ assert.equal(tempWritten.isError, undefined);
157
+
158
+ const tempRead = callTool(oauth.accessToken, sessionId, 71, "read", {
159
+ workspaceId,
160
+ path: tempFile,
161
+ });
162
+ assert.match(tempRead.structuredContent.result, /forgerelay temp before edit/);
163
+
164
+ const tempEdited = callTool(oauth.accessToken, sessionId, 72, "edit", {
165
+ workspaceId,
166
+ path: tempFile,
167
+ edits: [{ oldText: "before edit", newText: "after edit" }],
168
+ });
169
+ assert.equal(tempEdited.isError, undefined);
170
+ assert.equal(readFileSync(tempFile, "utf8"), "forgerelay temp after edit\n");
171
+
172
+ const outsideRoots = callTool(oauth.accessToken, sessionId, 73, "read", {
173
+ workspaceId,
174
+ path: join(homedir(), "forgerelay-debug-outside-roots.txt"),
175
+ });
176
+ assert.equal(outsideRoots.isError, true);
177
+ assert.match(toolText(outsideRoots), /outside allowed roots/i);
178
+ pass("OS temp file tools", "write + read + edit passed; arbitrary home path rejected");
179
+
147
180
  setupGitProject(gitProject);
148
181
  const worktreeOpened = callTool(oauth.accessToken, sessionId, 8, "open_workspace", {
149
182
  path: gitProject,
@@ -211,6 +244,7 @@ try {
211
244
  console.error("\nForgeRelay 7677 acceptance failed.");
212
245
  throw error;
213
246
  } finally {
247
+ rmSync(tempAcceptanceRoot, { recursive: true, force: true });
214
248
  await stopServer(server);
215
249
  }
216
250