codeep 3.3.3 → 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.
Files changed (79) hide show
  1. package/dist/acp/commands.d.ts +50 -1
  2. package/dist/acp/commands.js +545 -109
  3. package/dist/acp/protocol.d.ts +14 -5
  4. package/dist/acp/server.d.ts +36 -1
  5. package/dist/acp/server.js +581 -155
  6. package/dist/acp/serverHandlers.d.ts +2 -1
  7. package/dist/acp/serverHandlers.js +3 -0
  8. package/dist/acp/session.d.ts +28 -2
  9. package/dist/acp/session.js +25 -6
  10. package/dist/acp/transport.d.ts +40 -4
  11. package/dist/acp/transport.js +218 -25
  12. package/dist/acp/turns.d.ts +20 -0
  13. package/dist/acp/turns.js +30 -0
  14. package/dist/api/index.js +2 -0
  15. package/dist/api/ollamaNative.d.ts +3 -0
  16. package/dist/api/ollamaNative.js +35 -3
  17. package/dist/config/index.d.ts +21 -4
  18. package/dist/config/index.js +178 -123
  19. package/dist/renderer/agentExecution.d.ts +30 -2
  20. package/dist/renderer/agentExecution.js +248 -92
  21. package/dist/renderer/commands/helpers.d.ts +18 -2
  22. package/dist/renderer/commands/helpers.js +28 -5
  23. package/dist/renderer/commands.d.ts +2 -0
  24. package/dist/renderer/commands.js +180 -64
  25. package/dist/renderer/main.d.ts +41 -0
  26. package/dist/renderer/main.js +181 -80
  27. package/dist/utils/agent.d.ts +69 -4
  28. package/dist/utils/agent.js +416 -248
  29. package/dist/utils/agentChat.js +82 -10
  30. package/dist/utils/agents.d.ts +2 -1
  31. package/dist/utils/agents.js +100 -29
  32. package/dist/utils/auditLog.d.ts +4 -3
  33. package/dist/utils/auditLog.js +92 -9
  34. package/dist/utils/checkpoints.js +11 -6
  35. package/dist/utils/codeReview.js +28 -23
  36. package/dist/utils/codeepCloud.d.ts +14 -2
  37. package/dist/utils/codeepCloud.js +56 -20
  38. package/dist/utils/customCommands.js +7 -2
  39. package/dist/utils/git.d.ts +262 -4
  40. package/dist/utils/git.js +1928 -61
  41. package/dist/utils/gitHookInstaller.d.ts +32 -1
  42. package/dist/utils/gitHookInstaller.js +76 -8
  43. package/dist/utils/gitignore.d.ts +8 -0
  44. package/dist/utils/gitignore.js +41 -10
  45. package/dist/utils/headlessReview.d.ts +11 -0
  46. package/dist/utils/headlessReview.js +33 -5
  47. package/dist/utils/history.d.ts +22 -6
  48. package/dist/utils/history.js +140 -26
  49. package/dist/utils/logger.js +6 -7
  50. package/dist/utils/mcpConfig.d.ts +24 -0
  51. package/dist/utils/mcpConfig.js +36 -5
  52. package/dist/utils/mentions.d.ts +28 -5
  53. package/dist/utils/mentions.js +253 -45
  54. package/dist/utils/personalities.js +16 -6
  55. package/dist/utils/planMode.d.ts +13 -7
  56. package/dist/utils/planMode.js +32 -12
  57. package/dist/utils/projectIntelligence.d.ts +2 -0
  58. package/dist/utils/projectIntelligence.js +27 -8
  59. package/dist/utils/projectPaths.d.ts +53 -0
  60. package/dist/utils/projectPaths.js +146 -0
  61. package/dist/utils/shell.d.ts +119 -0
  62. package/dist/utils/shell.js +417 -45
  63. package/dist/utils/skillBundles.js +17 -7
  64. package/dist/utils/skillBundlesCloud.js +20 -3
  65. package/dist/utils/skills.d.ts +24 -2
  66. package/dist/utils/skills.js +235 -43
  67. package/dist/utils/smartContext.js +97 -23
  68. package/dist/utils/telegramApproval.d.ts +10 -2
  69. package/dist/utils/telegramApproval.js +22 -4
  70. package/dist/utils/toolExecution.d.ts +50 -2
  71. package/dist/utils/toolExecution.js +418 -16
  72. package/dist/utils/toolParsing.d.ts +7 -1
  73. package/dist/utils/toolParsing.js +12 -3
  74. package/dist/utils/userProfile.js +58 -16
  75. package/dist/utils/verify.d.ts +25 -4
  76. package/dist/utils/verify.js +259 -74
  77. package/dist/version.d.ts +1 -1
  78. package/dist/version.js +1 -1
  79. package/package.json +1 -1
