@ai-sdk/harness-claude-code 1.0.174 → 1.0.176

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": "@ai-sdk/harness-claude-code",
3
- "version": "1.0.174",
3
+ "version": "1.0.176",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "sideEffects": false,
@@ -26,9 +26,9 @@
26
26
  }
27
27
  },
28
28
  "dependencies": {
29
- "@ai-sdk/harness": "1.0.170",
29
+ "@ai-sdk/harness": "1.0.172",
30
30
  "@ai-sdk/provider": "4.0.35",
31
- "@ai-sdk/provider-utils": "5.0.72",
31
+ "@ai-sdk/provider-utils": "5.0.73",
32
32
  "ws": "^8.21.0"
33
33
  },
34
34
  "peerDependencies": {
@@ -55,6 +55,7 @@ import {
55
55
  type ClaudeMessage,
56
56
  } from './create-emit-stream-event';
57
57
  import { jsonSchemaToZodObject } from './json-schema-to-zod';
58
+ import { areFileToolPathsTrusted } from './permission-paths';
58
59
  import {
59
60
  resolveInactiveNativeTools,
60
61
  resolveNativeTools,
@@ -173,6 +174,7 @@ function createPermissionOptions(input: {
173
174
  approvalRequestedToolUseIds: Set<string>;
174
175
  emittedToolCallIds: Set<string>;
175
176
  approvalDecisions: Map<string, { approved: boolean; reason?: string }>;
177
+ toolCwds: Map<string, string>;
176
178
  }): Record<string, unknown> {
177
179
  const permissionMode = input.start.permissionMode ?? 'allow-all';
178
180
  const inactiveNativeTools = new Set(input.inactiveNativeTools);
@@ -210,10 +212,18 @@ function createPermissionOptions(input: {
210
212
  canUseTool: async (
211
213
  toolName: string,
212
214
  toolInput: Record<string, unknown>,
213
- options: { toolUseID: string },
215
+ options: { toolUseID: string; blockedPath?: string },
214
216
  ) => {
215
217
  if (
216
- !needsHostApproval({ toolName, permissionMode, inactiveNativeTools })
218
+ !(await needsHostApproval({
219
+ toolName,
220
+ toolInput,
221
+ permissionMode,
222
+ inactiveNativeTools,
223
+ cwd: input.toolCwds.get(options.toolUseID) ?? '',
224
+ readableSkillRoots: input.start.readableSkillRoots ?? [],
225
+ blockedPath: options.blockedPath,
226
+ }))
217
227
  ) {
218
228
  return { behavior: 'allow', updatedInput: toolInput };
219
229
  }
@@ -390,19 +400,72 @@ function createGatewayModelOverrides(
390
400
  return id.includes('.') ? { [id.replace(/\./g, '-')]: model } : undefined;
391
401
  }
392
402
 
393
- function needsHostApproval(input: {
403
+ async function needsHostApproval(input: {
394
404
  toolName: string;
405
+ toolInput: unknown;
406
+ cwd: string;
407
+ readableSkillRoots: readonly string[];
408
+ blockedPath?: string;
395
409
  permissionMode: 'allow-reads' | 'allow-edits' | 'allow-all';
396
410
  inactiveNativeTools: ReadonlySet<string>;
397
- }): boolean {
411
+ }): Promise<boolean> {
398
412
  if (input.toolName.startsWith('mcp__harness-tools__')) return false;
399
- return (
413
+ if (
400
414
  input.inactiveNativeTools.has(input.toolName) ||
401
415
  nativeToolRequiresApproval({
402
416
  nativeName: input.toolName,
403
417
  permissionMode: input.permissionMode,
404
418
  })
405
- );
419
+ )
420
+ return true;
421
+ const args =
422
+ input.toolInput != null && typeof input.toolInput === 'object'
423
+ ? (input.toolInput as Record<string, unknown>)
424
+ : {};
425
+ let paths: unknown[];
426
+ let access: 'read' | 'write';
427
+ if (
428
+ input.toolName === 'Glob' &&
429
+ (typeof args.pattern !== 'string' ||
430
+ /\.\.|[{}()[\]]/.test(args.pattern) ||
431
+ args.pattern.includes('\\') ||
432
+ args.pattern.startsWith('/'))
433
+ )
434
+ return true;
435
+ switch (input.toolName) {
436
+ case 'Read':
437
+ paths = [args.file_path];
438
+ access = 'read';
439
+ break;
440
+ case 'Write':
441
+ case 'Edit':
442
+ paths = [args.file_path];
443
+ access = 'write';
444
+ break;
445
+ case 'NotebookEdit':
446
+ paths = [args.notebook_path];
447
+ access = 'write';
448
+ break;
449
+ case 'Glob':
450
+ case 'Grep':
451
+ paths = [args.path ?? input.cwd];
452
+ access = 'read';
453
+ break;
454
+ default:
455
+ if (input.blockedPath == null) return false;
456
+ paths = [];
457
+ access = 'read';
458
+ }
459
+ if (input.blockedPath != null) paths.push(input.blockedPath);
460
+ if (!paths.every((value): value is string => typeof value === 'string'))
461
+ return true;
462
+ return !(await areFileToolPathsTrusted({
463
+ workDir: workdir!,
464
+ cwd: input.cwd,
465
+ paths,
466
+ access,
467
+ readableSkillRoots: input.readableSkillRoots,
468
+ }));
406
469
  }
407
470
 
408
471
  /*
@@ -418,8 +481,11 @@ function createApprovalPreToolUseHook(input: {
418
481
  decisions: Map<string, { approved: boolean; reason?: string }>;
419
482
  deferredToolUseIds: Set<string>;
420
483
  nativeToolCallNames: Map<string, string>;
484
+ readableSkillRoots: readonly string[];
485
+ toolCwds: Map<string, string>;
421
486
  }): HookCallback {
422
487
  return async (hookInput, toolUseID): Promise<HookJSONOutput> => {
488
+ if (hookInput.hook_event_name !== 'PreToolUse') return {};
423
489
  const toolName = (hookInput as { tool_name?: unknown }).tool_name;
424
490
  if (
425
491
  toolUseID == null ||
@@ -428,6 +494,7 @@ function createApprovalPreToolUseHook(input: {
428
494
  ) {
429
495
  return {};
430
496
  }
497
+ input.toolCwds.set(toolUseID, hookInput.cwd);
431
498
  const decision = input.decisions.get(toolUseID);
432
499
  if (decision != null) {
433
500
  // A bridge that resumes another bridge's deferred call never saw its
@@ -447,11 +514,14 @@ function createApprovalPreToolUseHook(input: {
447
514
  };
448
515
  }
449
516
  if (
450
- !needsHostApproval({
517
+ !(await needsHostApproval({
451
518
  toolName,
519
+ toolInput: hookInput.tool_input,
520
+ cwd: hookInput.cwd,
521
+ readableSkillRoots: input.readableSkillRoots,
452
522
  permissionMode: input.permissionMode,
453
523
  inactiveNativeTools: input.inactiveNativeTools,
454
- })
524
+ }))
455
525
  ) {
456
526
  return {};
457
527
  }
@@ -470,6 +540,12 @@ function nativeToolRequiresApproval(input: {
470
540
  permissionMode: 'allow-reads' | 'allow-edits' | 'allow-all';
471
541
  }): boolean {
472
542
  if (input.permissionMode === 'allow-all') return false;
543
+ if (
544
+ input.nativeName === 'EnterWorktree' ||
545
+ input.nativeName === 'ExitWorktree'
546
+ )
547
+ return true;
548
+ if (!(input.nativeName in NATIVE_TOOL_KINDS)) return true;
473
549
  const kind = NATIVE_TOOL_KINDS[input.nativeName] ?? 'edit';
474
550
  if (input.permissionMode === 'allow-edits') return kind === 'bash';
475
551
  return kind === 'edit' || kind === 'bash';
@@ -653,7 +729,9 @@ async function runTurn(start: StartMessage, turn: BridgeTurn): Promise<void> {
653
729
  string,
654
730
  { approved: boolean; reason?: string }
655
731
  >();
732
+ const toolCwds = new Map<string, string>();
656
733
  const permissionOptions = createPermissionOptions({
734
+ toolCwds,
657
735
  approvalDecisions,
658
736
  start,
659
737
  inactiveNativeTools,
@@ -670,6 +748,8 @@ async function runTurn(start: StartMessage, turn: BridgeTurn): Promise<void> {
670
748
  const modelOverrides = createGatewayModelOverrides(start.model);
671
749
  const deferredToolUseIds = new Set<string>();
672
750
  const approvalPreToolUseHook = createApprovalPreToolUseHook({
751
+ toolCwds,
752
+ readableSkillRoots: start.readableSkillRoots ?? [],
673
753
  permissionMode,
674
754
  inactiveNativeTools: new Set(inactiveNativeTools),
675
755
  decisions: approvalDecisions,
@@ -0,0 +1,108 @@
1
+ import { lstat, realpath } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
4
+ export async function areFileToolPathsTrusted(options: {
5
+ workDir: string;
6
+ cwd: string;
7
+ paths: readonly string[];
8
+ access: 'read' | 'write';
9
+ readableSkillRoots: readonly string[];
10
+ }): Promise<boolean> {
11
+ if (options.paths.length === 0) return false;
12
+ try {
13
+ const roots = [await realpath(options.workDir)];
14
+ if (options.access === 'read') {
15
+ for (const root of options.readableSkillRoots) {
16
+ try {
17
+ roots.push(await realpath(root));
18
+ } catch {
19
+ continue;
20
+ }
21
+ }
22
+ }
23
+ for (const filePath of options.paths) {
24
+ /*
25
+ * Grep and Glob trim paths and expand home-directory shorthand after
26
+ * the approval hook. Require approval for these forms so a literal
27
+ * workdir-relative path cannot authorize a different resolved path.
28
+ */
29
+ if (
30
+ typeof filePath !== 'string' ||
31
+ filePath.length === 0 ||
32
+ filePath !== filePath.trim() ||
33
+ filePath === '~' ||
34
+ filePath.startsWith('~/')
35
+ ) {
36
+ return false;
37
+ }
38
+ if (
39
+ !path.posix.isAbsolute(filePath) &&
40
+ !path.posix.isAbsolute(options.cwd)
41
+ ) {
42
+ return false;
43
+ }
44
+ const absolutePath = path.posix.isAbsolute(filePath)
45
+ ? filePath
46
+ : `${options.cwd}/${filePath}`;
47
+ /*
48
+ * Native runtimes differ in whether they normalize parent components
49
+ * before filesystem access. Both interpretations must stay within a
50
+ * trusted root, including when a symlink precedes a parent component.
51
+ */
52
+ for (const candidatePath of [
53
+ absolutePath,
54
+ path.posix.normalize(absolutePath),
55
+ ]) {
56
+ const candidate = await resolvePermissionPath({
57
+ filePath: candidatePath,
58
+ });
59
+ if (
60
+ !roots.some(root => {
61
+ const relative = path.posix.relative(root, candidate);
62
+ return (
63
+ relative === '' ||
64
+ (relative !== '..' &&
65
+ !relative.startsWith('../') &&
66
+ !path.posix.isAbsolute(relative))
67
+ );
68
+ })
69
+ )
70
+ return false;
71
+ }
72
+ }
73
+ return true;
74
+ } catch {
75
+ return false;
76
+ }
77
+ }
78
+
79
+ async function resolvePermissionPath(options: {
80
+ filePath: string;
81
+ }): Promise<string> {
82
+ let existingPath = options.filePath;
83
+ const missing: string[] = [];
84
+ for (;;) {
85
+ let exists = true;
86
+ try {
87
+ await lstat(existingPath);
88
+ } catch (error) {
89
+ if (
90
+ error == null ||
91
+ typeof error !== 'object' ||
92
+ !('code' in error) ||
93
+ error.code !== 'ENOENT'
94
+ )
95
+ throw error;
96
+ exists = false;
97
+ }
98
+ if (exists)
99
+ return path.posix.join(await realpath(existingPath), ...missing);
100
+ const parent = path.posix.dirname(existingPath);
101
+ const segment = path.posix.basename(existingPath);
102
+ if (parent === existingPath || segment === '.' || segment === '..') {
103
+ throw new Error('Unable to resolve permission path.');
104
+ }
105
+ missing.unshift(segment);
106
+ existingPath = parent;
107
+ }
108
+ }
@@ -38,6 +38,7 @@ const skillOverridesSchema = z.record(
38
38
 
39
39
  export const startMessageSchema = harnessV1BridgeStartBaseSchema.extend({
40
40
  prompt: harnessPromptContentSchema,
41
+ readableSkillRoots: z.array(z.string()).optional(),
41
42
  instructions: z.string().optional(),
42
43
  thinking: thinkingSchema,
43
44
  effort: z.enum(['low', 'medium', 'high', 'xhigh', 'max']).optional(),
@@ -1670,7 +1670,12 @@ function createSession({
1670
1670
  thinking,
1671
1671
  ...(effort !== undefined ? { effort } : {}),
1672
1672
  ...(promptOpts.skills.length > 0
1673
- ? { skills: promptOpts.skills.map(skill => skill.name) }
1673
+ ? {
1674
+ skills: promptOpts.skills.map(skill => skill.name),
1675
+ readableSkillRoots: promptOpts.skills.map(
1676
+ skill => `${sandboxHomeDir}/.claude/skills/${skill.name}`,
1677
+ ),
1678
+ }
1674
1679
  : {}),
1675
1680
  ...(mcpServers == null ? {} : { mcpServers }),
1676
1681
  ...(toolHints == null ? {} : { toolHints }),
@@ -1749,7 +1754,12 @@ function createSession({
1749
1754
  thinking,
1750
1755
  ...(effort !== undefined ? { effort } : {}),
1751
1756
  ...(continueOpts.skills.length > 0
1752
- ? { skills: continueOpts.skills.map(skill => skill.name) }
1757
+ ? {
1758
+ skills: continueOpts.skills.map(skill => skill.name),
1759
+ readableSkillRoots: continueOpts.skills.map(
1760
+ skill => `${sandboxHomeDir}/.claude/skills/${skill.name}`,
1761
+ ),
1762
+ }
1753
1763
  : {}),
1754
1764
  ...(mcpServers == null ? {} : { mcpServers }),
1755
1765
  ...(toolHints == null ? {} : { toolHints }),