@ottocode/server 0.1.207 → 0.1.208

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.207",
3
+ "version": "0.1.208",
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.207",
53
- "@ottocode/database": "0.1.207",
52
+ "@ottocode/sdk": "0.1.208",
53
+ "@ottocode/database": "0.1.208",
54
54
  "drizzle-orm": "^0.44.5",
55
55
  "hono": "^4.9.9",
56
56
  "zod": "^4.1.8"
@@ -800,7 +800,7 @@ export function registerSessionsRoutes(app: Hono) {
800
800
  );
801
801
  const { runSessionLoop } = await import('../runtime/agent/runner.ts');
802
802
 
803
- const toolApprovalMode = cfg.defaults.toolApproval ?? 'auto';
803
+ const toolApprovalMode = cfg.defaults.toolApproval ?? 'dangerous';
804
804
 
805
805
  enqueueAssistantRun(
806
806
  {
@@ -168,7 +168,7 @@ export async function dispatchAssistantMessage(
168
168
  );
169
169
  }
170
170
 
171
- const toolApprovalMode = cfg.defaults.toolApproval ?? 'auto';
171
+ const toolApprovalMode = cfg.defaults.toolApproval ?? 'dangerous';
172
172
 
173
173
  enqueueAssistantRun(
174
174
  {
@@ -0,0 +1,159 @@
1
+ export type GuardAction =
2
+ | { type: 'block'; reason: string }
3
+ | { type: 'approve'; reason: string }
4
+ | { type: 'allow' };
5
+
6
+ export function guardToolCall(toolName: string, args: unknown): GuardAction {
7
+ const a = (args ?? {}) as Record<string, unknown>;
8
+
9
+ switch (toolName) {
10
+ case 'bash':
11
+ return guardBashCommand(String(a.cmd ?? ''));
12
+ case 'terminal':
13
+ return guardTerminal(a);
14
+ case 'read':
15
+ return guardReadPath(String(a.path ?? ''));
16
+ case 'write':
17
+ case 'edit':
18
+ case 'multiedit':
19
+ return guardWritePath(toolName, a);
20
+ default:
21
+ return { type: 'allow' };
22
+ }
23
+ }
24
+
25
+ function guardBashCommand(cmd: string): GuardAction {
26
+ const n = cmd.trim();
27
+ if (!n) return { type: 'allow' };
28
+
29
+ const blocked = checkBlockedCommand(n);
30
+ if (blocked) return { type: 'block', reason: blocked };
31
+
32
+ const approval = checkApprovalCommand(n);
33
+ if (approval) return { type: 'approve', reason: approval };
34
+
35
+ return { type: 'allow' };
36
+ }
37
+
38
+ function checkBlockedCommand(cmd: string): string | null {
39
+ if (isRecursiveDeleteRoot(cmd)) return 'Recursive delete of root filesystem';
40
+ if (isRecursiveDeleteHome(cmd)) return 'Recursive delete of home directory';
41
+ if (isForkBomb(cmd)) return 'Fork bomb detected';
42
+ if (isFilesystemFormat(cmd)) return 'Filesystem format command';
43
+ if (isRawDiskWrite(cmd)) return 'Raw disk write operation';
44
+ return null;
45
+ }
46
+
47
+ function isRecursiveDeleteRoot(cmd: string): boolean {
48
+ if (!/\brm\b/.test(cmd)) return false;
49
+ if (!hasRecursiveFlag(cmd)) return false;
50
+ return /\s\/(\s*$|\s*\*|\s*;|\s*&|\s*\|)/.test(cmd);
51
+ }
52
+
53
+ function isRecursiveDeleteHome(cmd: string): boolean {
54
+ if (!/\brm\b/.test(cmd)) return false;
55
+ if (!hasRecursiveFlag(cmd)) return false;
56
+ return /\s~\/?\s*($|\*|;|&|\|)/.test(cmd);
57
+ }
58
+
59
+ function hasRecursiveFlag(cmd: string): boolean {
60
+ return /-\w*[rR]|--recursive/.test(cmd);
61
+ }
62
+
63
+ function isForkBomb(cmd: string): boolean {
64
+ return /:\(\)\s*\{[^}]*:\s*\|\s*:/.test(cmd);
65
+ }
66
+
67
+ function isFilesystemFormat(cmd: string): boolean {
68
+ return /\bmkfs(\.\w+)?\s/.test(cmd);
69
+ }
70
+
71
+ function isRawDiskWrite(cmd: string): boolean {
72
+ if (/\bdd\b/.test(cmd) && /\bof=\/dev\//.test(cmd)) return true;
73
+ if (/>\s*\/dev\/[sv]d/.test(cmd)) return true;
74
+ return false;
75
+ }
76
+
77
+ function checkApprovalCommand(cmd: string): string | null {
78
+ if (/\brm\b/.test(cmd) && hasRecursiveFlag(cmd)) {
79
+ return 'Recursive delete command';
80
+ }
81
+ if (/\bsudo\b/.test(cmd)) {
82
+ return 'Privilege escalation (sudo)';
83
+ }
84
+ if (/\b(chmod|chown)\b/.test(cmd) && /(-\w*R|--recursive)/.test(cmd)) {
85
+ return 'Recursive permission/ownership change';
86
+ }
87
+ if (/\b(curl|wget)\b/.test(cmd) && /\|\s*(bash|sh|zsh)\b/.test(cmd)) {
88
+ return 'Remote code execution via pipe to shell';
89
+ }
90
+ if (/\bgit\s+push\b.*--force/.test(cmd)) {
91
+ return 'Force push to remote';
92
+ }
93
+ return null;
94
+ }
95
+
96
+ function guardTerminal(args: Record<string, unknown>): GuardAction {
97
+ const op = String(args.operation ?? '');
98
+ if (op === 'start' && typeof args.command === 'string') {
99
+ return guardBashCommand(args.command);
100
+ }
101
+ return { type: 'allow' };
102
+ }
103
+
104
+ const BLOCKED_READ_PATHS: Array<{ pattern: RegExp; reason: string }> = [
105
+ { pattern: /^~?\/?\.ssh\/id_/, reason: 'SSH private key access' },
106
+ { pattern: /^\/etc\/shadow$/, reason: 'System password hashes' },
107
+ ];
108
+
109
+ const SENSITIVE_READ_PATHS: Array<{ pattern: RegExp; reason: string }> = [
110
+ { pattern: /^\/etc\/passwd$/, reason: 'System password file' },
111
+ { pattern: /^~?\/?\.ssh\//, reason: 'SSH directory access' },
112
+ { pattern: /^~?\/?\.aws\//, reason: 'AWS credentials' },
113
+ { pattern: /^~?\/?\.gnupg\//, reason: 'GPG keyring' },
114
+ { pattern: /^~?\/?\.config\/gh\//, reason: 'GitHub CLI tokens' },
115
+ { pattern: /^~?\/?\.npmrc$/, reason: 'npm auth tokens' },
116
+ { pattern: /^~?\/?\.netrc$/, reason: 'Network credentials' },
117
+ { pattern: /^~?\/?\.kube\//, reason: 'Kubernetes config' },
118
+ { pattern: /^~?\/?\.docker\/config\.json$/, reason: 'Docker credentials' },
119
+ ];
120
+
121
+ function guardReadPath(path: string): GuardAction {
122
+ if (!path) return { type: 'allow' };
123
+ const p = path.trim();
124
+
125
+ for (const { pattern, reason } of BLOCKED_READ_PATHS) {
126
+ if (pattern.test(p)) return { type: 'block', reason };
127
+ }
128
+ for (const { pattern, reason } of SENSITIVE_READ_PATHS) {
129
+ if (pattern.test(p)) return { type: 'approve', reason };
130
+ }
131
+ if (p.startsWith('/') || p.startsWith('~')) {
132
+ return { type: 'approve', reason: 'Reading path outside project root' };
133
+ }
134
+ return { type: 'allow' };
135
+ }
136
+
137
+ const SENSITIVE_WRITE_PATHS: Array<{ pattern: RegExp; reason: string }> = [
138
+ { pattern: /(^|\/)\.env($|\.)/, reason: 'Writing to environment file' },
139
+ { pattern: /(^|\/)\.git\/hooks\//, reason: 'Writing to git hooks' },
140
+ ];
141
+
142
+ function guardWritePath(
143
+ toolName: string,
144
+ args: Record<string, unknown>,
145
+ ): GuardAction {
146
+ const path =
147
+ typeof args.path === 'string'
148
+ ? args.path
149
+ : typeof args.filePath === 'string'
150
+ ? args.filePath
151
+ : '';
152
+ if (!path) return { type: 'allow' };
153
+ const p = path.trim();
154
+
155
+ for (const { pattern, reason } of SENSITIVE_WRITE_PATHS) {
156
+ if (pattern.test(p)) return { type: 'approve', reason };
157
+ }
158
+ return { type: 'allow' };
159
+ }
@@ -17,6 +17,7 @@ import {
17
17
  requiresApproval,
18
18
  requestApproval,
19
19
  } from '../runtime/tools/approval.ts';
20
+ import { guardToolCall } from '../runtime/tools/guards.ts';
20
21
 
21
22
  export type { ToolAdapterContext } from '../runtime/tools/context.ts';
22
23
 
@@ -38,6 +39,8 @@ type PendingCallMeta = {
38
39
  stepIndex?: number;
39
40
  args?: unknown;
40
41
  approvalPromise?: Promise<boolean>;
42
+ blocked?: boolean;
43
+ blockReason?: string;
41
44
  };
42
45
 
43
46
  function getPendingQueue(
@@ -336,6 +339,19 @@ export function adaptTools(
336
339
  args,
337
340
  );
338
341
  }
342
+ const guard = guardToolCall(name, args);
343
+ if (guard.type === 'block') {
344
+ meta.blocked = true;
345
+ meta.blockReason = guard.reason;
346
+ } else if (guard.type === 'approve' && !meta.approvalPromise) {
347
+ meta.approvalPromise = requestApproval(
348
+ ctx.sessionId,
349
+ ctx.messageId,
350
+ callId,
351
+ name,
352
+ args,
353
+ );
354
+ }
339
355
  if (typeof base.onInputAvailable === 'function') {
340
356
  // biome-ignore lint/suspicious/noExplicitAny: AI SDK types are complex
341
357
  await base.onInputAvailable(options as any);
@@ -367,14 +383,36 @@ export function adaptTools(
367
383
 
368
384
  const executeWithGuards = async (): Promise<ToolExecuteReturn> => {
369
385
  try {
386
+ if (meta?.blocked) {
387
+ const blockedResult = {
388
+ ok: false,
389
+ error: `Blocked: ${meta.blockReason}`,
390
+ details: { reason: 'safety_guard' },
391
+ };
392
+ await persistToolErrorResult(blockedResult, {
393
+ callId: callIdFromQueue,
394
+ startTs: startTsFromQueue,
395
+ stepIndexForEvent,
396
+ args: meta?.args,
397
+ });
398
+ return blockedResult as ToolExecuteReturn;
399
+ }
370
400
  // Await approval if it was requested in onInputAvailable
371
401
  if (meta?.approvalPromise) {
372
402
  const approved = await meta.approvalPromise;
373
403
  if (!approved) {
374
- return {
404
+ const rejectedResult = {
375
405
  ok: false,
376
406
  error: 'Tool execution rejected by user',
377
- } as ToolExecuteReturn;
407
+ details: { reason: 'user_rejected' },
408
+ };
409
+ await persistToolErrorResult(rejectedResult, {
410
+ callId: callIdFromQueue,
411
+ startTs: startTsFromQueue,
412
+ stepIndexForEvent,
413
+ args: meta?.args,
414
+ });
415
+ return rejectedResult as ToolExecuteReturn;
378
416
  }
379
417
  }
380
418
  // Handle session-relative paths and cwd tools