@ottocode/server 0.1.230 → 0.1.231

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ottocode/server",
3
- "version": "0.1.230",
3
+ "version": "0.1.231",
4
4
  "description": "HTTP API server for ottocode",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -49,8 +49,8 @@
49
49
  "typecheck": "tsc --noEmit"
50
50
  },
51
51
  "dependencies": {
52
- "@ottocode/sdk": "0.1.230",
53
- "@ottocode/database": "0.1.230",
52
+ "@ottocode/sdk": "0.1.231",
53
+ "@ottocode/database": "0.1.231",
54
54
  "drizzle-orm": "^0.44.5",
55
55
  "hono": "^4.9.9",
56
56
  "zod": "^4.3.6"
package/src/index.ts CHANGED
@@ -108,7 +108,6 @@ export type StandaloneAppConfig = {
108
108
  export function createStandaloneApp(_config?: StandaloneAppConfig) {
109
109
  const honoApp = new Hono();
110
110
 
111
- // Enable CORS for localhost and local network access
112
111
  honoApp.use(
113
112
  '*',
114
113
  cors({
@@ -192,6 +191,7 @@ export type EmbeddedAppConfig = {
192
191
  model?: string;
193
192
  agent?: string;
194
193
  toolApproval?: 'auto' | 'dangerous' | 'all';
194
+ fullWidthContent?: boolean;
195
195
  };
196
196
  /** Additional CORS origins for proxies/Tailscale (e.g., ['https://myapp.ts.net', 'https://example.com']) */
197
197
  corsOrigins?: string[];
@@ -214,7 +214,6 @@ export function createEmbeddedApp(config: EmbeddedAppConfig = {}) {
214
214
  await next();
215
215
  });
216
216
 
217
- // Enable CORS for localhost and local network access
218
217
  honoApp.use(
219
218
  '*',
220
219
  cors({
@@ -178,6 +178,7 @@ export const configPaths = {
178
178
  agent: { type: 'string' },
179
179
  provider: { type: 'string' },
180
180
  model: { type: 'string' },
181
+ fullWidthContent: { type: 'boolean' },
181
182
  reasoningText: { type: 'boolean' },
182
183
  reasoningLevel: {
183
184
  type: 'string',
@@ -208,6 +209,7 @@ export const configPaths = {
208
209
  agent: { type: 'string' },
209
210
  provider: { type: 'string' },
210
211
  model: { type: 'string' },
212
+ fullWidthContent: { type: 'boolean' },
211
213
  reasoningText: { type: 'boolean' },
212
214
  reasoningLevel: {
213
215
  type: 'string',
@@ -196,6 +196,7 @@ export const schemas = {
196
196
  agent: { type: 'string' },
197
197
  provider: { $ref: '#/components/schemas/Provider' },
198
198
  model: { type: 'string' },
199
+ fullWidthContent: { type: 'boolean' },
199
200
  reasoningText: { type: 'boolean' },
200
201
  reasoningLevel: {
201
202
  type: 'string',
@@ -21,6 +21,7 @@ export function registerDefaultsRoute(app: Hono) {
21
21
  reasoningText?: boolean;
22
22
  reasoningLevel?: ReasoningLevel;
23
23
  theme?: string;
24
+ fullWidthContent?: boolean;
24
25
  scope?: 'global' | 'local';
25
26
  }>();
26
27
 
@@ -34,6 +35,7 @@ export function registerDefaultsRoute(app: Hono) {
34
35
  reasoningText: boolean;
35
36
  reasoningLevel: ReasoningLevel;
36
37
  theme: string;
38
+ fullWidthContent: boolean;
37
39
  }> = {};
38
40
 
39
41
  if (body.agent) updates.agent = body.agent;
@@ -45,6 +47,8 @@ export function registerDefaultsRoute(app: Hono) {
45
47
  updates.reasoningText = body.reasoningText;
46
48
  if (body.reasoningLevel) updates.reasoningLevel = body.reasoningLevel;
47
49
  if (body.theme) updates.theme = body.theme;
50
+ if (body.fullWidthContent !== undefined)
51
+ updates.fullWidthContent = body.fullWidthContent;
48
52
 
49
53
  await setConfig(scope, updates, projectRoot);
50
54
 
@@ -63,6 +63,12 @@ export function registerMainConfigRoute(app: Hono) {
63
63
  reasoningText: cfg.defaults.reasoningText ?? true,
64
64
  reasoningLevel: cfg.defaults.reasoningLevel ?? 'high',
65
65
  theme: cfg.defaults.theme,
66
+ fullWidthContent:
67
+ getDefault(
68
+ undefined,
69
+ embeddedConfig?.defaults?.fullWidthContent,
70
+ cfg.defaults.fullWidthContent,
71
+ ) ?? false,
66
72
  };
67
73
 
68
74
  return c.json({
@@ -138,6 +138,7 @@ export function registerModelsRoutes(app: Hono) {
138
138
  reasoningText: m.reasoningText,
139
139
  vision: m.modalities?.input?.includes('image') ?? false,
140
140
  attachment: m.attachment ?? false,
141
+ free: m.cost?.input === 0 && m.cost?.output === 0,
141
142
  })),
142
143
  default: getDefault(
143
144
  embeddedConfig?.model,
@@ -205,6 +206,7 @@ export function registerModelsRoutes(app: Hono) {
205
206
  reasoningText: m.reasoningText,
206
207
  vision: m.modalities?.input?.includes('image') ?? false,
207
208
  attachment: m.attachment ?? false,
209
+ free: m.cost?.input === 0 && m.cost?.output === 0,
208
210
  })),
209
211
  };
210
212
  }
package/src/routes/mcp.ts CHANGED
@@ -1,36 +1,21 @@
1
1
  import type { Hono } from 'hono';
2
2
  import {
3
+ COPILOT_MCP_SCOPE,
3
4
  getMCPManager,
5
+ getCopilotMCPOAuthKey,
6
+ getStoredCopilotMCPToken,
4
7
  initializeMCP,
8
+ isGitHubCopilotUrl,
5
9
  loadMCPConfig,
6
10
  getGlobalConfigDir,
7
11
  MCPClientWrapper,
12
+ OAuthCredentialStore,
8
13
  addMCPServerToConfig,
9
14
  removeMCPServerFromConfig,
10
15
  } from '@ottocode/sdk';
11
- import {
12
- authorizeCopilot,
13
- pollForCopilotTokenOnce,
14
- getAuth,
15
- setAuth,
16
- } from '@ottocode/sdk';
16
+ import { authorizeCopilot, pollForCopilotTokenOnce } from '@ottocode/sdk';
17
17
 
18
- const GITHUB_COPILOT_HOSTS = [
19
- 'api.githubcopilot.com',
20
- 'copilot-proxy.githubusercontent.com',
21
- ];
22
-
23
- function isGitHubCopilotUrl(url?: string): boolean {
24
- if (!url) return false;
25
- try {
26
- const parsed = new URL(url);
27
- return GITHUB_COPILOT_HOSTS.some(
28
- (h) => parsed.hostname === h || parsed.hostname.endsWith(`.${h}`),
29
- );
30
- } catch {
31
- return false;
32
- }
33
- }
18
+ const copilotMCPOAuthStore = new OAuthCredentialStore();
34
19
 
35
20
  const copilotMCPSessions = new Map<
36
21
  string,
@@ -184,13 +169,14 @@ export function registerMCPRoutes(app: Hono) {
184
169
  );
185
170
 
186
171
  if (isGitHubCopilotUrl(serverConfig.url) && !status?.connected) {
187
- const MCP_SCOPES =
188
- 'repo read:org read:packages gist notifications read:project security_events';
189
- const existingAuth = await getAuth('copilot');
190
- const hasMCPScopes =
191
- existingAuth?.type === 'oauth' && existingAuth.scopes === MCP_SCOPES;
172
+ const existingAuth = await getStoredCopilotMCPToken(
173
+ copilotMCPOAuthStore,
174
+ name,
175
+ serverConfig.scope ?? 'global',
176
+ projectRoot,
177
+ );
192
178
 
193
- if (!existingAuth || existingAuth.type !== 'oauth' || !hasMCPScopes) {
179
+ if (!existingAuth.token || existingAuth.needsReauth) {
194
180
  const deviceData = await authorizeCopilot({ mcp: true });
195
181
  const sessionId = crypto.randomUUID();
196
182
  copilotMCPSessions.set(sessionId, {
@@ -256,14 +242,13 @@ export function registerMCPRoutes(app: Hono) {
256
242
 
257
243
  if (isGitHubCopilotUrl(serverConfig.url)) {
258
244
  try {
259
- const MCP_SCOPES =
260
- 'repo read:org read:packages gist notifications read:project security_events';
261
- const existingAuth = await getAuth('copilot');
262
- if (
263
- existingAuth?.type === 'oauth' &&
264
- existingAuth.refresh &&
265
- existingAuth.scopes === MCP_SCOPES
266
- ) {
245
+ const existingAuth = await getStoredCopilotMCPToken(
246
+ copilotMCPOAuthStore,
247
+ name,
248
+ serverConfig.scope ?? 'global',
249
+ projectRoot,
250
+ );
251
+ if (existingAuth.token && !existingAuth.needsReauth) {
267
252
  return c.json({
268
253
  ok: true,
269
254
  name,
@@ -334,29 +319,31 @@ export function registerMCPRoutes(app: Hono) {
334
319
  const result = await pollForCopilotTokenOnce(session.deviceCode);
335
320
  if (result.status === 'complete') {
336
321
  copilotMCPSessions.delete(sessionId);
337
- await setAuth(
338
- 'copilot',
339
- {
340
- type: 'oauth',
341
- refresh: result.accessToken,
342
- access: result.accessToken,
343
- expires: 0,
344
- scopes:
345
- 'repo read:org read:packages gist notifications read:project security_events',
346
- },
347
- undefined,
348
- 'global',
349
- );
350
322
  const projectRoot = process.cwd();
351
323
  const config = await loadMCPConfig(projectRoot, getGlobalConfigDir());
352
324
  const serverConfig = config.servers.find((s) => s.name === name);
325
+ if (!serverConfig) {
326
+ return c.json(
327
+ { ok: false, error: `Server "${name}" not found` },
328
+ 404,
329
+ );
330
+ }
331
+ await copilotMCPOAuthStore.saveTokens(
332
+ getCopilotMCPOAuthKey(
333
+ name,
334
+ serverConfig.scope ?? 'global',
335
+ projectRoot,
336
+ ),
337
+ {
338
+ access_token: result.accessToken,
339
+ scope: COPILOT_MCP_SCOPE,
340
+ },
341
+ );
353
342
  let mcpMgr = getMCPManager();
354
- if (serverConfig) {
355
- if (!mcpMgr) {
356
- mcpMgr = await initializeMCP({ servers: [] }, projectRoot);
357
- }
358
- await mcpMgr.restartServer(serverConfig);
343
+ if (!mcpMgr) {
344
+ mcpMgr = await initializeMCP({ servers: [] }, projectRoot);
359
345
  }
346
+ await mcpMgr.restartServer(serverConfig);
360
347
  mcpMgr = getMCPManager();
361
348
  const status = mcpMgr
362
349
  ? (await mcpMgr.getStatusAsync()).find((s) => s.name === name)
@@ -421,8 +408,13 @@ export function registerMCPRoutes(app: Hono) {
421
408
 
422
409
  if (serverConfig && isGitHubCopilotUrl(serverConfig.url)) {
423
410
  try {
424
- const auth = await getAuth('copilot');
425
- const authenticated = auth?.type === 'oauth' && !!auth.refresh;
411
+ const auth = await getStoredCopilotMCPToken(
412
+ copilotMCPOAuthStore,
413
+ name,
414
+ serverConfig.scope ?? 'global',
415
+ projectRoot,
416
+ );
417
+ const authenticated = !!auth.token && !auth.needsReauth;
426
418
  return c.json({ authenticated, authType: 'copilot-device' });
427
419
  } catch {
428
420
  return c.json({ authenticated: false, authType: 'copilot-device' });
@@ -450,10 +442,22 @@ export function registerMCPRoutes(app: Hono) {
450
442
 
451
443
  if (serverConfig && isGitHubCopilotUrl(serverConfig.url)) {
452
444
  try {
453
- const { removeAuth } = await import('@ottocode/sdk');
454
- await removeAuth('copilot');
445
+ const key = getCopilotMCPOAuthKey(
446
+ name,
447
+ serverConfig.scope ?? 'global',
448
+ projectRoot,
449
+ );
450
+ await copilotMCPOAuthStore.clearServer(key);
451
+ if (key !== name) {
452
+ await copilotMCPOAuthStore.clearServer(name);
453
+ }
455
454
  const manager = getMCPManager();
456
455
  if (manager) {
456
+ await manager.clearAuthData(
457
+ name,
458
+ serverConfig.scope ?? 'global',
459
+ projectRoot,
460
+ );
457
461
  await manager.stopServer(name);
458
462
  }
459
463
  return c.json({ ok: true, name });
@@ -1,3 +1,4 @@
1
+ import type { Context } from 'hono';
1
2
  import type { Hono } from 'hono';
2
3
  import { subscribe } from '../events/bus.ts';
3
4
  import type { OttoEvent } from '../events/types.ts';
@@ -8,54 +9,54 @@ function safeStringify(obj: unknown): string {
8
9
  );
9
10
  }
10
11
 
11
- export function registerSessionStreamRoute(app: Hono) {
12
- app.get('/v1/sessions/:id/stream', async (c) => {
13
- const sessionId = c.req.param('id');
14
- const headers = new Headers({
15
- 'Content-Type': 'text/event-stream',
16
- 'Cache-Control': 'no-cache, no-transform',
17
- Connection: 'keep-alive',
18
- });
19
-
20
- const encoder = new TextEncoder();
12
+ function handleSessionStream(c: Context) {
13
+ const sessionId = c.req.param('id');
14
+ const headers = new Headers({
15
+ 'Content-Type': 'text/event-stream',
16
+ 'Cache-Control': 'no-cache, no-transform',
17
+ Connection: 'keep-alive',
18
+ });
21
19
 
22
- const stream = new ReadableStream<Uint8Array>({
23
- start(controller) {
24
- const write = (evt: OttoEvent) => {
25
- let line: string;
26
- try {
27
- line =
28
- `event: ${evt.type}\n` +
29
- `data: ${safeStringify(evt.payload ?? {})}\n\n`;
30
- } catch {
31
- line = `event: ${evt.type}\ndata: {}\n\n`;
32
- }
33
- controller.enqueue(encoder.encode(line));
34
- };
35
- const unsubscribe = subscribe(sessionId, write);
36
- // Initial ping
37
- controller.enqueue(encoder.encode(`: connected ${sessionId}\n\n`));
38
- // Heartbeat every 5s to prevent idle timeout (Bun default is 10s)
39
- const hb = setInterval(() => {
40
- try {
41
- controller.enqueue(encoder.encode(`: hb ${Date.now()}\n\n`));
42
- } catch {
43
- // Controller might be closed
44
- clearInterval(hb);
45
- }
46
- }, 5000);
20
+ const encoder = new TextEncoder();
47
21
 
48
- const signal = c.req.raw?.signal as AbortSignal | undefined;
49
- signal?.addEventListener('abort', () => {
22
+ const stream = new ReadableStream<Uint8Array>({
23
+ start(controller) {
24
+ const write = (evt: OttoEvent) => {
25
+ let line: string;
26
+ try {
27
+ line =
28
+ `event: ${evt.type}\n` +
29
+ `data: ${safeStringify(evt.payload ?? {})}\n\n`;
30
+ } catch {
31
+ line = `event: ${evt.type}\ndata: {}\n\n`;
32
+ }
33
+ controller.enqueue(encoder.encode(line));
34
+ };
35
+ const unsubscribe = subscribe(sessionId, write);
36
+ controller.enqueue(encoder.encode(`: connected ${sessionId}\n\n`));
37
+ const hb = setInterval(() => {
38
+ try {
39
+ controller.enqueue(encoder.encode(`: hb ${Date.now()}\n\n`));
40
+ } catch {
50
41
  clearInterval(hb);
51
- unsubscribe();
52
- try {
53
- controller.close();
54
- } catch {}
55
- });
56
- },
57
- });
42
+ }
43
+ }, 5000);
58
44
 
59
- return new Response(stream, { headers });
45
+ const signal = c.req.raw?.signal as AbortSignal | undefined;
46
+ signal?.addEventListener('abort', () => {
47
+ clearInterval(hb);
48
+ unsubscribe();
49
+ try {
50
+ controller.close();
51
+ } catch {}
52
+ });
53
+ },
60
54
  });
55
+
56
+ return new Response(stream, { headers });
57
+ }
58
+
59
+ export function registerSessionStreamRoute(app: Hono) {
60
+ app.get('/v1/sessions/:id/stream', handleSessionStream);
61
+ app.post('/v1/sessions/:id/stream', handleSessionStream);
61
62
  }
@@ -1,3 +1,4 @@
1
+ import type { Context } from 'hono';
1
2
  import type { Hono } from 'hono';
2
3
  import { streamSSE } from 'hono/streaming';
3
4
  import type { TerminalManager } from '@ottocode/sdk';
@@ -77,7 +78,7 @@ export function registerTerminalsRoutes(
77
78
  return c.json({ terminal: terminal.toJSON() });
78
79
  });
79
80
 
80
- app.get('/v1/terminals/:id/output', async (c) => {
81
+ const handleTerminalOutput = async (c: Context) => {
81
82
  const id = c.req.param('id');
82
83
  logger.debug('SSE client connecting to terminal', { id });
83
84
  const terminal = terminalManager.get(id);
@@ -91,7 +92,6 @@ export function registerTerminalsRoutes(
91
92
 
92
93
  return streamSSE(c, async (stream) => {
93
94
  logger.debug('SSE stream started for terminal', { id });
94
- // Send historical buffer first (unless skipHistory is set)
95
95
  const skipHistory = c.req.query('skipHistory') === 'true';
96
96
  if (!skipHistory) {
97
97
  const history = activeTerminal.read();
@@ -121,10 +121,19 @@ export function registerTerminalsRoutes(
121
121
  let resolveStream: (() => void) | null = null;
122
122
  let finished = false;
123
123
 
124
+ const hb = setInterval(async () => {
125
+ try {
126
+ await stream.write(`: hb ${Date.now()}\n\n`);
127
+ } catch {
128
+ clearInterval(hb);
129
+ }
130
+ }, 15000);
131
+
124
132
  function cleanup() {
125
133
  activeTerminal.removeDataListener(onData);
126
134
  activeTerminal.removeExitListener(onExit);
127
135
  c.req.raw.signal.removeEventListener('abort', onAbort);
136
+ clearInterval(hb);
128
137
  }
129
138
 
130
139
  function finish() {
@@ -168,7 +177,10 @@ export function registerTerminalsRoutes(
168
177
 
169
178
  await waitForClose;
170
179
  });
171
- });
180
+ };
181
+
182
+ app.get('/v1/terminals/:id/output', handleTerminalOutput);
183
+ app.post('/v1/terminals/:id/output', handleTerminalOutput);
172
184
 
173
185
  app.post('/v1/terminals/:id/input', async (c) => {
174
186
  const id = c.req.param('id');
@@ -1,4 +1,5 @@
1
1
  import type { Hono } from 'hono';
2
+ import type { Context } from 'hono';
2
3
  import { streamSSE } from 'hono/streaming';
3
4
  import {
4
5
  OttoTunnel,
@@ -159,8 +160,8 @@ export function registerTunnelRoutes(app: Hono) {
159
160
  }
160
161
  });
161
162
 
162
- app.get('/v1/tunnel/stream', async (c) => {
163
- return streamSSE(c, async (stream) => {
163
+ const handleTunnelStream = async (c: Context) => {
164
+ return streamSSE(c as Context, async (stream) => {
164
165
  const sendEvent = async (data: Record<string, unknown>) => {
165
166
  try {
166
167
  await stream.write(`data: ${JSON.stringify(data)}\n\n`);
@@ -202,7 +203,10 @@ export function registerTunnelRoutes(app: Hono) {
202
203
 
203
204
  clearInterval(interval);
204
205
  });
205
- });
206
+ };
207
+
208
+ app.get('/v1/tunnel/stream', handleTunnelStream);
209
+ app.post('/v1/tunnel/stream', handleTunnelStream);
206
210
  }
207
211
 
208
212
  export function stopActiveTunnel() {
@@ -130,7 +130,14 @@ export async function dispatchAssistantMessage(
130
130
  publish({
131
131
  type: 'message.created',
132
132
  sessionId,
133
- payload: { id: userMessageId, role: 'user', agent, provider, model },
133
+ payload: {
134
+ id: userMessageId,
135
+ role: 'user',
136
+ agent,
137
+ provider,
138
+ model,
139
+ content: String(content),
140
+ },
134
141
  });
135
142
 
136
143
  const assistantMessageId = crypto.randomUUID();
@@ -1,9 +1,19 @@
1
+ import { resolve as resolvePath } from 'node:path';
2
+
1
3
  export type GuardAction =
2
4
  | { type: 'block'; reason: string }
3
5
  | { type: 'approve'; reason: string }
4
6
  | { type: 'allow' };
5
7
 
6
- export function guardToolCall(toolName: string, args: unknown): GuardAction {
8
+ export type GuardContext = {
9
+ projectRoot?: string;
10
+ };
11
+
12
+ export function guardToolCall(
13
+ toolName: string,
14
+ args: unknown,
15
+ context: GuardContext = {},
16
+ ): GuardAction {
7
17
  const a = (args ?? {}) as Record<string, unknown>;
8
18
 
9
19
  switch (toolName) {
@@ -12,7 +22,7 @@ export function guardToolCall(toolName: string, args: unknown): GuardAction {
12
22
  case 'terminal':
13
23
  return guardTerminal(a);
14
24
  case 'read':
15
- return guardReadPath(String(a.path ?? ''));
25
+ return guardReadPath(String(a.path ?? ''), context.projectRoot);
16
26
  case 'write':
17
27
  case 'edit':
18
28
  case 'multiedit':
@@ -118,7 +128,42 @@ const SENSITIVE_READ_PATHS: Array<{ pattern: RegExp; reason: string }> = [
118
128
  { pattern: /^~?\/?\.docker\/config\.json$/, reason: 'Docker credentials' },
119
129
  ];
120
130
 
121
- function guardReadPath(path: string): GuardAction {
131
+ function normalizeForComparison(value: string): string {
132
+ const withForwardSlashes = value.replace(/\\/g, '/');
133
+ return process.platform === 'win32'
134
+ ? withForwardSlashes.toLowerCase()
135
+ : withForwardSlashes;
136
+ }
137
+
138
+ function expandTilde(path: string): string {
139
+ const home = process.env.HOME || process.env.USERPROFILE || '';
140
+ if (!home) return path;
141
+ if (path === '~') return home;
142
+ if (path.startsWith('~/')) return `${home}/${path.slice(2)}`;
143
+ return path;
144
+ }
145
+
146
+ function isAbsoluteLike(path: string): boolean {
147
+ return (
148
+ path.startsWith('/') || path.startsWith('~') || /^[A-Za-z]:[\\/]/.test(path)
149
+ );
150
+ }
151
+
152
+ function isPathInProject(path: string, projectRoot?: string): boolean {
153
+ if (!projectRoot || !isAbsoluteLike(path)) return false;
154
+ const root = resolvePath(projectRoot);
155
+ const target = resolvePath(expandTilde(path));
156
+ const rootNorm = (() => {
157
+ const normalized = normalizeForComparison(root);
158
+ if (normalized === '/') return '/';
159
+ return normalized.replace(/[\\/]+$/, '');
160
+ })();
161
+ const targetNorm = normalizeForComparison(target);
162
+ const rootWithSlash = rootNorm === '/' ? '/' : `${rootNorm}/`;
163
+ return targetNorm === rootNorm || targetNorm.startsWith(rootWithSlash);
164
+ }
165
+
166
+ function guardReadPath(path: string, projectRoot?: string): GuardAction {
122
167
  if (!path) return { type: 'allow' };
123
168
  const p = path.trim();
124
169
 
@@ -128,7 +173,10 @@ function guardReadPath(path: string): GuardAction {
128
173
  for (const { pattern, reason } of SENSITIVE_READ_PATHS) {
129
174
  if (pattern.test(p)) return { type: 'approve', reason };
130
175
  }
131
- if (p.startsWith('/') || p.startsWith('~')) {
176
+ if (isPathInProject(p, projectRoot)) {
177
+ return { type: 'allow' };
178
+ }
179
+ if (isAbsoluteLike(p)) {
132
180
  return { type: 'approve', reason: 'Reading path outside project root' };
133
181
  }
134
182
  return { type: 'allow' };
@@ -393,7 +393,9 @@ export function adaptTools(
393
393
  args,
394
394
  );
395
395
  }
396
- const guard = guardToolCall(name, args);
396
+ const guard = guardToolCall(name, args, {
397
+ projectRoot: ctx.projectRoot,
398
+ });
397
399
  if (guard.type === 'block') {
398
400
  meta.blocked = true;
399
401
  meta.blockReason = guard.reason;