praxis-agent 0.46.1 → 0.46.4

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.
@@ -1,4 +1,5 @@
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';
@@ -247,6 +248,20 @@ function stringInput(input, name, allowEmpty = false) {
247
248
  }
248
249
  return value;
249
250
  }
251
+ function stripShellControlTrace(content, token) {
252
+ if (!content.includes(token))
253
+ return content;
254
+ return content
255
+ .split('\n')
256
+ .filter((line) => !line.includes(token))
257
+ .join('\n');
258
+ }
259
+ function commandShellSyntaxArguments(command) {
260
+ const args = [...commandShellArguments(command)];
261
+ const commandIndex = args.indexOf('-c');
262
+ args.splice(commandIndex, 0, '-n');
263
+ return args;
264
+ }
250
265
  function optionalString(input, name) {
251
266
  const value = input[name];
252
267
  if (value === undefined)
@@ -480,6 +495,14 @@ function formatKilobytes(bytes) {
480
495
  const value = bytes / 1024;
481
496
  return `${value < 10 ? value.toFixed(1) : Math.round(value)}KB`.replace('.0KB', 'KB');
482
497
  }
498
+ function newlineStyle(content) {
499
+ return content.match(/\r\n|\n|\r/u)?.[0];
500
+ }
501
+ function normalizeNewlines(content, style) {
502
+ if (style === undefined)
503
+ return content;
504
+ return content.replace(/\r\n|\n|\r/gu, style);
505
+ }
483
506
  function parsePdfPages(value) {
484
507
  const match = /^(\d+)(?:-(\d+))?$/u.exec(value);
485
508
  if (!match)
@@ -506,6 +529,7 @@ export class LocalToolRegistry {
506
529
  maxOutputBytes;
507
530
  maxFileBytes;
508
531
  maxShellTimeoutMs;
532
+ maxSearchTimeoutMs;
509
533
  processRunner;
510
534
  enableReportFindings;
511
535
  environment;
@@ -513,7 +537,9 @@ export class LocalToolRegistry {
513
537
  sandbox;
514
538
  homeDirectory;
515
539
  configRoot;
540
+ sessionCwds = new Map();
516
541
  protectedWriteReason;
542
+ mutationTargetExisted = new WeakMap();
517
543
  constructor(options) {
518
544
  this.cwd = resolve(options.cwd);
519
545
  this.cwdProvider = options.cwdProvider;
@@ -524,7 +550,8 @@ export class LocalToolRegistry {
524
550
  this.additionalReadDirectories = (options.additionalReadDirectories ?? []).map((directory) => resolve(directory));
525
551
  this.maxOutputBytes = options.maxOutputBytes ?? 128 * 1024;
526
552
  this.maxFileBytes = options.maxFileBytes ?? 10 * 1024 * 1024;
527
- this.maxShellTimeoutMs = options.maxShellTimeoutMs ?? 120_000;
553
+ this.maxShellTimeoutMs = options.maxShellTimeoutMs ?? 600_000;
554
+ this.maxSearchTimeoutMs = options.maxShellTimeoutMs ?? 120_000;
528
555
  this.enableReportFindings = options.enableReportFindings ?? false;
529
556
  this.environment = options.environment;
530
557
  this.sessionEnvironment = options.sessionEnvironment;
@@ -551,7 +578,7 @@ export class LocalToolRegistry {
551
578
  }
552
579
  }
553
580
  assertProtectedBashCommand(command, context) {
554
- const cwd = this.currentCwd(context);
581
+ const cwd = this.currentBashCwd(context);
555
582
  const protectedWrite = context?.preToolUseAllowed
556
583
  ? (filePath) => {
557
584
  const reason = this.protectedWriteReason(filePath);
@@ -576,6 +603,18 @@ export class LocalToolRegistry {
576
603
  currentCwd(context) {
577
604
  return resolve(context?.cwd || this.cwdProvider?.() || this.cwd);
578
605
  }
606
+ currentBashCwd(context) {
607
+ const hostCwd = this.currentCwd(context);
608
+ if (context?.sessionId) {
609
+ const sessionCwd = this.sessionCwds.get(context.sessionId);
610
+ if (sessionCwd) {
611
+ if (sessionCwd.hostCwd === hostCwd)
612
+ return sessionCwd.cwd;
613
+ this.sessionCwds.delete(context.sessionId);
614
+ }
615
+ }
616
+ return hostCwd;
617
+ }
579
618
  definitions() {
580
619
  const definitions = this.enableReportFindings
581
620
  ? [...TOOL_DEFINITIONS, REPORT_FINDINGS_DEFINITION]
@@ -646,28 +685,48 @@ export class LocalToolRegistry {
646
685
  },
647
686
  };
648
687
  }
649
- case 'Write':
650
- return {
688
+ case 'Write': {
689
+ const filePath = await this.filePath(stringInput(call.input, 'file_path'), true, false, context);
690
+ this.assertProtectedWritePath(filePath);
691
+ const targetExisted = await this.pathExists(filePath);
692
+ if (targetExisted &&
693
+ !(await this.wasSuccessfullyRead(filePath, context.messages ?? [], context))) {
694
+ throw new Error('<tool_use_error>File has not been read yet. Read it first before writing to it.</tool_use_error>');
695
+ }
696
+ const prepared = {
651
697
  ...call,
652
698
  input: {
653
- file_path: await this.filePath(stringInput(call.input, 'file_path'), true, false, context),
699
+ file_path: filePath,
654
700
  content: stringInput(call.input, 'content', true),
655
701
  },
656
702
  };
703
+ this.mutationTargetExisted.set(prepared, targetExisted);
704
+ return prepared;
705
+ }
657
706
  case 'Edit': {
658
707
  const replaceAll = call.input.replace_all;
659
708
  if (replaceAll !== undefined && typeof replaceAll !== 'boolean') {
660
709
  throw new Error('replace_all must be a boolean');
661
710
  }
662
- return {
711
+ const oldString = stringInput(call.input, 'old_string', true);
712
+ const filePath = await this.filePath(stringInput(call.input, 'file_path'), oldString === '', false, context);
713
+ this.assertProtectedWritePath(filePath);
714
+ const targetExisted = await this.pathExists(filePath);
715
+ if (targetExisted &&
716
+ !(await this.wasSuccessfullyRead(filePath, context.messages ?? [], context))) {
717
+ throw new Error('<tool_use_error>File has not been read yet. Read it first before writing to it.</tool_use_error>');
718
+ }
719
+ const prepared = {
663
720
  ...call,
664
721
  input: {
665
- file_path: await this.filePath(stringInput(call.input, 'file_path'), false, false, context),
666
- old_string: stringInput(call.input, 'old_string'),
722
+ file_path: filePath,
723
+ old_string: oldString,
667
724
  new_string: stringInput(call.input, 'new_string', true),
668
725
  replace_all: replaceAll ?? false,
669
726
  },
670
727
  };
728
+ this.mutationTargetExisted.set(prepared, targetExisted);
729
+ return prepared;
671
730
  }
672
731
  case 'NotebookEdit': {
673
732
  const requestedPath = stringInput(call.input, 'notebook_path');
@@ -759,17 +818,24 @@ export class LocalToolRegistry {
759
818
  async execute(call, context) {
760
819
  if (context.signal?.aborted)
761
820
  throw abortError();
821
+ const approvedTargetExisted = this.mutationTargetExisted.get(call);
762
822
  const prepared = await this.prepare(call, context);
763
823
  if (JSON.stringify(prepared.input) !== JSON.stringify(call.input)) {
764
824
  throw new Error('Tool input changed after permission approval');
765
825
  }
826
+ const executionTargetExisted = this.mutationTargetExisted.get(prepared);
827
+ if (approvedTargetExisted !== undefined &&
828
+ executionTargetExisted !== approvedTargetExisted) {
829
+ throw new Error('Tool input changed after permission approval');
830
+ }
831
+ const targetExisted = approvedTargetExisted ?? executionTargetExisted;
766
832
  switch (prepared.name) {
767
833
  case 'Read':
768
834
  return this.read(prepared, context);
769
835
  case 'Write':
770
- return this.write(prepared);
836
+ return this.write(prepared, context, targetExisted);
771
837
  case 'Edit':
772
- return this.edit(prepared);
838
+ return this.edit(prepared, targetExisted);
773
839
  case 'NotebookEdit':
774
840
  return this.notebookEdit(prepared);
775
841
  case 'Glob':
@@ -864,11 +930,28 @@ export class LocalToolRegistry {
864
930
  return join(parent, basename(candidate));
865
931
  }
866
932
  }
933
+ async pathExists(filePath) {
934
+ try {
935
+ await stat(filePath);
936
+ return true;
937
+ }
938
+ catch (error) {
939
+ if (error.code === 'ENOENT')
940
+ return false;
941
+ throw error;
942
+ }
943
+ }
867
944
  async assertStablePath(filePath) {
868
945
  if ((await realpath(filePath)) !== filePath) {
869
946
  throw new Error('Tool input changed after permission approval');
870
947
  }
871
948
  }
949
+ async assertStableCreationParent(filePath) {
950
+ const parent = dirname(filePath);
951
+ if ((await realpath(parent)) !== parent) {
952
+ throw new Error('Tool input changed after permission approval');
953
+ }
954
+ }
872
955
  async globRoot(requestedPath, context) {
873
956
  const displayedPath = isAbsolute(requestedPath)
874
957
  ? resolve(requestedPath)
@@ -1053,21 +1136,19 @@ export class LocalToolRegistry {
1053
1136
  await mkdir(parent, { recursive: true });
1054
1137
  return mkdtemp(join(parent, 'pdf-'));
1055
1138
  }
1056
- async write(call) {
1139
+ async write(call, context, preparedTargetExisted) {
1057
1140
  const filePath = stringInput(call.input, 'file_path');
1058
1141
  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
- }
1142
+ const requestedContent = stringInput(call.input, 'content', true);
1063
1143
  let handle;
1064
1144
  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;
1145
+ const hasRead = await this.wasSuccessfullyRead(filePath, context.messages ?? [], context);
1146
+ const targetExisted = preparedTargetExisted ?? (await this.pathExists(filePath));
1147
+ if (!targetExisted) {
1148
+ if (Buffer.byteLength(requestedContent) > this.maxFileBytes) {
1149
+ throw new Error(`Content exceeds ${this.maxFileBytes} byte write limit`);
1150
+ }
1151
+ await this.assertStableCreationParent(filePath);
1071
1152
  try {
1072
1153
  handle = await open(filePath, constants.O_RDWR |
1073
1154
  constants.O_CREAT |
@@ -1076,19 +1157,40 @@ export class LocalToolRegistry {
1076
1157
  newFile = true;
1077
1158
  }
1078
1159
  catch (createError) {
1079
- if (createError.code !== 'EEXIST') {
1080
- throw createError;
1160
+ if (createError.code === 'EEXIST') {
1161
+ throw new Error('<tool_use_error>File has not been read yet. Read it first before writing to it.</tool_use_error>');
1081
1162
  }
1163
+ throw createError;
1164
+ }
1165
+ }
1166
+ else {
1167
+ if (!hasRead) {
1168
+ throw new Error('<tool_use_error>File has not been read yet. Read it first before writing to it.</tool_use_error>');
1169
+ }
1170
+ try {
1082
1171
  handle = await open(filePath, constants.O_RDWR | constants.O_NOFOLLOW);
1083
1172
  }
1173
+ catch (error) {
1174
+ if (error.code === 'ENOENT') {
1175
+ throw new Error('Tool input changed after permission approval');
1176
+ }
1177
+ throw error;
1178
+ }
1084
1179
  }
1085
1180
  try {
1086
1181
  await this.assertStablePath(filePath);
1087
1182
  const metadata = await handle.stat();
1088
1183
  if (!metadata.isFile())
1089
1184
  throw new Error(`Not a file: ${filePath}`);
1185
+ if (!newFile && metadata.size > this.maxFileBytes) {
1186
+ throw new Error(`File exceeds ${this.maxFileBytes} byte write limit`);
1187
+ }
1090
1188
  const preContent = newFile ? '' : await handle.readFile('utf8');
1189
+ const content = normalizeNewlines(requestedContent, newlineStyle(preContent));
1091
1190
  const encoded = Buffer.from(content);
1191
+ if (encoded.length > this.maxFileBytes) {
1192
+ throw new Error(`Content exceeds ${this.maxFileBytes} byte write limit`);
1193
+ }
1092
1194
  const { linesAdded, linesRemoved } = countLineChanges(preContent, content, preContent === '' ? { newFile: true } : undefined);
1093
1195
  await handle.write(encoded, 0, encoded.length, 0);
1094
1196
  await handle.truncate(encoded.length);
@@ -1098,28 +1200,84 @@ export class LocalToolRegistry {
1098
1200
  isError: false,
1099
1201
  linesAdded,
1100
1202
  linesRemoved,
1203
+ nativeToolUseResult: { filePath, content },
1101
1204
  };
1102
1205
  }
1103
1206
  finally {
1104
1207
  await handle.close();
1105
1208
  }
1106
1209
  }
1107
- async edit(call) {
1210
+ async edit(call, preparedTargetExisted) {
1108
1211
  const filePath = stringInput(call.input, 'file_path');
1109
1212
  this.assertProtectedWritePath(filePath);
1110
- const oldString = stringInput(call.input, 'old_string');
1213
+ const oldString = stringInput(call.input, 'old_string', true);
1111
1214
  const newString = stringInput(call.input, 'new_string', true);
1112
- const handle = await open(filePath, constants.O_RDWR | constants.O_NOFOLLOW);
1215
+ if (oldString === '' && Buffer.byteLength(newString) > this.maxFileBytes) {
1216
+ throw new Error(`Content exceeds ${this.maxFileBytes} byte write limit`);
1217
+ }
1218
+ let handle;
1219
+ let newFile = false;
1220
+ if (oldString === '') {
1221
+ const targetExisted = preparedTargetExisted ?? (await this.pathExists(filePath));
1222
+ if (targetExisted) {
1223
+ handle = await open(filePath, constants.O_RDWR | constants.O_NOFOLLOW);
1224
+ }
1225
+ else {
1226
+ await this.assertStableCreationParent(filePath);
1227
+ try {
1228
+ handle = await open(filePath, constants.O_RDWR |
1229
+ constants.O_CREAT |
1230
+ constants.O_EXCL |
1231
+ constants.O_NOFOLLOW, 0o666);
1232
+ newFile = true;
1233
+ }
1234
+ catch (error) {
1235
+ if (error.code === 'EEXIST') {
1236
+ throw new Error('Tool input changed after permission approval');
1237
+ }
1238
+ throw error;
1239
+ }
1240
+ }
1241
+ }
1242
+ else {
1243
+ handle = await open(filePath, constants.O_RDWR | constants.O_NOFOLLOW);
1244
+ }
1113
1245
  try {
1114
1246
  await this.assertStablePath(filePath);
1115
1247
  const metadata = await handle.stat();
1116
1248
  if (!metadata.isFile())
1117
1249
  throw new Error(`Not a file: ${filePath}`);
1250
+ if (newFile) {
1251
+ const content = newString;
1252
+ const encoded = Buffer.from(content);
1253
+ if (encoded.length > this.maxFileBytes) {
1254
+ throw new Error(`Content exceeds ${this.maxFileBytes} byte write limit`);
1255
+ }
1256
+ const { linesAdded, linesRemoved } = countLineChanges('', content, {
1257
+ newFile: true,
1258
+ });
1259
+ await handle.write(encoded, 0, encoded.length, 0);
1260
+ await handle.truncate(encoded.length);
1261
+ await handle.sync();
1262
+ return {
1263
+ content: `Replaced 1 occurrence(s)`,
1264
+ isError: false,
1265
+ linesAdded,
1266
+ linesRemoved,
1267
+ nativeToolUseResult: { filePath, content },
1268
+ };
1269
+ }
1270
+ if (oldString === '') {
1271
+ throw new Error('Edit with an empty old_string is only valid for creating a missing file');
1272
+ }
1118
1273
  if (metadata.size > this.maxFileBytes) {
1119
1274
  throw new Error(`File exceeds ${this.maxFileBytes} byte edit limit`);
1120
1275
  }
1121
1276
  const source = await handle.readFile('utf8');
1122
- const occurrences = source.split(oldString).length - 1;
1277
+ const style = newlineStyle(source);
1278
+ const normalizedOldString = normalizeNewlines(oldString, style);
1279
+ const normalizedNewString = normalizeNewlines(newString, style);
1280
+ const occurrences = source.split(normalizedOldString).length - 1;
1123
1281
  if (occurrences === 0)
1124
1282
  throw new Error('old_string was not found');
1125
1283
  if (call.input.replace_all !== true && occurrences !== 1) {
@@ -1128,13 +1286,14 @@ export class LocalToolRegistry {
1128
1286
  const replacementCount = call.input.replace_all === true ? occurrences : 1;
1129
1287
  const outputBytes = Buffer.byteLength(source) +
1130
1288
  replacementCount *
1131
- (Buffer.byteLength(newString) - Buffer.byteLength(oldString));
1289
+ (Buffer.byteLength(normalizedNewString) -
1290
+ Buffer.byteLength(normalizedOldString));
1132
1291
  if (outputBytes > this.maxFileBytes) {
1133
1292
  throw new Error(`Edited content exceeds ${this.maxFileBytes} bytes`);
1134
1293
  }
1135
1294
  const output = call.input.replace_all === true
1136
- ? source.replaceAll(oldString, newString)
1137
- : source.replace(oldString, newString);
1295
+ ? source.replaceAll(normalizedOldString, normalizedNewString)
1296
+ : source.replace(normalizedOldString, normalizedNewString);
1138
1297
  const { linesAdded, linesRemoved } = countLineChanges(source, output);
1139
1298
  const encoded = Buffer.from(output);
1140
1299
  await handle.write(encoded, 0, encoded.length, 0);
@@ -1145,6 +1304,7 @@ export class LocalToolRegistry {
1145
1304
  isError: false,
1146
1305
  linesAdded,
1147
1306
  linesRemoved,
1307
+ nativeToolUseResult: { filePath, content: output },
1148
1308
  };
1149
1309
  }
1150
1310
  finally {
@@ -1191,7 +1351,7 @@ export class LocalToolRegistry {
1191
1351
  async glob(call, context) {
1192
1352
  const requestedPath = call.input.path === undefined ? '.' : stringInput(call.input, 'path');
1193
1353
  const root = await this.globRoot(requestedPath, context);
1194
- const timeoutSignal = AbortSignal.timeout(this.maxShellTimeoutMs);
1354
+ const timeoutSignal = AbortSignal.timeout(this.maxSearchTimeoutMs);
1195
1355
  const searchSignal = context.signal
1196
1356
  ? AbortSignal.any([context.signal, timeoutSignal])
1197
1357
  : timeoutSignal;
@@ -1215,7 +1375,7 @@ export class LocalToolRegistry {
1215
1375
  throw abortError();
1216
1376
  if (timeoutSignal.aborted) {
1217
1377
  return {
1218
- content: `Search timed out after ${this.maxShellTimeoutMs}ms`,
1378
+ content: `Search timed out after ${this.maxSearchTimeoutMs}ms`,
1219
1379
  isError: true,
1220
1380
  };
1221
1381
  }
@@ -1240,14 +1400,14 @@ export class LocalToolRegistry {
1240
1400
  const result = await this.processRunner.run({
1241
1401
  command: 'rg',
1242
1402
  args,
1243
- timeoutMs: this.maxShellTimeoutMs,
1403
+ timeoutMs: this.maxSearchTimeoutMs,
1244
1404
  cwd: this.currentCwd(context),
1245
1405
  ...(this.environment ? { env: this.environment } : {}),
1246
1406
  ...(context.signal ? { signal: context.signal } : {}),
1247
1407
  });
1248
1408
  if (result.timedOut) {
1249
1409
  return {
1250
- content: `Search timed out after ${this.maxShellTimeoutMs}ms`,
1410
+ content: `Search timed out after ${this.maxSearchTimeoutMs}ms`,
1251
1411
  isError: true,
1252
1412
  };
1253
1413
  }
@@ -1274,39 +1434,131 @@ export class LocalToolRegistry {
1274
1434
  };
1275
1435
  const sandboxed = this.sandbox?.shouldUseSandbox(sandboxPolicyInput) ?? false;
1276
1436
  const shell = commandShell();
1437
+ let cwdToken;
1438
+ let sandboxCommandAttempted = false;
1277
1439
  let result;
1278
1440
  try {
1279
1441
  const sessionEnvironment = context.sessionId
1280
1442
  ? await this.sessionEnvironment?.(context.sessionId)
1281
1443
  : undefined;
1282
- const command = sandboxed
1283
- ? await this.sandbox?.wrapCommand(sandboxPolicyInput, {
1284
- shell,
1444
+ const processEnvironment = this.environment || sessionEnvironment
1445
+ ? { env: { ...this.environment, ...sessionEnvironment } }
1446
+ : {};
1447
+ let executionTimeout = timeout;
1448
+ let syntaxTimeoutResult;
1449
+ if (context.sessionId) {
1450
+ const syntaxStartedAt = Date.now();
1451
+ const syntaxResult = await this.processRunner.run({
1452
+ command: shell,
1453
+ args: commandShellSyntaxArguments(rawCommand),
1454
+ timeoutMs: timeout,
1455
+ cwd: this.currentBashCwd(context),
1456
+ ...processEnvironment,
1285
1457
  ...(context.signal ? { signal: context.signal } : {}),
1286
- commandId: call.id,
1287
- })
1458
+ });
1459
+ executionTimeout = Math.max(1, timeout - (Date.now() - syntaxStartedAt));
1460
+ if (syntaxResult.timedOut) {
1461
+ syntaxTimeoutResult = syntaxResult;
1462
+ }
1463
+ else if (syntaxResult.code === 0) {
1464
+ cwdToken = randomBytes(16).toString('hex');
1465
+ }
1466
+ }
1467
+ const controlOutputFd = cwdToken ? randomInt(5, 10) : undefined;
1468
+ const statusVariable = cwdToken
1469
+ ? `_praxis_exit_status_${cwdToken}`
1470
+ : undefined;
1471
+ const trapStatusVariable = cwdToken
1472
+ ? `_praxis_trap_status_${cwdToken}`
1473
+ : undefined;
1474
+ const cwdReportCommand = cwdToken && controlOutputFd
1475
+ ? `{ printf "%s%s\\0" "${cwdToken}" "$(pwd -P 2>/dev/null)" >&${controlOutputFd}; } 2>/dev/null`
1476
+ : undefined;
1477
+ const executionCommand = cwdToken && trapStatusVariable && cwdReportCommand
1478
+ ? `if [ -n "\${ZSH_VERSION-}" ]; then 0=${JSON.stringify(shell)}; fi
1479
+ trap '${trapStatusVariable}=$?; ${cwdReportCommand}; exit "$${trapStatusVariable}"' EXIT
1480
+ ${rawCommand}
1481
+ ${statusVariable}=$?
1482
+ ${cwdReportCommand}
1483
+ exit "$${statusVariable}"`
1288
1484
  : 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
- });
1485
+ const scriptLoaderCommand = 'eval "$(cat <&4)"';
1486
+ if (syntaxTimeoutResult) {
1487
+ result = syntaxTimeoutResult;
1488
+ }
1489
+ else {
1490
+ let command;
1491
+ if (sandboxed) {
1492
+ sandboxCommandAttempted = true;
1493
+ command = await this.sandbox?.wrapCommand(cwdToken
1494
+ ? { ...sandboxPolicyInput, executionCommand: scriptLoaderCommand }
1495
+ : sandboxPolicyInput, {
1496
+ shell,
1497
+ ...(context.signal ? { signal: context.signal } : {}),
1498
+ commandId: call.id,
1499
+ });
1500
+ }
1501
+ else {
1502
+ command = cwdToken ? scriptLoaderCommand : rawCommand;
1503
+ }
1504
+ result = await this.processRunner.run({
1505
+ command: shell,
1506
+ args: commandShellArguments(command ?? rawCommand),
1507
+ timeoutMs: executionTimeout,
1508
+ cwd: this.currentBashCwd(context),
1509
+ ...processEnvironment,
1510
+ ...(context.signal ? { signal: context.signal } : {}),
1511
+ ...(cwdToken && controlOutputFd
1512
+ ? {
1513
+ controlOutputBytes: 8192,
1514
+ controlOutputFd,
1515
+ scriptInput: executionCommand,
1516
+ }
1517
+ : {}),
1518
+ });
1519
+ }
1299
1520
  }
1300
1521
  finally {
1301
- if (sandboxed)
1522
+ if (sandboxCommandAttempted)
1302
1523
  this.sandbox?.cleanupAfterCommand();
1303
1524
  }
1304
- if (sandboxed && this.sandbox) {
1525
+ if (cwdToken) {
1526
+ result = {
1527
+ ...result,
1528
+ stdout: stripShellControlTrace(result.stdout, cwdToken),
1529
+ stderr: stripShellControlTrace(result.stderr, cwdToken),
1530
+ output: stripShellControlTrace(result.output, cwdToken),
1531
+ };
1532
+ }
1533
+ if (sandboxCommandAttempted && this.sandbox) {
1305
1534
  result = {
1306
1535
  ...result,
1307
1536
  stderr: this.sandbox.annotateStderr(call.id, result.stderr),
1308
1537
  };
1309
1538
  }
1539
+ if (cwdToken && !result.timedOut && result.controlOutput) {
1540
+ const controlRecord = result.controlOutput.startsWith(cwdToken)
1541
+ ? result.controlOutput.slice(cwdToken.length)
1542
+ : undefined;
1543
+ const terminator = controlRecord?.indexOf('\0') ?? -1;
1544
+ const record = controlRecord && terminator > 0
1545
+ ? controlRecord.slice(0, terminator)
1546
+ : undefined;
1547
+ if (record) {
1548
+ try {
1549
+ const canonicalCwd = await realpath(record);
1550
+ if ((await stat(canonicalCwd)).isDirectory() && context.sessionId) {
1551
+ this.sessionCwds.set(context.sessionId, {
1552
+ cwd: canonicalCwd,
1553
+ hostCwd: resolve(context.cwd || this.cwdProvider?.() || this.cwd),
1554
+ });
1555
+ }
1556
+ }
1557
+ catch {
1558
+ // A command may remove or replace its final directory. Keep prior state.
1559
+ }
1560
+ }
1561
+ }
1310
1562
  if (result.timedOut) {
1311
1563
  return {
1312
1564
  content: `Command timed out after ${timeout}ms`,
@@ -304,11 +304,12 @@ function validate(input, name) {
304
304
  for (const member of input.roster) {
305
305
  if (!member || typeof member !== 'object' || Array.isArray(member))
306
306
  throw new Error('Invalid Team member');
307
- objectInput(member, [
308
- 'name',
309
- 'agentType',
310
- 'access',
311
- ]);
307
+ const value = member;
308
+ objectInput(value, ['name', 'agentType', 'access']);
309
+ stringValue(value, 'name');
310
+ stringValue(value, 'agentType');
311
+ if (value.access !== 'read-only' && value.access !== 'write')
312
+ throw new Error('Invalid Team member access');
312
313
  }
313
314
  for (const task of input.tasks) {
314
315
  if (!task || typeof task !== 'object' || Array.isArray(task))
@@ -321,6 +322,12 @@ function validate(input, name) {
321
322
  'blockedBy',
322
323
  'claims',
323
324
  ]);
325
+ stringValue(value, 'id');
326
+ stringValue(value, 'description');
327
+ stringValue(value, 'assignee');
328
+ if (!Array.isArray(value.blockedBy) ||
329
+ value.blockedBy.some((entry) => typeof entry !== 'string' || entry.trim() === ''))
330
+ throw new Error('Invalid Team blockedBy');
324
331
  if (!value.claims ||
325
332
  typeof value.claims !== 'object' ||
326
333
  Array.isArray(value.claims))
@@ -332,6 +339,18 @@ function validate(input, name) {
332
339
  'migrations',
333
340
  'mergeTargets',
334
341
  ]);
342
+ for (const key of [
343
+ 'files',
344
+ 'publicContracts',
345
+ 'generatedArtifacts',
346
+ 'migrations',
347
+ 'mergeTargets',
348
+ ]) {
349
+ const entries = value.claims[key];
350
+ if (!Array.isArray(entries) ||
351
+ entries.some((entry) => typeof entry !== 'string' || entry.trim() === ''))
352
+ throw new Error(`Invalid Team claim list: ${key}`);
353
+ }
335
354
  }
336
355
  return input;
337
356
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "praxis-agent",
3
- "version": "0.46.1",
3
+ "version": "0.46.4",
4
4
  "description": "Local-first, single-user general agent for the command line.",
5
5
  "license": "MIT",
6
6
  "author": "wuqisen",
@@ -29,7 +29,7 @@
29
29
  },
30
30
  "scripts": {
31
31
  "build": "tsc -p tsconfig.build.json",
32
- "check": "npm run format:check && npm run lint && npm run test:docs && npm run verify:release-automation && npm run verify:ci-coverage && npm run check:boundaries && npm run typecheck && npm run build:native && npm run build && npm test",
32
+ "check": "npm run format:check && npm run lint && npm run test:docs && npm run verify:release-automation && npm run verify:ci-coverage && npm run verify:fixture-contracts && npm run check:boundaries && npm run typecheck && npm run build:native && npm run build && npm test",
33
33
  "check:boundaries": "node scripts/check-boundaries.mjs",
34
34
  "dev": "tsx src/cli.ts",
35
35
  "format": "prettier --write .",
@@ -44,11 +44,11 @@
44
44
  "release:artifacts": "node scripts/build-release-artifacts.mjs",
45
45
  "release:verify": "node scripts/verify-release-ref.mjs",
46
46
  "test": "vitest run",
47
- "test:coverage": "vitest run --coverage",
47
+ "test:coverage": "vitest run --coverage && node scripts/verify-nonzero-runtime-coverage.mjs",
48
48
  "test:docs": "node scripts/verify-docs.mjs",
49
49
  "test:tui:pty": "npm run build && node scripts/verify-fullscreen-rendering.mjs && node scripts/verify-interactive-ansi.mjs",
50
50
  "test:self-update": "npm run build && node scripts/verify-self-update-contract.mjs",
51
- "test:core-completion": "npm run build && node scripts/verify-core-completion-audit.mjs",
51
+ "test:core-completion": "npm run test:fixtures",
52
52
  "test:performance": "npm run build && node scripts/verify-projection-scaling.mjs && node scripts/verify-projection-regression.mjs && node scripts/verify-quiet-frame-performance.mjs",
53
53
  "test:performance:projection": "npm run build && node scripts/verify-projection-scaling.mjs",
54
54
  "test:performance:projection-regression": "npm run build && node scripts/verify-projection-regression.mjs",
@@ -56,9 +56,12 @@
56
56
  "test:prompt-suggestions": "npm run build && node scripts/verify-prompt-suggestions.mjs",
57
57
  "test:mcp-oauth-serve": "npm run build && node scripts/verify-mcp-oauth-serve.mjs",
58
58
  "test:package": "npm run build && node scripts/verify-native-release-package.mjs",
59
+ "test:security": "npm audit --omit=dev",
59
60
  "typecheck": "tsc --noEmit",
60
61
  "verify:release-automation": "node scripts/verify-release-automation.mjs",
61
- "verify:ci-coverage": "node scripts/verify-ci-coverage.mjs"
62
+ "verify:ci-coverage": "node scripts/verify-ci-coverage.mjs",
63
+ "verify:fixture-contracts": "node scripts/verify-fixture-contracts.mjs",
64
+ "test:fixtures": "node scripts/run-fixture-contracts.mjs"
62
65
  },
63
66
  "engines": {
64
67
  "node": ">=24"