codeep 3.4.0 → 3.4.1

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.
@@ -6,6 +6,7 @@ import { resolve, relative, isAbsolute } from 'path';
6
6
  import { existsSync } from 'fs';
7
7
  import { isIP } from 'net';
8
8
  import { assertFetchUrlAllowed, isBlockedIp } from './ssrfGuard.js';
9
+ import { hardenedGitEnv, isExecutingConfigKey, GitHardeningError } from './git.js';
9
10
  function timedOutStderr(stderr, timeout) {
10
11
  const note = `Command timed out after ${timeout}ms`;
11
12
  return stderr.trim() ? `${stderr.replace(/\s+$/, '')}\n${note}` : note;
@@ -291,6 +292,200 @@ function hasExecEscape(command, args) {
291
292
  return false;
292
293
  return args.some((a) => flags.includes(a) || flags.some((f) => a.startsWith(f + '=')));
293
294
  }
295
+ // ─── git argv ────────────────────────────────────────────────────────────────
296
+ /**
297
+ * git's own options, before the subcommand, that take a separate argument.
298
+ * Needed so the subcommand is found at the right place: in
299
+ * `git -c user.name=x config`, the `config` is the subcommand, and in
300
+ * `git -C sub status` the `sub` is not.
301
+ */
302
+ const GIT_GLOBAL_OPTIONS_WITH_VALUE = new Set(['-C', '-c', '--git-dir', '--work-tree', '--namespace', '--super-prefix', '--attr-source']);
303
+ /**
304
+ * git options that move the call somewhere hardenedGitEnv() did not look, or
305
+ * feed it config from a place the caller cannot see. Refused rather than
306
+ * resolved.
307
+ *
308
+ * `--git-dir` / `--work-tree` point git at another repository's config, which
309
+ * is the one this process never scanned. `--exec-path` makes git load its own
310
+ * subcommands from a directory of the caller's choosing — that is code
311
+ * execution outright, not a config question. `--config-env` names an
312
+ * ENVIRONMENT VARIABLE to read a config value from, so the value that decides
313
+ * whether git runs a program is not in the command the user approved.
314
+ *
315
+ * `--attr-source=<tree>` is the same move against ATTRIBUTES: it makes git
316
+ * take `.gitattributes` from a tree object instead of from the working tree,
317
+ * and attributes are what route a file at a `filter.<d>.clean` or a
318
+ * `diff.<d>.textconv`. The tree it names is one nothing in this process ever
319
+ * read, so a command carrying it decides which programs a git call runs from
320
+ * a place the approval never showed. It used to be listed only as an option
321
+ * that takes a value, i.e. skipped. `-c attr.tree=<tree>` is the config
322
+ * spelling of the same thing and is refused alongside it — see
323
+ * GIT_CONFIG_EXTRA_EXECUTING_KEYS.
324
+ *
325
+ * `-C` is NOT here: it only moves the working directory, so the scan can
326
+ * simply follow it — see gitEffectiveCwd(). `-c` is not here either, because
327
+ * most of what it sets is ordinary (`user.name`, `core.autocrlf`,
328
+ * `protocol.file.allow`) and refusing it wholesale would break perfectly
329
+ * normal agent commands. The keys that make git RUN something are refused
330
+ * instead — see isRefusedGitConfigArg() below.
331
+ */
332
+ const GIT_REDIRECTING_OPTIONS = new Set([
333
+ '--git-dir',
334
+ '--work-tree',
335
+ '--exec-path',
336
+ '--config-env',
337
+ '--attr-source',
338
+ ]);
339
+ /**
340
+ * The `-c` keys execute_command will not pass to git.
341
+ *
342
+ * This shipped allowing every `-c`, defended by a comment claiming the value
343
+ * is IN THE COMMAND so the user reading the approval prompt sees it. That
344
+ * defence is FALSE wherever approvals are automatic — auto-approve and the
345
+ * headless paths run the command with nobody reading anything — and even
346
+ * with a human in front of it, `git -c core.pager=/tmp/x log` is arbitrary
347
+ * execution through the tool whose whole job is to gate arbitrary execution.
348
+ * `git` is on ALLOWED_COMMANDS precisely because it is not supposed to be
349
+ * one of those.
350
+ *
351
+ * What is refused is the executing family and nothing wider:
352
+ * - every key utils/git.ts neutralises or refuses in a repository's own
353
+ * config, asked of that same table through isExecutingConfigKey() so the
354
+ * two cannot drift — `core.fsmonitor`, `core.pager`, `gpg.program`,
355
+ * `filter.<d>.clean`, `alias.<name>`, `credential.helper`, the lot. A `-c`
356
+ * BEATS every one of those overrides: git reads its own `-c` after the
357
+ * GIT_CONFIG_* pairs (verified, git 2.54), so allowing it here is allowing
358
+ * the hardening to be switched off from the argv.
359
+ * - `core.hooksPath` and `init.templateDir`, which name a DIRECTORY of
360
+ * scripts rather than a program, and are not in that table because the
361
+ * repository-scope answer to them is the `noHooks` option instead.
362
+ * - `include.path` / `includeIf.<condition>.path`, which pull in a whole
363
+ * config file: every key in it is a program git may run and the argv shows
364
+ * a path, not a program. Same power as `--config-env` with the value
365
+ * hidden somewhere else, so it gets the same answer.
366
+ * - `attr.tree`, which names a TREE OBJECT to read `.gitattributes` from
367
+ * instead of the working tree. Attributes are what route a file at a
368
+ * `filter.<d>.clean` or a `diff.<d>.textconv`, so this picks which programs
369
+ * the call runs out of a tree nothing here ever read — and it names no
370
+ * program itself, so the executing-key table never matched it. It is the
371
+ * config spelling of `--attr-source`, which GIT_REDIRECTING_OPTIONS
372
+ * refuses.
373
+ *
374
+ * Case-insensitive throughout because git's own key lookup is: `-c
375
+ * CORE.PAGER=/tmp/x` runs the program exactly as the lower-case spelling
376
+ * does (verified, git 2.54), so a case-sensitive test here would be no test
377
+ * at all.
378
+ */
379
+ const GIT_CONFIG_EXTRA_EXECUTING_KEYS = /^(core\.hooksPath|init\.templateDir|include\.path|includeIf\..*\.path|attr\.tree)$/i;
380
+ function isRefusedGitConfigArg(key) {
381
+ return GIT_CONFIG_EXTRA_EXECUTING_KEYS.test(key) || isExecutingConfigKey(key);
382
+ }
383
+ /**
384
+ * `git config` flags that write somewhere the repo-scope scan deliberately
385
+ * TRUSTS. The scan leaves global and system scope alone so that git-lfs,
386
+ * commit signing and `git push` keep working — which means an agent that can
387
+ * run `git config --global core.pager /tmp/x` has moved its own program into
388
+ * the trusted scope, and the next Codeep git call runs it. Proven with git
389
+ * 2.54 through GIT_CONFIG_GLOBAL.
390
+ *
391
+ * `--file` is the same move against any file the user's config includes;
392
+ * `--config-env` is caught by GIT_REDIRECTING_OPTIONS already and is listed
393
+ * here so the reason a `git config` line was refused is the right one.
394
+ */
395
+ const GIT_CONFIG_ESCALATING_FLAGS = new Set(['--global', '--system', '--file', '-f', '--config-env']);
396
+ /** The flag part of `--name=value`, or the argument itself. */
397
+ function flagName(arg) {
398
+ const eq = arg.indexOf('=');
399
+ return eq === -1 ? arg : arg.slice(0, eq);
400
+ }
401
+ /**
402
+ * Walk git's global options and report where the subcommand starts. Returns
403
+ * the refusal reason instead when an option redirects the call.
404
+ */
405
+ function scanGitArgv(args) {
406
+ const chdirs = [];
407
+ let i = 0;
408
+ for (; i < args.length; i++) {
409
+ const arg = args[i];
410
+ if (!arg.startsWith('-'))
411
+ break;
412
+ const name = flagName(arg);
413
+ if (GIT_REDIRECTING_OPTIONS.has(name)) {
414
+ return {
415
+ problem: `'git ${name}' points git at a repository, a set of attributes, or a program this process ` +
416
+ 'has not checked, and is not allowed in agent mode. Run git in that directory instead ' +
417
+ '(git -C <dir> …), or run the command yourself.',
418
+ };
419
+ }
420
+ if (arg === '-C') {
421
+ // git rejects `-C<path>` and `-C=<path>`, so the value is always the
422
+ // next argument (verified, git 2.54).
423
+ chdirs.push(args[++i] ?? '');
424
+ continue;
425
+ }
426
+ if (name === '-c' || name === '--config') {
427
+ // git rejects `-ccore.pager=cat` outright ("unknown option"), so a
428
+ // `-c` always takes the NEXT argument (verified, git 2.54). `--config`
429
+ // is not a git option today; it is read here so that the key is
430
+ // checked rather than skipped if that ever changes.
431
+ const pair = arg === name ? (args[++i] ?? '') : arg.slice(name.length + 1);
432
+ const eq = pair.indexOf('=');
433
+ const key = eq === -1 ? pair : pair.slice(0, eq);
434
+ if (isRefusedGitConfigArg(key)) {
435
+ return {
436
+ problem: `'git ${name} ${key}=…' makes git run a program of the command's own choosing, or decides ` +
437
+ 'from somewhere unread which files get routed at one — that is the whole point of those ' +
438
+ 'keys — and a -c beats the hardening Codeep puts in the environment, ' +
439
+ 'because git reads its own -c last. It is also the one key family this repository\'s config ' +
440
+ 'is scanned for, so allowing it from the argv would hand back exactly what the scan takes ' +
441
+ 'away. Run the program directly if that is what you meant, or run the command yourself.',
442
+ };
443
+ }
444
+ continue;
445
+ }
446
+ if (GIT_GLOBAL_OPTIONS_WITH_VALUE.has(arg))
447
+ i++; // value, not the subcommand
448
+ }
449
+ return { subcommandAt: i, chdirs };
450
+ }
451
+ /**
452
+ * Why this `git` command may not run, or null.
453
+ *
454
+ * Two different holes, both on the execute_command path: an argv that aims
455
+ * git at a repository hardenedGitEnv() never scanned, and a `git config`
456
+ * that writes into the scope the scan trusts. See the two sets above.
457
+ */
458
+ function gitArgvProblem(args) {
459
+ const scan = scanGitArgv(args);
460
+ if ('problem' in scan)
461
+ return scan.problem;
462
+ if (args[scan.subcommandAt] !== 'config')
463
+ return null;
464
+ for (const arg of args.slice(scan.subcommandAt + 1)) {
465
+ const name = flagName(arg);
466
+ if (!GIT_CONFIG_ESCALATING_FLAGS.has(name))
467
+ continue;
468
+ return (`'git config ${name}' writes the git config outside this repository, which Codeep's hardening ` +
469
+ 'deliberately trusts — a program named there runs on the next git call. Change it yourself if ' +
470
+ 'you meant to, or use `git config --local` for this repository.');
471
+ }
472
+ return null;
473
+ }
474
+ /**
475
+ * The directory whose config decides what this `git` call can run.
476
+ *
477
+ * `-C` moves git before it reads any repository config, so hardening the
478
+ * spawn's `cwd` hardens the wrong repository: `git -C vendor/lib status` in a
479
+ * project whose own config is spotless ran the vendored checkout's
480
+ * `core.fsmonitor` AND its `filter.h.clean` (proven, git 2.54). Successive
481
+ * `-C` are relative to each other, exactly as git resolves them.
482
+ */
483
+ function gitEffectiveCwd(args, cwd) {
484
+ const scan = scanGitArgv(args);
485
+ if ('problem' in scan)
486
+ return cwd; // refused before it ever reaches a spawn
487
+ return scan.chdirs.reduce((dir, next) => (next ? resolve(dir, next) : dir), cwd);
488
+ }
294
489
  /**
295
490
  * Validate if a command is safe to execute (synchronous checks).
296
491
  * See validateCommandAsync for the DNS-resolving SSRF checks.
@@ -314,6 +509,13 @@ export function validateCommand(command, args, options) {
314
509
  if (hasExecEscape(command, args)) {
315
510
  return { valid: false, reason: `'${command}' with exec flags (-exec/-execdir/--to-command…) runs arbitrary commands and is not allowed in agent mode.` };
316
511
  }
512
+ // git's own argv can move the call out from under the hardening, or move a
513
+ // program INTO the scope the hardening trusts. See gitArgvProblem().
514
+ if (command === 'git') {
515
+ const problem = gitArgvProblem(args);
516
+ if (problem)
517
+ return { valid: false, reason: problem };
518
+ }
317
519
  // Check full command string against dangerous patterns
318
520
  const fullCommand = `${command} ${args.join(' ')}`;
319
521
  for (const pattern of BLOCKED_PATTERNS) {
@@ -352,6 +554,128 @@ export function validateCommand(command, args, options) {
352
554
  }
353
555
  return { valid: true };
354
556
  }
557
+ /**
558
+ * The environment a validated command runs in.
559
+ *
560
+ * `git` is on ALLOWED_COMMANDS, so a skill's shell line, a `!` command or the
561
+ * agent's own execute_command reaches git with whatever the repository put in
562
+ * its `.git/config` — and several of those settings make git RUN a program:
563
+ * a `filter.<driver>.clean` fires during the index refresh `git status` does,
564
+ * before anything looks like it executed code. Route git through the same
565
+ * hardening Codeep's own git calls use.
566
+ *
567
+ * Hooks are deliberately left alone here. The command was approved as
568
+ * written, so `git commit` through this path runs the repository's
569
+ * pre-commit hook exactly as it would in the user's terminal.
570
+ *
571
+ * A caller's own `env` goes in as the BASE rather than on top of the result:
572
+ * spread afterwards, their GIT_CONFIG_COUNT would replace ours and silently
573
+ * drop every override above their count.
574
+ *
575
+ * The bare name is the whole test because it has to be: validateCommand()
576
+ * only lets a command through when ALLOWED_COMMANDS holds it, and that set
577
+ * holds `git`, not `/usr/bin/git`. A path-spelled git never reaches here.
578
+ *
579
+ * The directory scanned comes from the ARGV, not from the spawn's cwd: `git
580
+ * -C vendor/lib status` reads the vendored checkout's config, so that is the
581
+ * config that has to be neutralised. The argv forms that redirect git
582
+ * somewhere this cannot follow (`--git-dir`, `--work-tree`, `--exec-path`,
583
+ * `--config-env`) never get here — validateCommand() refuses them.
584
+ *
585
+ * Throws `GitHardeningError` when the repository's config cannot be scanned —
586
+ * both runners below turn that into a failed CommandResult, because a refusal
587
+ * is this command's own failure and the user reads it as such.
588
+ *
589
+ * EXPORTED, and this signature is the contract, because the ACP terminal
590
+ * path spawns its own children and has to harden the SAME repository this
591
+ * does. Call it with the parsed command, its argv, the cwd the spawn will
592
+ * get and the caller's own env in `options.env`, and hand the result to the
593
+ * spawn as `env` — do not spread anything over it, or a later
594
+ * GIT_CONFIG_COUNT replaces ours and silently drops every override above it.
595
+ * The argv is not optional there: `git -C vendor/lib status` scans
596
+ * `vendor/lib`, and a caller that passes only the cwd hardens the wrong
597
+ * repository. A shell LINE rather than an argv belongs to shellCommandEnv()
598
+ * below instead. Both throw, and a refusal that escapes a promise executor
599
+ * never settles it.
600
+ */
601
+ export function commandEnv(command, args, cwd, options) {
602
+ const base = { ...process.env, ...options?.env };
603
+ return command === 'git' ? hardenedGitEnv({ cwd: gitEffectiveCwd(args, cwd), base }) : base;
604
+ }
605
+ /**
606
+ * `git` as a whole word anywhere in a command line. Deliberately loose:
607
+ * hardening a line that never runs git costs one `git config --list`, while
608
+ * missing one that does is the hole this closes. It does not see a git that
609
+ * runs from inside a script the line calls (`npm test`) — the same limit
610
+ * commandEnv() has, for the same reason.
611
+ */
612
+ const SHELL_LINE_MENTIONS_GIT = /(^|\W)git(\W|$)/;
613
+ /**
614
+ * The environment for a whole SHELL COMMAND LINE that may reach git.
615
+ *
616
+ * commandEnv() above can check a parsed binary name; a line handed to a shell
617
+ * can reach git from anywhere inside it — `cd sub && git status`, `make && git
618
+ * commit`, `foo | git apply` — so it needs its own entry point. This is that
619
+ * entry point for the callers that spawn with `shell: true`: the skill runner
620
+ * in src/acp/commands.ts and the one in src/renderer/agentExecution.ts, both
621
+ * of which used to reach git raw. A hostile `gpg.program` that createCommit
622
+ * neutralises still executed through those two spawns (proven, git 2.54).
623
+ *
624
+ * This is the ONE helper for that job — an earlier cut of this hotfix also
625
+ * had a `hardenedShellEnv()` in utils/toolExecution.ts, which hardened every
626
+ * skill step unconditionally and therefore refused an `echo` in a repository
627
+ * whose config cannot be scanned. Keep it one: two helpers with two different
628
+ * answers to "does a refusal stop this line?" is how one of them ends up
629
+ * wrong and unused.
630
+ *
631
+ * The contract, since those two call sites are not this file's to edit:
632
+ *
633
+ * - Pass the command line, the cwd the shell will get and any env of your
634
+ * own, and hand the RESULT to the spawn as `env`. Do not spread anything
635
+ * over it — a later `GIT_CONFIG_COUNT` replaces ours and silently drops
636
+ * every override above it.
637
+ * - It THROWS `GitHardeningError` when the repository's config cannot be
638
+ * scanned, or names a program no override can switch off. Catch it and fail
639
+ * the command with `error.message`, which is written for the user. Letting
640
+ * it escape a `spawnSync` call site turns a refusal into a crash; letting
641
+ * it escape inside a promise executor leaves the caller hanging.
642
+ * - Hooks are left alone, as they are for executeCommand(): the line was
643
+ * approved as written, so `git commit` in it runs the repository's
644
+ * pre-commit hook exactly as it would in the user's terminal.
645
+ * - A line that cannot reach git comes back unhardened, so a repository with
646
+ * an unreadable config does not also break `echo`. That is also why a
647
+ * refusal never reaches a non-git line: an `echo` must not stop working
648
+ * because some repository in the project sets `remote.origin.uploadpack`.
649
+ *
650
+ * WHAT THIS CAN AND CANNOT PROMISE, because a shell line is not an argv:
651
+ *
652
+ * - Scanned: the repository at `cwd`, AND every submodule of it — the ones
653
+ * its index records as gitlinks and the ones its config records by name,
654
+ * wherever each keeps its git directory (see listSubmoduleConfig in
655
+ * utils/git.ts). Every key in REPO_EXECUTING_RULES
656
+ * that any of them sets is neutralised, and because the overrides ride in
657
+ * the ENVIRONMENT rather than in an argv, they apply wherever in the line
658
+ * git ends up — so `cd vendor/lib && git add` is covered in full when
659
+ * `vendor/lib` is a submodule, which is the shape a skill step usually has.
660
+ * - Not scanned: a repository that is not `cwd` and not one of its
661
+ * submodules — an independent checkout under `vendor/`, a sibling clone,
662
+ * anywhere a `make` target cds to. There is no way to know where a shell
663
+ * line ends up without running it, so this does not pretend to. What still
664
+ * covers those is the always-on GIT_EXECUTING_CONFIG layer, which is why
665
+ * `core.fsmonitor` is blanket there rather than scope-aware. The gap is the
666
+ * keys GIT_CONFIG_* cannot wildcard — `filter.*` above all — in an
667
+ * unrelated repository below the one scanned. Proven with git 2.54: `cd
668
+ * vendor/lib && git status`, with `vendor/lib` a plain nested clone rather
669
+ * than a submodule, did not run the nested `core.fsmonitor` and did run the
670
+ * nested `filter.<d>.clean`.
671
+ * - executeCommand()'s argv path has no such gap: it reads `-C` out of the
672
+ * argv and scans where git will actually run, and refuses `--git-dir` /
673
+ * `--work-tree` / `--exec-path` / `--config-env` outright.
674
+ */
675
+ export function shellCommandEnv(commandLine, cwd, env) {
676
+ const base = { ...process.env, ...env };
677
+ return SHELL_LINE_MENTIONS_GIT.test(commandLine) ? hardenedGitEnv({ cwd, base }) : base;
678
+ }
355
679
  /**
356
680
  * Execute a shell command with safety checks
357
681
  */
@@ -384,14 +708,30 @@ export function executeCommand(command, args = [], options) {
384
708
  args,
385
709
  };
386
710
  }
