praxis-agent 0.20.4 → 0.20.7

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.
@@ -87,8 +87,12 @@ async function downloadOne(resource, config) {
87
87
  ? AbortSignal.any([config.signal, timeoutSignal])
88
88
  : timeoutSignal;
89
89
  const response = await fetcher(filesEndpoint(config.baseUrl, resource.fileId), {
90
- headers: config.headers ?? {
91
- Authorization: `Bearer ${config.apiKey}`,
90
+ headers: {
91
+ ...(config.headers ?? {
92
+ Authorization: `Bearer ${config.apiKey}`,
93
+ }),
94
+ 'anthropic-version': config.headers?.['anthropic-version'] ?? '2023-06-01',
95
+ 'anthropic-beta': config.headers?.['anthropic-beta'] ?? 'files-api-2025-04-14',
92
96
  },
93
97
  signal: requestSignal,
94
98
  });
@@ -12,6 +12,7 @@ export interface BashPathSafetyOptions {
12
12
  sandboxWriteConfig?: FsWriteRestrictionConfig;
13
13
  permissionMode: 'acceptEdits' | 'auto' | 'bypassPermissions' | 'manual' | 'dontAsk' | 'plan' | 'default';
14
14
  fileRule?: (operation: FileOperation, absolutePath: string) => RuleOutcome;
15
+ protectedWrite?: (absolutePath: string) => string | undefined;
15
16
  platform?: NodeJS.Platform;
16
17
  }
17
18
  export type BashPathSafetyResult = {
@@ -421,6 +421,20 @@ function pathFailure(rawPath, operation, options, dangerous = false) {
421
421
  : rawPath;
422
422
  const absolutePath = expandPath(validationPath, options);
423
423
  const pathsToCheck = pathRepresentations(absolutePath);
424
+ if (operation !== 'read' && options.protectedWrite !== undefined) {
425
+ for (const path of pathsToCheck) {
426
+ const protectedReason = options.protectedWrite(path);
427
+ if (protectedReason !== undefined) {
428
+ return {
429
+ safe: false,
430
+ behavior: 'deny',
431
+ reason: `Refusing to write protected path: ${protectedReason}`,
432
+ path: absolutePath,
433
+ operation,
434
+ };
435
+ }
436
+ }
437
+ }
424
438
  const rules = pathsToCheck.map((path) => options.fileRule?.(operation, path) ?? null);
425
439
  if (rules.includes('deny')) {
426
440
  return {
@@ -0,0 +1,11 @@
1
+ export interface ProtectedWriteOptions {
2
+ homeDirectory: string;
3
+ configRoot: string;
4
+ }
5
+ /**
6
+ * Returns a human-readable rejection reason when a write to `path` targets a
7
+ * bypass-immune protected credential or configuration file, or undefined when
8
+ * the write is not protected. Reads are never affected.
9
+ */
10
+ export declare function protectedWritePathReason(path: string, options: ProtectedWriteOptions): string | undefined;
11
+ //# sourceMappingURL=bypass-immune-paths.d.ts.map
@@ -0,0 +1,86 @@
1
+ import { realpathSync } from 'node:fs';
2
+ import { dirname, isAbsolute, relative, resolve } from 'node:path';
3
+ const SSH_CREDENTIAL_BASENAMES = new Set([
4
+ 'authorized_keys',
5
+ 'id_rsa',
6
+ 'id_ed25519',
7
+ 'id_ecdsa',
8
+ 'id_dsa',
9
+ ]);
10
+ const AWS_CREDENTIAL_BASENAMES = new Set(['credentials']);
11
+ function isWithin(candidate, root) {
12
+ const child = relative(resolve(root), resolve(candidate));
13
+ return child === '' || (!child.startsWith('..') && !isAbsolute(child));
14
+ }
15
+ function pathRepresentations(path) {
16
+ const absolute = resolve(path);
17
+ const representations = new Set([absolute]);
18
+ let existing = absolute;
19
+ for (;;) {
20
+ try {
21
+ realpathSync(existing);
22
+ break;
23
+ }
24
+ catch {
25
+ const parent = dirname(existing);
26
+ if (parent === existing)
27
+ return [...representations];
28
+ existing = parent;
29
+ }
30
+ }
31
+ try {
32
+ representations.add(resolve(realpathSync.native(existing), relative(existing, absolute)));
33
+ }
34
+ catch {
35
+ // A disappearing or inaccessible path retains its lexical representation.
36
+ }
37
+ try {
38
+ representations.add(realpathSync.native(absolute));
39
+ }
40
+ catch {
41
+ // Non-existent targets are covered by their deepest existing ancestor.
42
+ }
43
+ return [...representations];
44
+ }
45
+ function lowerBasename(path) {
46
+ const segments = path.split(/[\\/]+/u).filter(Boolean);
47
+ return (segments.at(-1) ?? '').toLowerCase();
48
+ }
49
+ /**
50
+ * Returns a human-readable rejection reason when a write to `path` targets a
51
+ * bypass-immune protected credential or configuration file, or undefined when
52
+ * the write is not protected. Reads are never affected.
53
+ */
54
+ export function protectedWritePathReason(path, options) {
55
+ const homeDirectory = resolve(options.homeDirectory);
56
+ const configRoot = resolve(options.configRoot);
57
+ const sshDirectory = resolve(homeDirectory, '.ssh');
58
+ const awsDirectory = resolve(homeDirectory, '.aws');
59
+ const sshDirectories = pathRepresentations(sshDirectory);
60
+ const awsDirectories = pathRepresentations(awsDirectory);
61
+ const configRoots = pathRepresentations(configRoot);
62
+ for (const representation of pathRepresentations(path)) {
63
+ const basename = lowerBasename(representation);
64
+ if (sshDirectories.some((root) => isWithin(representation, root))) {
65
+ return `${sshDirectory} is a protected SSH directory`;
66
+ }
67
+ if (awsDirectories.some((root) => isWithin(representation, root))) {
68
+ return `${awsDirectory} is a protected AWS credentials directory`;
69
+ }
70
+ if (SSH_CREDENTIAL_BASENAMES.has(basename)) {
71
+ return `${basename} is a protected SSH credential file`;
72
+ }
73
+ if (AWS_CREDENTIAL_BASENAMES.has(basename)) {
74
+ return `${basename} is a protected AWS credential file`;
75
+ }
76
+ if (basename === '.env' || basename.startsWith('.env.')) {
77
+ return '.env and .env.* files are protected';
78
+ }
79
+ if (configRoots.some((root) => isWithin(representation, root)) &&
80
+ (basename === 'settings.json' || basename.endsWith('.jsonl'))) {
81
+ return 'settings.json and .jsonl files in the Claude config root are protected';
82
+ }
83
+ }
84
+ return undefined;
85
+ }
86
+ //# sourceMappingURL=bypass-immune-paths.js.map
@@ -11,6 +11,8 @@ export interface LocalToolRegistryOptions {
11
11
  enableReportFindings?: boolean;
12
12
  environment?: Readonly<Record<string, string>>;
13
13
  sandbox?: BashSandboxRuntime;
14
+ homeDirectory?: string;
15
+ configRoot?: string;
14
16
  }
15
17
  export interface BashSandboxRuntime {
16
18
  shouldUseSandbox(input: {
@@ -41,7 +43,12 @@ export declare class LocalToolRegistry implements ToolRegistry {
41
43
  private readonly enableReportFindings;
42
44
  private readonly environment;
43
45
  private readonly sandbox;
46
+ private readonly homeDirectory;
47
+ private readonly configRoot;
48
+ private readonly protectedWriteReason;
44
49
  constructor(options: LocalToolRegistryOptions);
50
+ private assertProtectedWritePath;
51
+ private assertProtectedBashCommand;
45
52
  private currentCwd;
46
53
  definitions(): readonly ModelToolDefinition[];
47
54
  prepare(call: ModelToolCall, context: ToolExecutionContext): Promise<ModelToolCall>;
@@ -1,7 +1,7 @@
1
1
  import { constants } from 'node:fs';
2
2
  import { mkdir, mkdtemp, open, realpath, stat, writeFile, } from 'node:fs/promises';
3
3
  import { basename, dirname, extname, isAbsolute, join, relative, resolve, } from 'node:path';
4
- import { tmpdir } from 'node:os';
4
+ import { homedir, tmpdir } from 'node:os';
5
5
  import sharp from 'sharp';
6
6
  import { commandShell, commandShellArguments, } from '../platform/command-shell.js';
7
7
  import { BoundedProcessRunner, joinedProcessOutput, } from '../platform/bounded-process-runner.js';
@@ -9,6 +9,8 @@ import { globFiles } from './glob.js';
9
9
  import { countLineChanges } from './line-changes.js';
10
10
  import { editNotebook, formatNotebookForRead } from './notebook.js';
11
11
  import { openPdf } from './pdf.js';
12
+ import { validateBashPathSafety } from '../permissions/bash-path-safety.js';
13
+ import { protectedWritePathReason } from '../permissions/bypass-immune-paths.js';
12
14
  import { effectiveAdditionalDirectories } from '../permissions/permission-updates.js';
13
15
  const REPORT_FINDINGS_DEFINITION = {
14
16
  name: 'ReportFindings',
@@ -436,6 +438,9 @@ export class LocalToolRegistry {
436
438
  enableReportFindings;
437
439
  environment;
438
440
  sandbox;
441
+ homeDirectory;
442
+ configRoot;
443
+ protectedWriteReason;
439
444
  constructor(options) {
440
445
  this.cwd = resolve(options.cwd);
441
446
  this.cwdProvider = options.cwdProvider;
@@ -450,11 +455,43 @@ export class LocalToolRegistry {
450
455
  this.enableReportFindings = options.enableReportFindings ?? false;
451
456
  this.environment = options.environment;
452
457
  this.sandbox = options.sandbox;
458
+ this.homeDirectory = options.homeDirectory
459
+ ? resolve(options.homeDirectory)
460
+ : homedir();
461
+ this.configRoot = options.configRoot
462
+ ? resolve(options.configRoot)
463
+ : process.env.CLAUDE_CONFIG_DIR
464
+ ? resolve(process.env.CLAUDE_CONFIG_DIR)
465
+ : resolve(this.homeDirectory, '.claude');
466
+ this.protectedWriteReason = (absolutePath) => protectedWritePathReason(absolutePath, {
467
+ homeDirectory: this.homeDirectory,
468
+ configRoot: this.configRoot,
469
+ });
453
470
  this.processRunner = new BoundedProcessRunner({
454
471
  cwd: this.cwd,
455
472
  maxOutputBytes: this.maxOutputBytes,
456
473
  });
457
474
  }
475
+ assertProtectedWritePath(filePath) {
476
+ const reason = this.protectedWriteReason(filePath);
477
+ if (reason !== undefined) {
478
+ throw new Error(`Refusing to write protected path: ${reason}`);
479
+ }
480
+ }
481
+ assertProtectedBashCommand(command, context) {
482
+ const cwd = this.currentCwd(context);
483
+ const result = validateBashPathSafety(command, {
484
+ cwd,
485
+ homeDirectory: this.homeDirectory,
486
+ readRoots: [cwd],
487
+ writeRoots: [cwd],
488
+ permissionMode: 'bypassPermissions',
489
+ protectedWrite: this.protectedWriteReason,
490
+ });
491
+ if (!result.safe && result.behavior === 'deny') {
492
+ throw new Error(result.reason);
493
+ }
494
+ }
458
495
  currentCwd(context) {
459
496
  return resolve(context?.cwd || this.cwdProvider?.() || this.cwd);
460
497
  }
@@ -910,6 +947,7 @@ export class LocalToolRegistry {
910
947
  }
911
948
  async write(call) {
912
949
  const filePath = stringInput(call.input, 'file_path');
950
+ this.assertProtectedWritePath(filePath);
913
951
  const content = stringInput(call.input, 'content', true);
914
952
  if (Buffer.byteLength(content) > this.maxFileBytes) {
915
953
  throw new Error(`Content exceeds ${this.maxFileBytes} byte write limit`);
@@ -960,6 +998,7 @@ export class LocalToolRegistry {
960
998
  }
961
999
  async edit(call) {
962
1000
  const filePath = stringInput(call.input, 'file_path');
1001
+ this.assertProtectedWritePath(filePath);
963
1002
  const oldString = stringInput(call.input, 'old_string');
964
1003
  const newString = stringInput(call.input, 'new_string', true);
965
1004
  const handle = await open(filePath, constants.O_RDWR | constants.O_NOFOLLOW);
@@ -1006,6 +1045,7 @@ export class LocalToolRegistry {
1006
1045
  }
1007
1046
  async notebookEdit(call) {
1008
1047
  const filePath = stringInput(call.input, 'notebook_path');
1048
+ this.assertProtectedWritePath(filePath);
1009
1049
  const handle = await open(filePath, constants.O_RDWR | constants.O_NOFOLLOW);
1010
1050
  try {
1011
1051
  await this.assertStablePath(filePath);
@@ -1117,6 +1157,7 @@ export class LocalToolRegistry {
1117
1157
  if (timeout === undefined)
1118
1158
  throw new Error('Prepared Bash call has no timeout');
1119
1159
  const rawCommand = stringInput(call.input, 'command');
1160
+ this.assertProtectedBashCommand(rawCommand, context);
1120
1161
  const sandboxInput = {
1121
1162
  command: rawCommand,
1122
1163
  ...(call.input.dangerouslyDisableSandbox === true
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "praxis-agent",
3
- "version": "0.20.4",
3
+ "version": "0.20.7",
4
4
  "description": "Local-first, single-user general agent for the command line.",
5
5
  "license": "MIT",
6
6
  "author": "wuqisen",