@molecule/api-ai-tools 1.11.0 → 1.12.0

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/dist/utilities.js CHANGED
@@ -5,7 +5,7 @@
5
5
  * @module
6
6
  */
7
7
  import { posix } from 'path';
8
- import { commandSegments, locatedCommandSegments, statementArgumentEnd, withoutTimeoutWrapper, } from './command-position.js';
8
+ import { commandSegments, executedCommands, locatedCommandSegments, statementArgumentEnd, withoutTimeoutWrapper, } from './command-position.js';
9
9
  import { classifyRunFailure } from './failure-kind.js';
10
10
  import { commandRunsNoTests } from './no-test-run.js';
11
11
  import { isDiagnosisRun, scopedRerunCommand, suiteCommandTarget, suiteRunInTheWay, withoutHeredocs, withSuiteConfig, } from './suite-overlap.js';
@@ -163,8 +163,41 @@ export function redactSecrets(s) {
163
163
  export function redactSecretsInCode(s) {
164
164
  return s.replace(SECRET_ENV_ASSIGNMENT, '$1=[REDACTED]');
165
165
  }
166
+ /**
167
+ * The placeholder the output filter substitutes for a hidden value. Copying it into
168
+ * application configuration changes the value rather than recovering it. The write
169
+ * tools check for added occurrences ({@link addedRedactionMask}); intentional literals
170
+ * require the caller's explicit per-call declaration.
171
+ *
172
+ * Only the shapes a value can take count: quoted (`'[REDACTED]'`, `"[REDACTED]"`) or
173
+ * assigned (`=[REDACTED]`). Prose that mentions the token is ordinary text.
174
+ */
175
+ const REDACTION_MASK = /'\[REDACTED\]'|"\[REDACTED\]"|=[ \t]*\[REDACTED\]/g;
176
+ /**
177
+ * The redaction mask `next` adds over `previous`, or null when it adds none.
178
+ *
179
+ * `previous` is the file's previous content for `write_file`, and the replacement's own
180
+ * `old_string` for `edit_file` — an edit that carries an existing mask through (a file that
181
+ * already had one, read back and rewritten) adds no occurrence. A new occurrence may
182
+ * be copied output or intentional source; the caller declares the latter explicitly.
183
+ *
184
+ * @param next - The content about to be written (or the replacement's `new_string`).
185
+ * @param previous - The content it replaces, already on disk (or the `old_string`).
186
+ * @returns The added mask as written (`'[REDACTED]'`, `"[REDACTED]"`, `=[REDACTED]`), or null.
187
+ */
188
+ export function addedRedactionMask(next, previous) {
189
+ const before = previous.match(REDACTION_MASK)?.length ?? 0;
190
+ const after = next.match(REDACTION_MASK) ?? [];
191
+ if (after.length <= before)
192
+ return null;
193
+ return after[before] ?? after[0] ?? '[REDACTED]';
194
+ }
166
195
  /** Source-file extensions a printing command's output is read as code for. */
167
196
  const SOURCE_FILE_RE = /\.(?:[cm]?[jt]sx?|css|scss|html|vue|svelte|md|sql|hurl)$/;
197
+ /** Programs whose output is file NAMES or counts, never another file's content. */
198
+ const INSPECTION_PROGRAMS = new Set(['find', 'ls', 'wc']);
199
+ /** `find` options that run a program or write a file — not read-only inspection. */
200
+ const FIND_NOT_INSPECTION = /^-(?:exec|execdir|ok|okdir|delete|fls|fprint|fprint0|fprintf)/;
168
201
  /**
169
202
  * A literal argument, not a shell expansion. The shared parser owns statement boundaries;
170
203
  * this deliberately small word grammar only decides whether an argument can read other data.
@@ -253,9 +286,46 @@ function sourcePrintFiles(program, args) {
253
286
  * @returns True when the command prints source files, with only literal headings and filters.
254
287
  */
255
288
  export function printsOnlySource(command) {
289
+ return printsOnly(command, false);
290
+ }
291
+ /**
292
+ * Whether a shell command only INSPECTS the workspace WITHOUT printing a file's contents:
293
+ * it prints source files (a pure literal read, {@link printsOnlySource}), lists names
294
+ * (`ls`, `find`), or counts lines (`wc`) — joined by `;`, `&&` or `|`, with literal
295
+ * `echo`/`printf` headings and `cd`.
296
+ *
297
+ * Its output is code or names, not an env dump, so it gets the code-safe redaction
298
+ * ({@link redactSecretsInCode}) instead of the name-keyed env-dump passes. Without this,
299
+ * ordinary code values such as `tokenKey: 'jwtToken'` in a mixed source read and
300
+ * `ls | head` command would be masked by the environment-dump rules.
301
+ *
302
+ * A RECURSIVE SEARCH IS NOT INSPECTION (`grep -rn x .`, `rg x src/`): its output is the
303
+ * content of whichever files matched, including `.env` files and JSON secret dumps, so it
304
+ * keeps the full grade. A path with no extension is not evidence that it is a directory —
305
+ * `/dev/stdin` and an ordinary `secrets` file read the same way.
306
+ *
307
+ * Env dumps keep the full grade: `.env` anywhere in the command, `env`/`printenv`, a
308
+ * non-literal or redirecting form, and an interpreter one-liner (`node -e`, `python -c`)
309
+ * all return false.
310
+ *
311
+ * @param command - The shell command.
312
+ * @returns True when the command only reads or lists workspace content.
313
+ */
314
+ export function isReadOnlyInspection(command) {
315
+ return printsOnly(command, true);
316
+ }
317
+ /**
318
+ * The one segment grammar both grades share: literal words only, no wrapper or group, no
319
+ * backgrounding, and every byte between segments a separator.
320
+ *
321
+ * @param command - The shell command.
322
+ * @param inspection - Also accept name/count listing (`ls`, `find`, `wc`).
323
+ * @returns True when every segment is allowed and at least one prints something.
324
+ */
325
+ function printsOnly(command, inspection) {
256
326
  if (/\.env\b/.test(command))
257
327
  return false;
258
- let readsSource = false;
328
+ let prints = false;
259
329
  let previousEnd = 0;
260
330
  for (const located of locatedCommandSegments(command)) {
261
331
  const { segment, start, end, programOffset, separator, terminator } = located;
@@ -302,6 +372,13 @@ export function printsOnlySource(command) {
302
372
  return false;
303
373
  }
304
374
  }
375
+ else if (inspection && INSPECTION_PROGRAMS.has(program) && separator !== '|') {
376
+ // Names and counts only — nothing here can be another file's content. `find` is the
377
+ // exception that can run a program or write a file.
378
+ if (program === 'find' && args.some((arg) => FIND_NOT_INSPECTION.test(arg)))
379
+ return false;
380
+ prints = true;
381
+ }
305
382
  else {
306
383
  const filter = separator === '|';
307
384
  if (!(filter ? /^(?:head|tail|sort|uniq|wc|cut|nl|grep)$/ : /^(?:cat|head|tail|nl|sed|grep|rg)$/).test(program)) {
@@ -319,11 +396,11 @@ export function printsOnlySource(command) {
319
396
  !files.every((file) => !file.startsWith('-') && SOURCE_FILE_RE.test(file))) {
320
397
  return false;
321
398
  }
322
- readsSource = true;
399
+ prints = true;
323
400
  }
324
401
  }
325
402
  }
326
- return readsSource && /^[\s;]*$/.test(command.slice(previousEnd));
403
+ return prints && /^[\s;]*$/.test(command.slice(previousEnd));
327
404
  }