711
+ // Built here rather than inline in spawnOptions below: a refusal from
712
+ // hardenedGitEnv() is this command's own failure and has to read as one,
713
+ // not as an exception out of a function whose whole contract is to report
714
+ // failures in its result.
715
+ let env;
716
+ try {
717
+ env = commandEnv(command, args, cwd, options);
718
+ }
719
+ catch (error) {
720
+ return {
721
+ success: false,
722
+ stdout: '',
723
+ stderr: error instanceof GitHardeningError ? error.message : String(error),
724
+ exitCode: -1,
725
+ duration: Date.now() - startTime,
726
+ command,
727
+ args,
728
+ };
729
+ }
387
730
  const spawnOptions = {
388
731
  cwd,
389
732
  timeout,
390
733
  encoding: 'utf-8',
391
- env: {
392
- ...process.env,
393
- ...options?.env,
394
- },
734
+ env,
395
735
  maxBuffer: 10 * 1024 * 1024, // 10MB
396
736
  };
397
737
  try {
@@ -510,9 +850,28 @@ export function executeCommandAsync(command, args = [], options) {
510
850
  });
511
851
  return;
512
852
  }
853
+ // Caught rather than thrown: this runs inside the promise executor, so
854
+ // a refusal from hardenedGitEnv() would reject a promise nobody holds
855
+ // and leave the caller waiting forever.
856
+ let env;
857
+ try {
858
+ env = commandEnv(command, args, cwd, options);
859
+ }
860
+ catch (error) {
861
+ resolve({
862
+ success: false,
863
+ stdout: '',
864
+ stderr: error instanceof GitHardeningError ? error.message : String(error),
865
+ exitCode: -1,
866
+ duration: Date.now() - startTime,
867
+ command,
868
+ args,
869
+ });
870
+ return;
871
+ }
513
872
  const child = spawn(command, args, {
514
873
  cwd,
515
- env: { ...process.env, ...options?.env },
874
+ env,
516
875
  });