@@ -6,6 +6,11 @@ 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';
10
+ function timedOutStderr(stderr, timeout) {
11
+ const note = `Command timed out after ${timeout}ms`;
12
+ return stderr.trim() ? `${stderr.replace(/\s+$/, '')}\n${note}` : note;
13
+ }
9
14
  // Dangerous command patterns that should never be executed
10
15
  const BLOCKED_COMMANDS = new Set([
11
16
  'sudo',
@@ -287,6 +292,200 @@ function hasExecEscape(command, args) {
287
292
  return false;
288
293
  return args.some((a) => flags.includes(a) || flags.some((f) => a.startsWith(f + '=')));
289
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
+ }
290
489
  /**
291
490
  * Validate if a command is safe to execute (synchronous checks).
292
491
  * See validateCommandAsync for the DNS-resolving SSRF checks.
@@ -310,6 +509,13 @@ export function validateCommand(command, args, options) {
310
509
  if (hasExecEscape(command, args)) {
311
510
  return { valid: false, reason: `'${command}' with exec flags (-exec/-execdir/--to-command…) runs arbitrary commands and is not allowed in agent mode.` };
312
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
+ }
313
519
  // Check full command string against dangerous patterns
314
520
  const fullCommand = `${command} ${args.join(' ')}`;
315
521
  for (const pattern of BLOCKED_PATTERNS) {
@@ -348,6 +554,128 @@ export function validateCommand(command, args, options) {
348
554
  }
349
555
  return { valid: true };
350
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
+ }
351
679
  /**
352
680
  * Execute a shell command with safety checks
353
681
  */
@@ -380,14 +708,30 @@ export function executeCommand(command, args = [], options) {
380
708
  args,
381
709
  };
382
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
+ }
383
730
  const spawnOptions = {
384
731
  cwd,
385
732
  timeout,
386
733
  encoding: 'utf-8',
387
- env: {
388
- ...process.env,
389
- ...options?.env,
390
- },
734
+ env,
391
735
  maxBuffer: 10 * 1024 * 1024, // 10MB
392
736
  };
393
737
  try {
@@ -398,11 +742,12 @@ export function executeCommand(command, args = [], options) {
398
742
  return {
399
743
  success: false,
400
744
  stdout: result.stdout?.toString() || '',
401
- stderr: `Command timed out after ${timeout}ms`,
745
+ stderr: timedOutStderr(result.stderr?.toString() || '', timeout),
402
746
  exitCode: -1,
403
747
  duration,
404
748
  command,
405
749
  args,
750
+ timedOut: true,
406
751
  };
407
752
  }
408
753
  return {
@@ -490,62 +835,89 @@ export function executeCommandAsync(command, args = [], options) {
490
835
  });
491
836
  return;
492
837
  }
838
+ // Cancelled while the checks above ran: never start the process.
839
+ const signal = options?.signal;
840
+ if (signal?.aborted) {
841
+ resolve({
842
+ success: false,
843
+ stdout: '',
844
+ stderr: 'Command cancelled',
845
+ exitCode: -1,
846
+ duration: Date.now() - startTime,
847
+ command,
848
+ args,
849
+ cancelled: true,
850
+ });
851
+ return;
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
+ }
493
872
  const child = spawn(command, args, {
494
873
  cwd,
495
- env: { ...process.env, ...options?.env },
874
+ env,
496
875
  });
497
876
  let stdout = '';
498
877
  let stderr = '';
499
878
  child.stdout.on('data', (data) => { stdout += data.toString(); });
500
879
  child.stderr.on('data', (data) => { stderr += data.toString(); });
501
880
  let settled = false;
502
- const timer = setTimeout(() => {
881
+ // A caller's signal usually belongs to a whole prompt and outlives
882
+ // many commands, so the listener comes off as soon as this one ends.
883
+ const finish = (result) => {
503
884
  if (settled)
504
885
  return;
505
886
  settled = true;
887
+ clearTimeout(timer);
888
+ signal?.removeEventListener('abort', onAbort);
889
+ resolve({ ...result, duration: Date.now() - startTime, command, args });
890
+ };
891
+ // Settle before killing: kill() can emit 'error' synchronously, and
892
+ // that must not replace the verdict.
893
+ const onAbort = () => {
894
+ if (settled)
895
+ return;
896
+ finish({ success: false, stdout, stderr: 'Command cancelled', exitCode: -1, cancelled: true });
506
897
  child.kill('SIGTERM');
507
- const duration = Date.now() - startTime;
508
- resolve({
509
- success: false,
510
- stdout,
511
- stderr: `Command timed out after ${timeout}ms`,
512
- exitCode: -1,
513
- duration,
514
- command,
515
- args,
516
- });
517
- }, timeout);
518
- child.on('close', (code) => {
898
+ // A child that ignores SIGTERM must not keep running after the
899
+ // caller was told it stopped.
900
+ const force = setTimeout(() => {
901
+ if (child.exitCode === null && child.signalCode === null)
902
+ child.kill('SIGKILL');
903
+ }, 2000);
904
+ force.unref();
905
+ child.once('exit', () => clearTimeout(force));
906
+ };
907
+ const timer = setTimeout(() => {
519
908
  if (settled)
520
909
  return;
521
- settled = true;
522
- clearTimeout(timer);
523
- const duration = Date.now() - startTime;
524
- resolve({
525
- success: code === 0,
526
- stdout,
527
- stderr,
528
- exitCode: code ?? -1,
529
- duration,
530
- command,
531
- args,
532
- });
910
+ // Keep what the command printed: a test runner reports its failures on
911
+ // stderr before it hangs, and a check needs them.
912
+ finish({ success: false, stdout, stderr: timedOutStderr(stderr, timeout), exitCode: -1, timedOut: true });
913
+ child.kill('SIGTERM');
914
+ }, timeout);
915
+ signal?.addEventListener('abort', onAbort, { once: true });
916
+ child.on('close', (code) => {
917
+ finish({ success: code === 0, stdout, stderr, exitCode: code ?? -1 });
533
918
  });
534
919
  child.on('error', (err) => {
535
- if (settled)
536
- return;
537
- settled = true;
538
- clearTimeout(timer);
539
- const duration = Date.now() - startTime;
540
- resolve({
541
- success: false,
542
- stdout: '',
543
- stderr: err.message,
544
- exitCode: -1,
545
- duration,
546
- command,
547
- args,
548
- });
920
+ finish({ success: false, stdout: '', stderr: err.message, exitCode: -1 });
549
921
  });
550
922
  });
551
923
  });
