praxis-agent 0.46.2 → 0.46.5

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/README.md CHANGED
@@ -153,6 +153,9 @@ troubleshooting. Run `praxis --help` for the authoritative command surface.
153
153
  structured JSON/JSONL, context compaction, tool loops, and bounded execution.
154
154
  - **Built-in tools** — read, write, edit, glob, search, shell, notebook, PDF,
155
155
  image, web, scheduled prompts, workflows, and worktrees.
156
+ - **Shell lifecycle** — foreground Bash allows up to 10 minutes and carries a
157
+ validated final working directory across calls in the same session without
158
+ leaking state across sessions or overriding an explicit `/cd`.
156
159
  - **Permission boundary** — local allow/ask/deny rules, safe and bare modes,
157
160
  searchable scoped-rule creation/removal, local/project/user atomic settings
158
161
  writes, tool-specific Bash/PowerShell/file/notebook/WebFetch/Skill approval
@@ -5,6 +5,7 @@ export interface ProcessResult {
5
5
  code: number;
6
6
  timedOut: boolean;
7
7
  truncated: boolean;
8
+ controlOutput?: string;
8
9
  }
9
10
  export interface BoundedProcessRunnerOptions {
10
11
  cwd: string;
@@ -18,6 +19,9 @@ export interface RunProcessOptions {
18
19
  signal?: AbortSignal;
19
20
  onOutput?: (output: string) => void | Promise<void>;
20
21
  env?: Readonly<Record<string, string>>;
22
+ controlOutputBytes?: number;
23
+ controlOutputFd?: number;
24
+ scriptInput?: string;
21
25
  }
22
26
  export declare function joinedProcessOutput(result: ProcessResult): string;
23
27
  export declare class BoundedProcessRunner {
@@ -87,15 +87,35 @@ export class BoundedProcessRunner {
87
87
  const sensitiveValues = sensitiveEnvironmentValues(process.env);
88
88
  const longestSensitiveValueBytes = sensitiveValues.reduce((longest, value) => Math.max(longest, Buffer.byteLength(value)), 0);
89
89
  const rawOutputLimit = this.options.maxOutputBytes + Math.max(3, longestSensitiveValueBytes);
90
+ const controlOutputFd = options.controlOutputBytes === undefined
91
+ ? undefined
92
+ : (options.controlOutputFd ?? 3);
93
+ const scriptInputFd = options.scriptInput === undefined ? undefined : 4;
94
+ if (controlOutputFd !== undefined &&
95
+ scriptInputFd !== undefined &&
96
+ controlOutputFd === scriptInputFd) {
97
+ reject(new Error('Process control output and script input FDs conflict'));
98
+ return;
99
+ }
100
+ const highestFd = Math.max(2, controlOutputFd ?? 2, scriptInputFd ?? 2);
101
+ const stdio = Array.from({ length: highestFd + 1 }, () => 'ignore');
102
+ stdio[1] = 'pipe';
103
+ stdio[2] = 'pipe';
104
+ if (controlOutputFd !== undefined)
105
+ stdio[controlOutputFd] = 'pipe';
106
+ if (scriptInputFd !== undefined)
107
+ stdio[scriptInputFd] = 'pipe';
90
108
  const child = spawn(options.command, options.args, {
91
109
  cwd: options.cwd ?? this.options.cwd,
92
110
  detached: process.platform !== 'win32',
93
111
  env: { ...sanitizeChildEnvironment(), ...options.env },
94
- stdio: ['ignore', 'pipe', 'pipe'],
112
+ stdio,
95
113
  });
96
114
  const chunks = { stdout: [], stderr: [] };
97
115
  const combined = [];
98
116
  const retainedBytes = { stdout: 0, stderr: 0, combined: 0 };
117
+ const controlChunks = [];
118
+ let controlBytes = 0;
99
119
  let outputBytes = 0;
100
120
  let timedOut = false;
101
121
  let settled = false;
@@ -136,8 +156,41 @@ export class BoundedProcessRunner {
136
156
  }
137
157
  updateLiveOutput();
138
158
  };
159
+ if (!child.stdout || !child.stderr) {
160
+ reject(new Error('Process output streams were not created'));
161
+ return;
162
+ }
139
163
  child.stdout.on('data', (chunk) => retain('stdout', chunk));
140
164
  child.stderr.on('data', (chunk) => retain('stderr', chunk));
165
+ const controlStream = controlOutputFd === undefined ? undefined : child.stdio[controlOutputFd];
166
+ if (controlStream) {
167
+ controlStream.on('data', (chunk) => {
168
+ const remaining = Math.max(0, (options.controlOutputBytes ?? 0) - controlBytes);
169
+ if (remaining <= 0)
170
+ return;
171
+ const retained = chunk.subarray(0, remaining);
172
+ controlChunks.push(retained);
173
+ controlBytes += retained.length;
174
+ });
175
+ child.once('exit', () => {
176
+ setImmediate(() => controlStream.destroy());
177
+ });
178
+ }
179
+ const scriptStream = scriptInputFd === undefined ? undefined : child.stdio[scriptInputFd];
180
+ if (scriptStream) {
181
+ scriptStream.on('error', () => {
182
+ // The child process result remains authoritative if it exits before
183
+ // consuming the complete script.
184
+ });
185
+ if (!('end' in scriptStream)) {
186
+ reject(new Error('Process script input stream is not writable'));
187
+ return;
188
+ }
189
+ scriptStream.end(options.scriptInput);
190
+ child.once('exit', () => {
191
+ setImmediate(() => scriptStream.destroy());
192
+ });
193
+ }
141
194
  const kill = () => {
142
195
  if (child.pid === undefined)
143
196
  return;
@@ -200,6 +253,11 @@ export class BoundedProcessRunner {
200
253
  code: code ?? 1,
201
254
  timedOut,
202
255
  truncated,
256
+ ...(options.controlOutputBytes === undefined
257
+ ? {}
258
+ : {
259
+ controlOutput: Buffer.concat(controlChunks).toString('utf8'),
260
+ }),
203
261
  });
204
262
  }, reject);