517
876
  let stdout = '';
518
877
  let stderr = '';
@@ -37,8 +37,13 @@ export { nextOffset } from './telegramUpdates';
37
37
  * be mangled by Markdown parsing into something that is not what will run — and
38
38
  * approving a command you were shown incorrectly is the one failure this whole
39
39
  * feature must not have.
40
+ *
41
+ * `reason` is what the file being written decides — "this file controls what
42
+ * commands git runs". The terminal dialog shows it, and someone answering from
43
+ * a phone is the one with the least context, so a message without it asks them
44
+ * to approve on less than the person at the desk had.
40
45
  */
41
- export declare function composeMessage(command: string, toolName: string, isDestructive: boolean): string;
46
+ export declare function composeMessage(command: string, toolName: string, isDestructive: boolean, reason?: string): string;
42
47
  /**
43
48
  * Only the configured chat may decide.
44
49
  *
@@ -102,8 +107,11 @@ export declare class TelegramApproval {
102
107
  * Resolves `null` when no answer arrived — the terminal was used instead, the
103
108
  * caller aborted, or Telegram could not be reached. **A null is never
104
109
  * approval**: the caller keeps its own gate and decides for itself.
110
+ *
111
+ * `reason` says what the file being written decides, when it decides
112
+ * anything, so the phone shows what the terminal shows.
105
113
  */