@@ -36,6 +36,7 @@
36
36
  import { existsSync, readdirSync, readFileSync, statSync } from 'fs';
37
37
  import { join } from 'path';
38
38
  import { homedir } from 'os';
39
+ import { leadsOutsideProject } from './projectPaths.js';
39
40
  /**
40
41
  * Tolerant YAML-frontmatter parser — handles `key: value`, `key: [a, b]`,
41
42
  * and `key:` followed by `- item` block-list lines. Quoted strings are
@@ -93,7 +94,9 @@ export function parseFrontmatter(raw) {
93
94
  export function stripQuotes(s) {
94
95
  return s.replace(/^["']|["']$/g, '');
95
96
  }
96
- function loadFromDir(dir, scope) {
97
+ /** Largest SKILL.md we'll read (256 KB). */
98
+ const MAX_SKILL_FILE_BYTES = 256 * 1024;
99
+ function loadFromDir(dir, scope, projectRoot) {
97
100
  if (!existsSync(dir))
98
101
  return [];
99
102
  let entries;
@@ -118,18 +121,25 @@ function loadFromDir(dir, scope) {
118
121
  const skillFile = join(bundleDir, 'SKILL.md');
119
122
  if (!existsSync(skillFile))
120
123
  continue;
124
+ // A project's bundles come with the repo: a link out of it (the bundle
125
+ // directory or its SKILL.md) would put a user file into the prompt.
126
+ if (projectRoot && leadsOutsideProject(skillFile, projectRoot))
127
+ continue;
128
+ // Cap at 256 KB — any bigger and the user is shipping something
129
+ // that doesn't belong in a SKILL.md. Skip silently to avoid OOM
130
+ // surprises when an agent run loads dozens of bundles. Check before
131
+ // reading: statSync follows symlinks, and a committed `SKILL.md ->
132
+ // /dev/zero` (or a FIFO) never comes back from readFileSync.
121
133
  let raw;
122
134
  try {
135
+ const fileStat = statSync(skillFile);
136
+ if (!fileStat.isFile() || fileStat.size > MAX_SKILL_FILE_BYTES)
137
+ continue;
123
138
  raw = readFileSync(skillFile, 'utf-8');
124
139
  }
125
140
  catch {
126
141
  continue;
127
142
  }
128
- // Cap at 256 KB — any bigger and the user is shipping something
129
- // that doesn't belong in a SKILL.md. Skip silently to avoid OOM
130
- // surprises when an agent run loads dozens of bundles.
131
- if (raw.length > 256 * 1024)
132
- continue;
133
143
  const { meta, body } = parseFrontmatter(raw);
134
144
  const name = typeof meta.name === 'string' && meta.name ? meta.name : entry;
135
145
  if (!/^[a-z0-9][a-z0-9-]*$/i.test(name))
@@ -168,7 +178,7 @@ export function asStringArray(v) {
168
178
  export function loadSkillBundles(workspaceRoot) {
169
179
  const global = loadFromDir(join(homedir(), '.codeep', 'skills'), 'global');
170
180
  const project = workspaceRoot
171
- ? loadFromDir(join(workspaceRoot, '.codeep', 'skills'), 'project')
181
+ ? loadFromDir(join(workspaceRoot, '.codeep', 'skills'), 'project', workspaceRoot)
172
182
  : [];
173
183
  const byName = new Map();
174
184
  for (const b of global)
@@ -10,7 +10,8 @@
10
10
  * Auth uses the same `x-sync-token` header `codeepCloud.ts` already sends
11
11
  * for /api/tasks and friends.
12
12
  */
13
- import { existsSync, readFileSync, mkdirSync, writeFileSync, rmSync } from 'fs';
13
+ import { existsSync, readFileSync, rmSync } from 'fs';
14
+ import { isSafeProjectDir, writeProjectFile } from './projectPaths.js';
14
15
  import { join } from 'path';
15
16
  import { homedir } from 'os';
16
17
  import { getSyncToken } from '../config/index.js';
@@ -83,12 +84,17 @@ export async function installBundle(workspaceRoot, idOrPath) {
83
84
  return { ok: false, error: data.error ?? `HTTP ${res.status}` };
84
85
  }
85
86
  const skill = data.skill;
87
+ // The slug becomes a directory name. codeep.dev only issues this shape;
88
+ // anything else from the server must not choose where files are written.
89
+ if (!isBundleSlug(skill.slug)) {
90
+ return { ok: false, error: `The server sent an invalid skill name: ${JSON.stringify(skill.slug)}` };
91
+ }
86
92
  const dir = join(workspaceRoot, '.codeep', 'skills', skill.slug);
87
93
  if (existsSync(dir)) {
88
94
  return { ok: false, error: `A bundle already exists at .codeep/skills/${skill.slug}/ — remove it before re-installing.` };
89
95
  }
90
- mkdirSync(dir, { recursive: true });
91
- writeFileSync(join(dir, 'SKILL.md'), skill.body);
96
+ // .codeep/ can come with a cloned repo: never write through a symlink.
97
+ writeProjectFile(workspaceRoot, join(dir, 'SKILL.md'), skill.body);
92
98
  return { ok: true, name: skill.slug };
93
99
  }
94
100
  catch (err) {
@@ -172,8 +178,14 @@ export function serialiseSkillMd(bundle) {
172
178
  meta.push('---', '');
173
179
  return meta.join('\n') + bundle.body;
174
180
  }
181
+ /** The bundle names codeep.dev issues (lowercase letters, digits, hyphens). */
182
+ function isBundleSlug(slug) {
183
+ return typeof slug === 'string' && /^[a-z0-9][a-z0-9-]{0,63}$/.test(slug);
184
+ }
175
185
  /** Read raw SKILL.md from disk — used when we want the unmodified bytes. */
176
186
  export function readRawSkillMd(workspaceRoot, slug) {
187
+ if (!isBundleSlug(slug))
188
+ return null;
177
189
  const file = join(workspaceRoot, '.codeep', 'skills', slug, 'SKILL.md');
178
190
  if (!existsSync(file))
179
191
  return null;
@@ -186,9 +198,14 @@ export function readRawSkillMd(workspaceRoot, slug) {
186
198
  }
187
199
  /** Delete the local copy of an installed skill bundle (for /skills uninstall). */
188
200
  export function uninstallLocalBundle(workspaceRoot, slug) {
201
+ if (!isBundleSlug(slug))
202
+ return false;
189
203
  const dir = join(workspaceRoot, '.codeep', 'skills', slug);
190
204
  if (!existsSync(dir))
191
205
  return false;
206
+ // Through a symlinked .codeep/ (or skills/) this would delete a directory elsewhere.
207
+ if (!isSafeProjectDir(workspaceRoot, dir))
208
+ return false;
192
209
  try {
193
210
  rmSync(dir, { recursive: true, force: true });
194
211
  return true;