328
405
  /**
329
406
  * Whether a path is an env file, for which {@link redactSecrets}' full env-dump
@@ -1301,30 +1378,127 @@ export function suiteRunPriorityLaunch(command, launch) {
1301
1378
  };
1302
1379
  }
1303
1380
  // ── Command blocking ──────────────────────────────────────────────────────────
1381
+ /** A word with one layer of matching shell quotes removed (`"/proc/self/environ"` → `/proc/self/environ`). */
1382
+ function shellWord(word) {
1383
+ return word.replace(/^(['"])(.*)\1$/, '$2');
1384
+ }
1385
+ /** `/proc/<pid>/environ` and `/proc/self/environ` — a process's environment, as the kernel exposes it. */
1386
+ const PROC_ENVIRON = /^\/proc\/(?:\d+|self)\/environ(?:\/|$)/;
1387
+ /** Anything under `/proc`: a process's environment is one of the files there. */
1388
+ const PROC_PATH = /^\/proc(?:\/|$)/;
1389
+ /** The molecule platform's secrets dir — `/etc/mol/env` carries the vault + platform secrets. */
1390
+ const MOL_SECRETS = /^\/etc\/mol(?:\/|$)/;
1391
+ /**
1392
+ * Whether one executed command reads a file that would print a process's environment or the
1393
+ * platform's secrets.
1394
+ *
1395
+ * This is asked of the commands the shell RUNS ({@link executedCommands}), never of the command
1396
+ * text: a file path quoted inside a grep pattern (`grep -E "ENV|/etc/mol" src`) is data a
1397
+ * comparison reads, not a file this command opens. The shapes below are the ones that print:
1398
+ * `cat` and its siblings of a `/proc/<pid>/environ`, a redirect's path handed to a reader
1399
+ * (`xxd < /proc/self/environ`), and reads of `/etc/mol`, which is an env-dump by another door.
1400
+ *
1401
+ * @param program - The command's program, directory removed.
1402
+ * @param args - Its arguments, as written.
1403
+ * @returns True when the command reads one of those files.
1404
+ */
1405
+ function readsSecretFile(program, args) {
1406
+ const words = args.map(shellWord);
1407
+ const first = words[0] ?? '';
1408
+ switch (program) {
1409
+ case 'cat':
1410
+ return words.some((word) => /^\/etc\/environment(?:\/|\.|$)/.test(word) ||
1411
+ word.startsWith('/root/.bashrc') ||
1412
+ PROC_ENVIRON.test(word) ||
1413
+ MOL_SECRETS.test(word));
1414
+ case 'sed':
1415
+ case 'awk':
1416
+ case 'cp':
1417
+ return words.some((word) => PROC_ENVIRON.test(word) || MOL_SECRETS.test(word));
1418
+ case 'xargs':
1419
+ return words.some((word) => PROC_ENVIRON.test(word));
1420
+ case 'dd':
1421
+ // `dd if=/proc/1/environ` carries the path inside its own argument.
1422
+ return words.some((word) => /\/proc(?:\/|$)/.test(word) || MOL_SECRETS.test(word));
1423
+ case 'base64':
1424
+ case 'head':
1425
+ case 'less':
1426
+ case 'od':
1427
+ case 'strings':
1428
+ case 'tail':
1429
+ case 'xxd':
1430
+ return PROC_PATH.test(first) || words.some((word) => MOL_SECRETS.test(word));
1431
+ default:
1432
+ return false;
1433
+ }
1434
+ }
1304
1435
  /**
1305
- * Commands that dump environment variables — blocked to prevent secret leakage.
1306
- * Also covers reads of `/etc/mol/…` (the molecule platform's secrets dir —
1307
- * `/etc/mol/env` carries the vault + platform secrets inside a sandbox),
1308
- * which is an env-dump by another door.
1436
+ * Whether one executed command prints the environment instead of running something in it.
1309
1437
  *
1310
- * `env` counts only when it DUMPS: bare, or with the `-0` / `--null` output
1311
- * flags, followed by the end of the command or a separator / pipe / redirect.
1312
- * `env -u KEY cmd`, `env KEY=value cmd` and `env -i cmd` run a command with a
1313
- * changed environment and print nothing; an executor reaches for them to prove
1314
- * a keyless build still succeeds, and blocking those sent it looking for
1315
- * workarounds (X0 rehearsal 14).
1438
+ * `env` counts only when it DUMPS: {@link executedCommands} reports it as the program only when
1439
+ * nothing follows it, because `env -u KEY cmd`, `env KEY=value cmd` and `env -i cmd` run `cmd`
1440
+ * with a changed environment and print nothing — an executor reaches for them to prove a keyless
1441
+ * build still succeeds, and blocking those sent it looking for workarounds (X0 rehearsal 14).
1442
+ * `export` / `set` / `declare` print every variable they carry when given nothing to set.
1443
+ *
1444
+ * @param program - The command's program, directory removed.
1445
+ * @param args - Its arguments, as written.
1446
+ * @returns True when the command prints the environment.
1316
1447
  */
1317
- // `(?<!\\)`: a backslash-escaped `\|` is a literal in the shell (grep "a\|env"), not a pipe (X1 flash-m58).
1318
- const BLOCKED_COMMANDS = /(?<!\\)(?:^|[;&|`]\s*|(?:sh|bash|zsh|dash)\s+-c\s+['"]?\s*)(?:\/usr\/bin\/)?(?:\benv(?:\s+(?:-0|--null|--))?\s*(?:$|[;&|>)`'"])|\bprintenv\b|\bexport\s*$|\bset\s*$|\bdeclare\s+-x|cat\s+\/etc\/environment|cat\s+\/root\/\.bashrc|cat\s+\/proc\/\d+\/environ|cat\s+\/proc\/self\/environ|strings\s+\/proc|xargs[^;&|\n]*\/proc\/[^;&|\n]*environ|less\s+\/proc|head\s+\/proc|tail\s+\/proc|xxd\s+\/proc|od\s+\/proc|base64\s+\/proc|dd\s[^\n]*\/proc|sed\s[^\n]*\/proc\/[^\n]*environ|awk\s[^\n]*\/proc\/[^\n]*environ|cp\s[^\n]*\/proc\/[^\n]*environ|(?:cat|less|head|tail|xxd|od|base64|strings|dd|cp|sed|awk)\s[^\n;&|]*\/etc\/mol(?:\/|\b))/i;
1448
+ function dumpsEnvironment(program, args) {
1449
+ const words = args.map(shellWord);
1450
+ if (program === 'env')
1451
+ return true;
1452
+ if (program === 'printenv')
1453
+ return true;
1454
+ if (program === 'export')
1455
+ return words.every((word) => word === '-p' || word === '--print');
1456
+ if (program === 'set')
1457
+ return words.length === 0;
1458
+ if (program === 'declare')
1459
+ return (words.length === 0 ||
1460
+ (words.every((word) => word.startsWith('-')) &&
1461
+ words.some((word) => /^-[A-Za-z]*[px]/.test(word))));
1462
+ return false;
1463
+ }
1464
+ /** Interpreters an env-dumping one-liner is written in (python, node, ruby, perl). */
1465
+ const INTERPRETER_PROGRAMS = new Set([
1466
+ 'node',
1467
+ 'perl',
1468
+ 'python',
1469
+ 'python2',
1470
+ 'python3',
1471
+ 'ruby',
1472
+ ]);
1473
+ /** An inline program (`-e` / `-c`) that reads the environment and prints it. */
1474
+ const INTERPRETER_ENV_DUMP = /(?:^|\s)(?:-e|-c)\s[^\n]*(?:os\.environ|process\.env|ENV\[|%ENV|ENVIRON)/i;
1475
+ /**
1476
+ * Whether one executed command dumps the environment from an inline interpreter program
1477
+ * (`node -e 'console.log(process.env)'`).
1478
+ *
1479
+ * @param program - The command's program, directory removed.
1480
+ * @param text - The program and its arguments as one line.
1481
+ * @returns True when the inline program reads the environment.
1482
+ */
1483
+ function dumpsEnvironmentFromInterpreter(program, text) {
1484
+ return INTERPRETER_PROGRAMS.has(program) && INTERPRETER_ENV_DUMP.test(text);
1485
+ }
1319
1486
  /** Block shell redirects from /proc environ. */
1320
1487
  const BLOCKED_PROC_REDIRECT = /(?:<\s*\/proc\/(?:\d+|self)\/environ)/i;
1321
1488
  /** Block shell redirects from the molecule platform secrets dir (`/etc/mol`). */
1322
1489
  const BLOCKED_MOL_REDIRECT = /(?:<\s*\/etc\/mol)/i;
1323
- /** Interpreter-based env dumping (python, node, ruby, perl). */
1324
- const BLOCKED_INTERPRETER_ENV = /(?:python[23]?|node|ruby|perl)\s+(?:-e|-c)\s+[^\n]*(?:os\.environ|process\.env|ENV\[|%ENV|ENVIRON)/i;
1325
1490
  /**
1326
1491
  * Check if a command is blocked for security reasons. Returns error message or null if allowed.
1327
1492
  *
1493
+ * The first check reads the commands the shell actually RUNS — the statements, a `sh -c`
1494
+ * string's body, and every command substitution ({@link executedCommands}) — so a `|` inside a
1495
+ * quoted grep alternation is data, not a pipeline: `grep -E "USER CALLS|ENV|BUTTON|passed|failed"
1496
+ * app.log` prints log lines, and the `ENV` in its pattern started nothing (X0
1497
+ * xr-molecule-flash-m331 blocked exactly that call as an environment dump, twice). What the
1498
+ * shell DOES run stays visible through the same walk: `ls | env`, `bash -c 'env'`,
1499
+ * `echo "$(printenv)"` and a nested `sh -c 'x; env'` are all commands, and so is `env` itself
1500
+ * when nothing follows it.
1501
+ *
1328
1502
  * @param command - Shell command string proposed for execution.
1329
1503
  * @returns A human-readable block reason, or `null` when the command is allowed.
1330
1504
  */
@@ -1341,13 +1515,14 @@ export function checkBlockedCommand(command) {
1341
1515
  "check the project's provisioned env file to see WHICH services exist. Printing them only " +
1342
1516
  'leaks secrets into the transcript. To check whether ONE variable is set without printing ' +
1343
1517
  'it: `test -n "$NAME" && echo set || echo missing`.';
1344
- if (BLOCKED_COMMANDS.test(command))
1518
+ const statements = executedCommands(command);
1519
+ if (statements.some((s) => dumpsEnvironment(s.program, s.args) || readsSecretFile(s.program, s.args)))
1345
1520
  return `Command blocked: dumping environment variables is not allowed.${envDumpSteer}`;
1346
1521
  if (BLOCKED_PROC_REDIRECT.test(command))
1347
1522
  return 'Command blocked: /proc/environ access is not allowed.' + envDumpSteer;
1348
1523
  if (BLOCKED_MOL_REDIRECT.test(command))
1349
1524
  return 'Command blocked: /etc/mol (platform secrets) access is not allowed.' + envDumpSteer;
1350
- if (BLOCKED_INTERPRETER_ENV.test(command))
1525
+ if (statements.some((s) => dumpsEnvironmentFromInterpreter(s.program, s.text)))
1351
1526
  return `Command blocked: dumping the environment from an interpreter is not allowed.${envDumpSteer}`;
1352
1527
  return null;
1353
1528
  }
@@ -3882,11 +4057,8 @@ export function repeatingFailureNote(found, o) {
3882
4057
  `repeats this one cause. Stop it and fix that cause first: \`exec_command: ${o.stop}\`, ` +
3883
4058
  `then run the whole suite again. It is still going (${where}).${reproduce}`);
3884
4059
  }
3885
- // Everything else is a note on a run the wait is still waiting for, and both its shapes end the
3886
- // same way: the run finishes, because it is the only thing that names the causes this finding is
3887
- // not. What it does NOT carry is the "Stop it if every test fails this way" clause — that one
3888
- // belongs to a PROVEN cause the run is still walking (the sentence below), never to a lead:
3889
- // xr-molecule-flash-m99's executor read it on a shared duration and voided the run.
4060
+ // The same predicate controls both an early return and stop advice. Without
4061
+ // that verdict, neither a repeated error nor a shared duration asks for a stop.
3890
4062
  const keepGoing = `Let the rest of the run finish — it is the only thing that will name the causes that are NOT ` +
3891
4063
  `this one, and a run stopped at its first repeating cause costs a second whole run to find ` +
3892
4064
  `them. It is still going (${where}).`;
@@ -3894,10 +4066,9 @@ export function repeatingFailureNote(found, o) {
3894
4066
  return (`${found.count} different tests in this run have already failed the same way — ${signature} — ` +
3895
4067
  `so one cause is repeating:\n${rows}${more}\n` +
3896
4068
  (o.editsInvalidateRun
3897
- ? `Work out the fix now: apply it when the run ends, not while it walks. Stop it only if every test it walks `
3898
- : `Start fixing it now, while the run keeps walking. Stop it only if every test it walks `) +
3899
- `fails this same way (the app unreachable, a 5xx on every request, the same selector timing ` +
3900
- `out on every test of every file so far).${reproduce} ${keepGoing}${thenRun}`);
4069
+ ? `Work out the fix now: apply it when the run ends, not while it walks.`
4070
+ : `Start fixing it now, while the run keeps walking.`) +
4071
+ `${reproduce} ${keepGoing}${thenRun}`);
3901
4072
  return (`${found.count} different tests in this run each failed after ~${found.seconds}s — the same ` +
3902
4073
  `duration to within half a second. A shared duration is a lead, not a cause: a suite whose ` +
3903
4074
  `config sets a timeout spends that timeout on every failure that waits for anything, so ` +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@molecule/api-ai-tools",
3
- "version": "1.11.0",
3
+ "version": "1.12.0",
4
4
  "description": "Shared AI agent tools with backend abstraction for sandbox and local execution",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",