106
- ask(command: string, toolName: string, isDestructive: boolean, signal?: AbortSignal): Promise<TelegramAnswer | null>;
114
+ ask(command: string, toolName: string, isDestructive: boolean, signal?: AbortSignal, reason?: string): Promise<TelegramAnswer | null>;
107
115
  /**
108
116
  * The terminal answered first. Close the question on the phone so nobody taps
109
117
  * a button that would do nothing, and say where it was decided.
@@ -32,6 +32,15 @@ export { nextOffset } from './telegramUpdates.js';
32
32
  * text; this keeps room for the heading and the fences, and a command longer
33
33
  * than this is not something anyone reads off a phone anyway. */
34
34
  const MAX_COMMAND_CHARS = 300;
35
+ /**
36
+ * Markdown markers neutralised.
37
+ *
38
+ * Telegram rejects a WHOLE message whose markup is unbalanced (these go out
39
+ * with `parse_mode: 'Markdown'`), so a single stray underscore in the text
40
+ * would mean the question never arrives at all. A visible backslash beats a
41
+ * phone that stayed silent.
42
+ */
43
+ const escapeMarkdown = (text) => text.replace(/([_*`[])/g, '\\$1');
35
44
  /**
36
45
  * The message text.
37
46
  *
@@ -39,18 +48,24 @@ const MAX_COMMAND_CHARS = 300;
39
48
  * be mangled by Markdown parsing into something that is not what will run — and
40
49
  * approving a command you were shown incorrectly is the one failure this whole
41
50
  * feature must not have.
51
+ *
52
+ * `reason` is what the file being written decides — "this file controls what
53
+ * commands git runs". The terminal dialog shows it, and someone answering from
54
+ * a phone is the one with the least context, so a message without it asks them
55
+ * to approve on less than the person at the desk had.
42
56
  */
43
- export function composeMessage(command, toolName, isDestructive) {
57
+ export function composeMessage(command, toolName, isDestructive, reason) {
44
58
  const head = isDestructive
45
59
  ? '⚠️ Codeep wants to run a destructive tool'
46
60
  : 'Codeep needs approval';
61
+ const why = reason ? `\n\n⚠️ ${escapeMarkdown(reason)}` : '';
47
62
  const trimmed = command.length > MAX_COMMAND_CHARS
48
63
  ? command.slice(0, MAX_COMMAND_CHARS - 1) + '…'
49
64
  : command;
50
65
  // A fence inside the command would close ours early and leak the rest as
51
66
  // prose. Neutralise it rather than trusting the input.
52
67
  const safe = trimmed.replace(/```/g, "'''");
53
- return `${head}\n\n\`${toolName}\`\n\n\`\`\`\n${safe}\n\`\`\``;
68
+ return `${head}${why}\n\n\`${toolName}\`\n\n\`\`\`\n${safe}\n\`\`\``;
54
69
  }
55
70
  /**
56
71
  * Only the configured chat may decide.
@@ -179,10 +194,13 @@ export class TelegramApproval {
179
194
  * Resolves `null` when no answer arrived — the terminal was used instead, the
180
195
  * caller aborted, or Telegram could not be reached. **A null is never
181
196
  * approval**: the caller keeps its own gate and decides for itself.
197
+ *
198
+ * `reason` says what the file being written decides, when it decides
199
+ * anything, so the phone shows what the terminal shows.
182
200
  */
183
- async ask(command, toolName, isDestructive, signal) {
201
+ async ask(command, toolName, isDestructive, signal, reason) {
184
202
  const token = randomToken();
185
- const messageID = await this.sendQuestion(composeMessage(command, toolName, isDestructive), token);
203
+ const messageID = await this.sendQuestion(composeMessage(command, toolName, isDestructive, reason), token);
186
204
  if (messageID === null) {
187
205
  // Say it once, here, rather than leaving the caller to guess from a null
188
206
  // that also means "answered elsewhere" and "cancelled".
@@ -5,6 +5,7 @@
5
5
  * executeTool() dispatches to individual tool handlers.
6
6
  * listDirectory() and htmlToText() are private helpers.
7
7
  * createActionLog() converts a ToolCall+ToolResult into a history ActionLog.
8
+ * trustBearingWrite() names the writes that decide what runs later.
8
9
  */
9
10
  import { ToolCall, ToolResult, ActionLog } from './tools';
10
11
  export { isBlockedIp, assertFetchUrlAllowed } from './ssrfGuard';
@@ -18,6 +19,46 @@ export declare function validatePath(path: string, projectRoot: string): {
18
19
  absolutePath: string;
19
20
  error?: string;
20
21
  };
22
+ /** Drop the cached hook directories. Call before spawning a command line. */
23
+ export declare function forgetHooksDirectory(): void;
24
+ /**
25
+ * The tail of the refusal one of these writes gets when the run has nobody to
26
+ * ask — no permission callback at all, which is how `codeep review --fix`
27
+ * runs in CI. agent.ts builds the refusal; headlessReview.ts recognises it by
28
+ * this text so it can say so in the run output rather than leave it buried in
29
+ * the agent's tool log.
30
+ *
31
+ * One constant, in the module that owns the classification rather than in
32
+ * agent.ts, for two reasons: a reworded refusal that stopped matching would
33
+ * put CI back to failing silently, and agent.js is a module the fix-run tests
34
+ * replace wholesale — a constant read from there would have been undefined in
35
+ * exactly the test that guards this.
36
+ */
37
+ export declare const NO_CONFIRMER_REFUSAL = "Nobody could be asked to confirm it, so nothing was written.";
38
+ export interface TrustBearingWrite {
39
+ /** The path exactly as the tool call named it, for the prompt. */
40
+ path: string;
41
+ /** The absolute path the classification matched — one spelling per file, so
42
+ * a "never again" answer given for `.git/config` also covers
43
+ * `./.git/config` and the symlink that reaches it. Never shown to anyone;
44
+ * it exists to key that answer (see agent.ts). */
45
+ file: string;
46
+ /** One plain sentence about what the file controls. */
47
+ reason: string;
48
+ }
49
+ /**
50
+ * What a tool call would write that decides what runs later, or null.
51
+ *
52
+ * Callers use this for two things: to force a confirmation the mode would
53
+ * otherwise skip (see agent.ts), and to tell the person answering it what
54
+ * they are approving.
55
+ *
56
+ * A symlink inside the project can point at one of these names — `ln -s .git
57
+ * tools/cfg` makes a write to `tools/cfg/config` land in the real `.git`, and
58
+ * validatePath allows it because it never leaves the project — so the
59
+ * resolved path is classified alongside the one the model asked for.
60
+ */
61
+ export declare function trustBearingWrite(toolCall: ToolCall, projectRoot: string): TrustBearingWrite | null;
21
62
  /**
22
63
  * Optional filesystem delegation. When an ACP client advertises `fs`
23
64
  * capability (Zed always does, VS Code may), the server should route