205
263
  });
@@ -21,6 +21,7 @@ export interface SandboxBackend {
21
21
  export interface SandboxCommandInput {
22
22
  command: string;
23
23
  dangerouslyDisableSandbox?: boolean;
24
+ executionCommand?: string;
24
25
  }
25
26
  export declare class ClaudeSandboxRuntime {
26
27
  private readonly backend;
@@ -136,7 +136,7 @@ export class ClaudeSandboxRuntime {
136
136
  throw new Error('Sandbox has not been initialized');
137
137
  if (!this.shouldUseSandbox(input))
138
138
  return input.command;
139
- return this.backend.wrapWithSandbox(input.command, options.shell, undefined, options.signal, {
139
+ return this.backend.wrapWithSandbox(input.executionCommand ?? input.command, options.shell, undefined, options.signal, {
140
140
  ...(options.commandId ? { commandId: options.commandId } : {}),
141
141
  commandText: input.command,
142
142
  });
@@ -26,6 +26,7 @@ export interface BashSandboxRuntime {
26
26
  wrapCommand(input: {
27
27
  command: string;
28
28
  dangerouslyDisableSandbox?: boolean;
29
+ executionCommand?: string;
29
30
  }, options?: {
30
31
  shell?: string;
31
32
  signal?: AbortSignal;
@@ -43,6 +44,7 @@ export declare class LocalToolRegistry implements ToolRegistry {
43
44
  private readonly maxOutputBytes;
44
45
  private readonly maxFileBytes;
45
46
  private readonly maxShellTimeoutMs;
47
+ private readonly maxSearchTimeoutMs;
46
48
  private readonly processRunner;
47
49
  private readonly enableReportFindings;
48
50
  private readonly environment;
@@ -50,11 +52,14 @@ export declare class LocalToolRegistry implements ToolRegistry {
50
52
  private readonly sandbox;
51
53
  private readonly homeDirectory;
52
54
  private readonly configRoot;
55
+ private readonly sessionCwds;
53
56
  private readonly protectedWriteReason;
57
+ private readonly mutationTargetExisted;
54
58
  constructor(options: LocalToolRegistryOptions);
55
59
  private assertProtectedWritePath;
56
60
  private assertProtectedBashCommand;
57
61
  private currentCwd;
62
+ private currentBashCwd;
58
63
  definitions(): readonly ModelToolDefinition[];
59
64
  schedulingPolicy(call: ModelToolCall): {
60
65
  concurrency: "concurrent";
@@ -78,7 +83,9 @@ export declare class LocalToolRegistry implements ToolRegistry {
78
83
  private workspacePath;
79
84
  private filePath;
80
85
  private resolvePath;
86
+ private pathExists;
81
87
  private assertStablePath;
88
+ private assertStableCreationParent;
82
89
  private globRoot;
83
90
  private wasSuccessfullyRead;
84
91
  private read;
@@ -1,8 +1,10 @@
1
1
  import { constants } from 'node:fs';
2
+ import { randomBytes, randomInt } from 'node:crypto';
2
3
  import { mkdir, mkdtemp, open, realpath, stat, writeFile, } from 'node:fs/promises';
3
4
  import { basename, dirname, extname, isAbsolute, join, relative, resolve, } from 'node:path';
4
5
  import { homedir, tmpdir } from 'node:os';
5
6
  import sharp from 'sharp';
7
+ import { countTokens } from '@anthropic-ai/tokenizer';
6
8
  import { commandShell, commandShellArguments, } from '../platform/command-shell.js';
7
9
  import { BoundedProcessRunner, joinedProcessOutput, } from '../platform/bounded-process-runner.js';
8
10
  import { globFiles } from './glob.js';
@@ -247,6 +249,20 @@ function stringInput(input, name, allowEmpty = false) {
247
249
  }
248
250
  return value;
249
251
  }
252
+ function stripShellControlTrace(content, token) {
253
+ if (!content.includes(token))
254
+ return content;
255
+ return content
256
+ .split('\n')
257
+ .filter((line) => !line.includes(token))
258
+ .join('\n');
259
+ }
260
+ function commandShellSyntaxArguments(command) {
261
+ const args = [...commandShellArguments(command)];
262
+ const commandIndex = args.indexOf('-c');
263
+ args.splice(commandIndex, 0, '-n');
264
+ return args;
265
+ }
250
266
  function optionalString(input, name) {
251
267
  const value = input[name];
252
268
  if (value === undefined)
@@ -447,6 +463,8 @@ function truncateOutput(content, maxBytes) {
447
463
  ? `${retained.content}\n[output truncated]`
448
464
  : retained.content;
449
465
  }
466
+ const TEXT_READ_MAX_BYTES = 256 * 1024;
467
+ const TEXT_READ_MAX_TOKENS = 25_000;
450
468
  function abortError() {
451
469
  return new DOMException('Tool execution aborted', 'AbortError');
452
470
  }
@@ -480,6 +498,14 @@ function formatKilobytes(bytes) {
480
498
  const value = bytes / 1024;
481
499
  return `${value < 10 ? value.toFixed(1) : Math.round(value)}KB`.replace('.0KB', 'KB');
482
500
  }
501
+ function newlineStyle(content) {
502
+ return content.match(/\r\n|\n|\r/u)?.[0];
503
+ }
504
+ function normalizeNewlines(content, style) {
505
+ if (style === undefined)
506
+ return content;
507
+ return content.replace(/\r\n|\n|\r/gu, style);
508
+ }
483
509
  function parsePdfPages(value) {
484
510
  const match = /^(\d+)(?:-(\d+))?$/u.exec(value);
485
511
  if (!match)
@@ -506,6 +532,7 @@ export class LocalToolRegistry {
506
532
  maxOutputBytes;
507
533
  maxFileBytes;
508
534
  maxShellTimeoutMs;
535
+ maxSearchTimeoutMs;
509
536
  processRunner;
510
537
  enableReportFindings;
511
538
  environment;
@@ -513,7 +540,9 @@ export class LocalToolRegistry {
513
540
  sandbox;
514
541
  homeDirectory;
515
542
  configRoot;
543
+ sessionCwds = new Map();
516
544
  protectedWriteReason;
545
+ mutationTargetExisted = new WeakMap();
517
546
  constructor(options) {
518
547
  this.cwd = resolve(options.cwd);
519
548
  this.cwdProvider = options.cwdProvider;
@@ -524,7 +553,8 @@ export class LocalToolRegistry {
524
553
  this.additionalReadDirectories = (options.additionalReadDirectories ?? []).map((directory) => resolve(directory));
525
554
  this.maxOutputBytes = options.maxOutputBytes ?? 128 * 1024;
526
555
  this.maxFileBytes = options.maxFileBytes ?? 10 * 1024 * 1024;
527
- this.maxShellTimeoutMs = options.maxShellTimeoutMs ?? 120_000;
556
+ this.maxShellTimeoutMs = options.maxShellTimeoutMs ?? 600_000;
557
+ this.maxSearchTimeoutMs = options.maxShellTimeoutMs ?? 120_000;
528
558
  this.enableReportFindings = options.enableReportFindings ?? false;
529
559
  this.environment = options.environment;
530
560
  this.sessionEnvironment = options.sessionEnvironment;
@@ -551,7 +581,7 @@ export class LocalToolRegistry {
551
581
  }
552
582
  }
553
583
  assertProtectedBashCommand(command, context) {
554
- const cwd = this.currentCwd(context);
584
+ const cwd = this.currentBashCwd(context);
555
585
  const protectedWrite = context?.preToolUseAllowed
556
586
  ? (filePath) => {
557
587
  const reason = this.protectedWriteReason(filePath);
@@ -576,6 +606,18 @@ export class LocalToolRegistry {
576
606
  currentCwd(context) {
577
607
  return resolve(context?.cwd || this.cwdProvider?.() || this.cwd);
578
608
  }
609
+ currentBashCwd(context) {
610
+ const hostCwd = this.currentCwd(context);
611
+ if (context?.sessionId) {
612
+ const sessionCwd = this.sessionCwds.get(context.sessionId);
613
+ if (sessionCwd) {
614
+ if (sessionCwd.hostCwd === hostCwd)
615
+ return sessionCwd.cwd;
616
+ this.sessionCwds.delete(context.sessionId);
617
+ }
618
+ }
619
+ return hostCwd;
620
+ }
579
621
  definitions() {
580
622
  const definitions = this.enableReportFindings
581
623
  ? [...TOOL_DEFINITIONS, REPORT_FINDINGS_DEFINITION]
@@ -646,28 +688,48 @@ export class LocalToolRegistry {
646
688
  },
647
689
  };
648
690
  }
649
- case 'Write':
650
- return {
691
+ case 'Write': {
692
+ const filePath = await this.filePath(stringInput(call.input, 'file_path'), true, false, context);
693
+ this.assertProtectedWritePath(filePath);
694
+ const targetExisted = await this.pathExists(filePath);
695
+ if (targetExisted &&
696
+ !(await this.wasSuccessfullyRead(filePath, context.messages ?? [], context))) {
697
+ throw new Error('<tool_use_error>File has not been read yet. Read it first before writing to it.</tool_use_error>');
698
+ }
699
+ const prepared = {
651
700
  ...call,
652
701
  input: {
653
- file_path: await this.filePath(stringInput(call.input, 'file_path'), true, false, context),
702
+ file_path: filePath,
654
703
  content: stringInput(call.input, 'content', true),
655
704
  },
656
705
  };
706
+ this.mutationTargetExisted.set(prepared, targetExisted);
707
+ return prepared;
708
+ }
657
709
  case 'Edit': {
658
710
  const replaceAll = call.input.replace_all;
659
711
  if (replaceAll !== undefined && typeof replaceAll !== 'boolean') {
660
712
  throw new Error('replace_all must be a boolean');
661
713
  }
662
- return {
714
+ const oldString = stringInput(call.input, 'old_string', true);
715
+ const filePath = await this.filePath(stringInput(call.input, 'file_path'), oldString === '', false, context);
716
+ this.assertProtectedWritePath(filePath);
717
+ const targetExisted = await this.pathExists(filePath);
718
+ if (targetExisted &&
719
+ !(await this.wasSuccessfullyRead(filePath, context.messages ?? [], context))) {
720
+ throw new Error('<tool_use_error>File has not been read yet. Read it first before writing to it.</tool_use_error>');
721
+ }
722
+ const prepared = {
663
723
  ...call,
664
724
  input: {
665
- file_path: await this.filePath(stringInput(call.input, 'file_path'), false, false, context),
666
- old_string: stringInput(call.input, 'old_string'),
725
+ file_path: filePath,
726
+ old_string: oldString,
667
727
  new_string: stringInput(call.input, 'new_string', true),
668
728
  replace_all: replaceAll ?? false,
669
729
  },
670
730
  };
731
+ this.mutationTargetExisted.set(prepared, targetExisted);
732
+ return prepared;
671
733
  }
672
734
  case 'NotebookEdit': {
673
735
  const requestedPath = stringInput(call.input, 'notebook_path');
@@ -759,17 +821,24 @@ export class LocalToolRegistry {
759
821
  async execute(call, context) {
760
822
  if (context.signal?.aborted)
761
823
  throw abortError();
824
+ const approvedTargetExisted = this.mutationTargetExisted.get(call);
762
825
  const prepared = await this.prepare(call, context);
763
826
  if (JSON.stringify(prepared.input) !== JSON.stringify(call.input)) {
764
827
  throw new Error('Tool input changed after permission approval');
765
828
  }
829
+ const executionTargetExisted = this.mutationTargetExisted.get(prepared);
830
+ if (approvedTargetExisted !== undefined &&
831
+ executionTargetExisted !== approvedTargetExisted) {
832
+ throw new Error('Tool input changed after permission approval');
833
+ }
834
+ const targetExisted = approvedTargetExisted ?? executionTargetExisted;
766
835
  switch (prepared.name) {
767
836
  case 'Read':
768
837
  return this.read(prepared, context);
769
838
  case 'Write':
770
- return this.write(prepared);
839
+ return this.write(prepared, context, targetExisted);
771
840
  case 'Edit':
772
- return this.edit(prepared);
841
+ return this.edit(prepared, targetExisted);
773
842
  case 'NotebookEdit':
774
843
  return this.notebookEdit(prepared);
775
844
  case 'Glob':
@@ -864,11 +933,28 @@ export class LocalToolRegistry {
864
933
  return join(parent, basename(candidate));
865
934
  }
866
935
  }
936
+ async pathExists(filePath) {
937
+ try {
938
+ await stat(filePath);
939
+ return true;
940
+ }
941
+ catch (error) {
942
+ if (error.code === 'ENOENT')
943
+ return false;
944
+ throw error;
945
+ }
946
+ }
867
947
  async assertStablePath(filePath) {
868
948
  if ((await realpath(filePath)) !== filePath) {
869
949
  throw new Error('Tool input changed after permission approval');
870
950
  }
871
951
  }
952
+ async assertStableCreationParent(filePath) {
953
+ const parent = dirname(filePath);
954
+ if ((await realpath(parent)) !== parent) {
955
+ throw new Error('Tool input changed after permission approval');
956
+ }
957
+ }
872
958
  async globRoot(requestedPath, context) {
873
959
  const displayedPath = isAbsolute(requestedPath)
874
960
  ? resolve(requestedPath)
@@ -954,8 +1040,20 @@ export class LocalToolRegistry {
954
1040
  : selected
955
1041
  .map((line, index) => `${(offset === 0 ? 0 : offset) + index}\t${line}`)
956
1042
  .join('\n');
1043
+ if (!notebook) {
1044
+ const contentBytes = Buffer.byteLength(content);
1045
+ if (contentBytes > TEXT_READ_MAX_BYTES) {
1046
+ throw new Error(`Read result is ${contentBytes} bytes, which exceeds the ${formatKilobytes(TEXT_READ_MAX_BYTES)} limit. Use offset and limit to read specific portions.`);
1047
+ }
1048
+ const contentTokens = countTokens(content);
1049
+ if (contentTokens > TEXT_READ_MAX_TOKENS) {
1050
+ throw new Error(`Read result is ${contentTokens} tokens, which exceeds the ${TEXT_READ_MAX_TOKENS} token limit. Use offset and limit to read specific portions.`);
1051
+ }
1052
+ }
957
1053
  return {
958
- content: truncateOutput(content, this.maxOutputBytes),
1054
+ content: notebook
1055
+ ? truncateOutput(content, this.maxOutputBytes)
1056
+ : content,
959
1057
  isError: false,
960
1058
  accessedPaths: [filePath],
961
1059
  ...(notebook
@@ -1053,21 +1151,19 @@ export class LocalToolRegistry {
1053
1151
  await mkdir(parent, { recursive: true });
1054
1152
  return mkdtemp(join(parent, 'pdf-'));
1055
1153
  }
1056
- async write(call) {
1154
+ async write(call, context, preparedTargetExisted) {
1057
1155
  const filePath = stringInput(call.input, 'file_path');
1058
1156
  this.assertProtectedWritePath(filePath);
1059
- const content = stringInput(call.input, 'content', true);
1060
- if (Buffer.byteLength(content) > this.maxFileBytes) {
1061
- throw new Error(`Content exceeds ${this.maxFileBytes} byte write limit`);
1062
- }
1157
+ const requestedContent = stringInput(call.input, 'content', true);
1063
1158
  let handle;
1064
1159
  let newFile = false;
1065
- try {
1066
- handle = await open(filePath, constants.O_RDWR | constants.O_NOFOLLOW);
1067
- }
1068
- catch (error) {
1069
- if (error.code !== 'ENOENT')
1070
- throw error;
1160
+ const hasRead = await this.wasSuccessfullyRead(filePath, context.messages ?? [], context);
1161
+ const targetExisted = preparedTargetExisted ?? (await this.pathExists(filePath));
1162
+ if (!targetExisted) {
1163
+ if (Buffer.byteLength(requestedContent) > this.maxFileBytes) {
1164
+ throw new Error(`Content exceeds ${this.maxFileBytes} byte write limit`);
1165
+ }
1166
+ await this.assertStableCreationParent(filePath);
1071
1167
  try {
1072
1168
  handle = await open(filePath, constants.O_RDWR |
1073
1169
  constants.O_CREAT |
@@ -1076,19 +1172,40 @@ export class LocalToolRegistry {
1076
1172
  newFile = true;
1077
1173
  }
1078
1174
  catch (createError) {
1079
- if (createError.code !== 'EEXIST') {
1080
- throw createError;
1175
+ if (createError.code === 'EEXIST') {
1176
+ throw new Error('<tool_use_error>File has not been read yet. Read it first before writing to it.</tool_use_error>');
1081
1177
  }
1178
+ throw createError;
1179
+ }
1180
+ }
1181
+ else {
1182
+ if (!hasRead) {
1183
+ throw new Error('<tool_use_error>File has not been read yet. Read it first before writing to it.</tool_use_error>');
1184
+ }
1185
+ try {
1082
1186
  handle = await open(filePath, constants.O_RDWR | constants.O_NOFOLLOW);
1083
1187
  }
1188
+ catch (error) {
1189
+ if (error.code === 'ENOENT') {
1190
+ throw new Error('Tool input changed after permission approval');
1191
+ }
1192
+ throw error;
1193
+ }
1084
1194
  }
1085
1195
  try {
1086
1196
  await this.assertStablePath(filePath);
1087
1197
  const metadata = await handle.stat();
1088
1198
  if (!metadata.isFile())
1089
1199
  throw new Error(`Not a file: ${filePath}`);
1200
+ if (!newFile && metadata.size > this.maxFileBytes) {
1201
+ throw new Error(`File exceeds ${this.maxFileBytes} byte write limit`);
1202
+ }
1090
1203
  const preContent = newFile ? '' : await handle.readFile('utf8');
1204
+ const content = normalizeNewlines(requestedContent, newlineStyle(preContent));
1091
1205
  const encoded = Buffer.from(content);
1206
+ if (encoded.length > this.maxFileBytes) {
1207
+ throw new Error(`Content exceeds ${this.maxFileBytes} byte write limit`);
1208
+ }
1092
1209
  const { linesAdded, linesRemoved } = countLineChanges(preContent, content, preContent === '' ? { newFile: true } : undefined);
1093
1210
  await handle.write(encoded, 0, encoded.length, 0);
1094
1211
  await handle.truncate(encoded.length);
@@ -1098,28 +1215,84 @@ export class LocalToolRegistry {
1098
1215
  isError: false,
1099
1216
  linesAdded,
1100
1217
  linesRemoved,
1218
+ nativeToolUseResult: { filePath, content },
1101
1219
  };
1102
1220
  }
1103
1221
  finally {
1104
1222
  await handle.close();
1105
1223
  }
1106
1224
  }
1107
- async edit(call) {
1225
+ async edit(call, preparedTargetExisted) {
1108
1226
  const filePath = stringInput(call.input, 'file_path');
1109
1227
  this.assertProtectedWritePath(filePath);
1110
- const oldString = stringInput(call.input, 'old_string');
1228
+ const oldString = stringInput(call.input, 'old_string', true);
1111
1229
  const newString = stringInput(call.input, 'new_string', true);
1112
- const handle = await open(filePath, constants.O_RDWR | constants.O_NOFOLLOW);
1230
+ if (oldString === '' && Buffer.byteLength(newString) > this.maxFileBytes) {
1231
+ throw new Error(`Content exceeds ${this.maxFileBytes} byte write limit`);
1232
+ }
1233
+ let handle;
1234
+ let newFile = false;
1235
+ if (oldString === '') {
1236
+ const targetExisted = preparedTargetExisted ?? (await this.pathExists(filePath));
1237
+ if (targetExisted) {
1238
+ handle = await open(filePath, constants.O_RDWR | constants.O_NOFOLLOW);
1239
+ }
1240
+ else {
1241
+ await this.assertStableCreationParent(filePath);
1242
+ try {
1243
+ handle = await open(filePath, constants.O_RDWR |
1244
+ constants.O_CREAT |
1245
+ constants.O_EXCL |
1246
+ constants.O_NOFOLLOW, 0o666);
1247
+ newFile = true;
1248
+ }
1249
+ catch (error) {
1250
+ if (error.code === 'EEXIST') {
1251
+ throw new Error('Tool input changed after permission approval');
1252
+ }
1253
+ throw error;
1254
+ }
1255
+ }
1256
+ }
1257
+ else {
1258
+ handle = await open(filePath, constants.O_RDWR | constants.O_NOFOLLOW);
1259
+ }
1113
1260
  try {
1114
1261
  await this.assertStablePath(filePath);
1115
1262
  const metadata = await handle.stat();
1116
1263
  if (!metadata.isFile())
1117
1264
  throw new Error(`Not a file: ${filePath}`);
1265
+ if (newFile) {
1266
+ const content = newString;
1267
+ const encoded = Buffer.from(content);
1268
+ if (encoded.length > this.maxFileBytes) {
1269
+ throw new Error(`Content exceeds ${this.maxFileBytes} byte write limit`);
1270
+ }
1271
+ const { linesAdded, linesRemoved } = countLineChanges('', content, {
1272
+ newFile: true,
1273
+ });
1274
+ await handle.write(encoded, 0, encoded.length, 0);
1275
+ await handle.truncate(encoded.length);
1276
+ await handle.sync();
1277
+ return {
1278
+ content: `Replaced 1 occurrence(s)`,
1279
+ isError: false,
1280
+ linesAdded,
1281
+ linesRemoved,
1282
+ nativeToolUseResult: { filePath, content },
1283
+ };
1284
+ }
1285
+ if (oldString === '') {
1286
+ throw new Error('Edit with an empty old_string is only valid for creating a missing file');
1287
+ }
1118
1288
  if (metadata.size > this.maxFileBytes) {
1119
1289
  throw new Error(`File exceeds ${this.maxFileBytes} byte edit limit`);
1120
1290
  }
1121
1291
  const source = await handle.readFile('utf8');
1122
- const occurrences = source.split(oldString).length - 1;
1292
+ const style = newlineStyle(source);
1293
+ const normalizedOldString = normalizeNewlines(oldString, style);
1294
+ const normalizedNewString = normalizeNewlines(newString, style);
1295
+ const occurrences = source.split(normalizedOldString).length - 1;
1123
1296
  if (occurrences === 0)
1124
1297
  throw new Error('old_string was not found');
1125
1298
  if (call.input.replace_all !== true && occurrences !== 1) {
@@ -1128,13 +1301,14 @@ export class LocalToolRegistry {
1128
1301
  const replacementCount = call.input.replace_all === true ? occurrences : 1;
1129
1302
  const outputBytes = Buffer.byteLength(source) +
1130
1303
  replacementCount *
1131
- (Buffer.byteLength(newString) - Buffer.byteLength(oldString));
1304
+ (Buffer.byteLength(normalizedNewString) -
1305
+ Buffer.byteLength(normalizedOldString));
1132
1306
  if (outputBytes > this.maxFileBytes) {
1133
1307
  throw new Error(`Edited content exceeds ${this.maxFileBytes} bytes`);
1134
1308
  }
1135
1309
  const output = call.input.replace_all === true
1136
- ? source.replaceAll(oldString, newString)
1137
- : source.replace(oldString, newString);
1310
+ ? source.replaceAll(normalizedOldString, normalizedNewString)
1311
+ : source.replace(normalizedOldString, normalizedNewString);
1138
1312
  const { linesAdded, linesRemoved } = countLineChanges(source, output);
1139
1313
  const encoded = Buffer.from(output);
1140
1314
  await handle.write(encoded, 0, encoded.length, 0);
@@ -1145,6 +1319,7 @@ export class LocalToolRegistry {
1145
1319
  isError: false,
1146
1320
  linesAdded,
1147
1321
  linesRemoved,
1322
+ nativeToolUseResult: { filePath, content: output },
1148
1323
  };
1149
1324
  }
1150
1325
  finally {
@@ -1191,7 +1366,7 @@ export class LocalToolRegistry {
1191
1366
  async glob(call, context) {
1192
1367
  const requestedPath = call.input.path === undefined ? '.' : stringInput(call.input, 'path');
1193
1368
  const root = await this.globRoot(requestedPath, context);
1194
- const timeoutSignal = AbortSignal.timeout(this.maxShellTimeoutMs);
1369
+ const timeoutSignal = AbortSignal.timeout(this.maxSearchTimeoutMs);
1195
1370
  const searchSignal = context.signal
1196
1371
  ? AbortSignal.any([context.signal, timeoutSignal])
1197
1372
  : timeoutSignal;
@@ -1215,7 +1390,7 @@ export class LocalToolRegistry {
1215
1390
  throw abortError();
1216
1391
  if (timeoutSignal.aborted) {
1217
1392
  return {
1218
- content: `Search timed out after ${this.maxShellTimeoutMs}ms`,
1393
+ content: `Search timed out after ${this.maxSearchTimeoutMs}ms`,
1219
1394
  isError: true,
1220
1395
  };
1221
1396
  }
@@ -1240,14 +1415,14 @@ export class LocalToolRegistry {
1240
1415
  const result = await this.processRunner.run({
1241
1416
  command: 'rg',
1242
1417
  args,
1243
- timeoutMs: this.maxShellTimeoutMs,
1418
+ timeoutMs: this.maxSearchTimeoutMs,
1244
1419
  cwd: this.currentCwd(context),
1245
1420
  ...(this.environment ? { env: this.environment } : {}),
1246
1421
  ...(context.signal ? { signal: context.signal } : {}),
1247
1422
  });
1248
1423
  if (result.timedOut) {
1249
1424
  return {
1250
- content: `Search timed out after ${this.maxShellTimeoutMs}ms`,
1425
+ content: `Search timed out after ${this.maxSearchTimeoutMs}ms`,
1251
1426
  isError: true,
1252
1427
  };
1253
1428
  }
@@ -1274,39 +1449,131 @@ export class LocalToolRegistry {
1274
1449
  };
1275
1450
  const sandboxed = this.sandbox?.shouldUseSandbox(sandboxPolicyInput) ?? false;
1276
1451
  const shell = commandShell();
1452
+ let cwdToken;
1453
+ let sandboxCommandAttempted = false;
1277
1454
  let result;
1278
1455
  try {
1279
1456
  const sessionEnvironment = context.sessionId
1280
1457
  ? await this.sessionEnvironment?.(context.sessionId)
1281
1458
  : undefined;
1282
- const command = sandboxed
1283
- ? await this.sandbox?.wrapCommand(sandboxPolicyInput, {
1284
- shell,
1459
+ const processEnvironment = this.environment || sessionEnvironment
1460
+ ? { env: { ...this.environment, ...sessionEnvironment } }
1461
+ : {};
1462
+ let executionTimeout = timeout;
1463
+ let syntaxTimeoutResult;
1464
+ if (context.sessionId) {
1465
+ const syntaxStartedAt = Date.now();
1466
+ const syntaxResult = await this.processRunner.run({
1467
+ command: shell,
1468
+ args: commandShellSyntaxArguments(rawCommand),
1469
+ timeoutMs: timeout,
1470
+ cwd: this.currentBashCwd(context),
1471
+ ...processEnvironment,
1285
1472
  ...(context.signal ? { signal: context.signal } : {}),
1286
- commandId: call.id,
1287
- })
1473
+ });
1474
+ executionTimeout = Math.max(1, timeout - (Date.now() - syntaxStartedAt));
1475
+ if (syntaxResult.timedOut) {
1476
+ syntaxTimeoutResult = syntaxResult;
1477
+ }
1478
+ else if (syntaxResult.code === 0) {
1479
+ cwdToken = randomBytes(16).toString('hex');
1480
+ }
1481
+ }
1482
+ const controlOutputFd = cwdToken ? randomInt(5, 10) : undefined;
1483
+ const statusVariable = cwdToken
1484
+ ? `_praxis_exit_status_${cwdToken}`
1485
+ : undefined;
1486
+ const trapStatusVariable = cwdToken
1487
+ ? `_praxis_trap_status_${cwdToken}`
1488
+ : undefined;
1489
+ const cwdReportCommand = cwdToken && controlOutputFd
1490
+ ? `{ printf "%s%s\\0" "${cwdToken}" "$(pwd -P 2>/dev/null)" >&${controlOutputFd}; } 2>/dev/null`
1491
+ : undefined;
1492
+ const executionCommand = cwdToken && trapStatusVariable && cwdReportCommand
1493
+ ? `if [ -n "\${ZSH_VERSION-}" ]; then 0=${JSON.stringify(shell)}; fi
1494
+ trap '${trapStatusVariable}=$?; ${cwdReportCommand}; exit "$${trapStatusVariable}"' EXIT
1495
+ ${rawCommand}
1496
+ ${statusVariable}=$?
1497
+ ${cwdReportCommand}
1498
+ exit "$${statusVariable}"`
1288
1499
  : rawCommand;
1289
- result = await this.processRunner.run({
1290
- command: shell,
1291
- args: commandShellArguments(command ?? rawCommand),
1292
- timeoutMs: timeout,
1293
- cwd: this.currentCwd(context),
1294
- ...(this.environment || sessionEnvironment
1295
- ? { env: { ...this.environment, ...sessionEnvironment } }
1296
- : {}),
1297
- ...(context.signal ? { signal: context.signal } : {}),
1298
- });
1500
+ const scriptLoaderCommand = 'eval "$(cat <&4)"';
1501
+ if (syntaxTimeoutResult) {
1502
+ result = syntaxTimeoutResult;
1503
+ }
1504
+ else {
1505
+ let command;
1506
+ if (sandboxed) {
1507
+ sandboxCommandAttempted = true;
1508
+ command = await this.sandbox?.wrapCommand(cwdToken
1509
+ ? { ...sandboxPolicyInput, executionCommand: scriptLoaderCommand }
1510
+ : sandboxPolicyInput, {
1511
+ shell,
1512
+ ...(context.signal ? { signal: context.signal } : {}),
1513
+ commandId: call.id,
1514
+ });
1515
+ }
1516
+ else {
1517
+ command = cwdToken ? scriptLoaderCommand : rawCommand;
1518
+ }
1519
+ result = await this.processRunner.run({
1520
+ command: shell,
1521
+ args: commandShellArguments(command ?? rawCommand),
1522
+ timeoutMs: executionTimeout,
1523
+ cwd: this.currentBashCwd(context),
1524
+ ...processEnvironment,
1525
+ ...(context.signal ? { signal: context.signal } : {}),
1526
+ ...(cwdToken && controlOutputFd
1527
+ ? {
1528
+ controlOutputBytes: 8192,
1529
+ controlOutputFd,
1530
+ scriptInput: executionCommand,
1531
+ }
1532
+ : {}),
1533
+ });
1534
+ }
1299
1535
  }
1300
1536
  finally {
1301
- if (sandboxed)
1537
+ if (sandboxCommandAttempted)
1302
1538
  this.sandbox?.cleanupAfterCommand();
1303
1539
  }
1304
- if (sandboxed && this.sandbox) {
1540
+ if (cwdToken) {
1541
+ result = {
1542
+ ...result,
1543
+ stdout: stripShellControlTrace(result.stdout, cwdToken),
1544
+ stderr: stripShellControlTrace(result.stderr, cwdToken),
1545
+ output: stripShellControlTrace(result.output, cwdToken),
1546
+ };
1547
+ }
1548
+ if (sandboxCommandAttempted && this.sandbox) {
1305
1549
  result = {
1306
1550
  ...result,
1307
1551
  stderr: this.sandbox.annotateStderr(call.id, result.stderr),
1308
1552
  };
1309
1553
  }
1554
+ if (cwdToken && !result.timedOut && result.controlOutput) {
1555
+ const controlRecord = result.controlOutput.startsWith(cwdToken)
1556
+ ? result.controlOutput.slice(cwdToken.length)
1557
+ : undefined;
1558
+ const terminator = controlRecord?.indexOf('\0') ?? -1;
1559
+ const record = controlRecord && terminator > 0
1560
+ ? controlRecord.slice(0, terminator)
1561
+ : undefined;
1562
+ if (record) {
1563
+ try {
1564
+ const canonicalCwd = await realpath(record);
1565
+ if ((await stat(canonicalCwd)).isDirectory() && context.sessionId) {
1566
+ this.sessionCwds.set(context.sessionId, {
1567
+ cwd: canonicalCwd,
1568
+ hostCwd: resolve(context.cwd || this.cwdProvider?.() || this.cwd),
1569
+ });
1570
+ }
1571
+ }
1572
+ catch {
1573
+ // A command may remove or replace its final directory. Keep prior state.
1574
+ }
1575
+ }
1576
+ }
1310
1577
  if (result.timedOut) {
1311
1578
  return {
1312
1579
  content: `Command timed out after ${timeout}ms`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "praxis-agent",
3
- "version": "0.46.2",
3
+ "version": "0.46.5",
4
4
  "description": "Local-first, single-user general agent for the command line.",
5
5
  "license": "MIT",
6
6
  "author": "wuqisen",