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.
- package/dist/acp/commands.d.ts +50 -1
- package/dist/acp/commands.js +545 -109
- package/dist/acp/protocol.d.ts +14 -5
- package/dist/acp/server.d.ts +36 -1
- package/dist/acp/server.js +581 -155
- package/dist/acp/serverHandlers.d.ts +2 -1
- package/dist/acp/serverHandlers.js +3 -0
- package/dist/acp/session.d.ts +28 -2
- package/dist/acp/session.js +25 -6
- package/dist/acp/transport.d.ts +40 -4
- package/dist/acp/transport.js +218 -25
- package/dist/acp/turns.d.ts +20 -0
- package/dist/acp/turns.js +30 -0
- package/dist/api/index.js +2 -0
- package/dist/api/ollamaNative.d.ts +3 -0
- package/dist/api/ollamaNative.js +35 -3
- package/dist/config/index.d.ts +21 -4
- package/dist/config/index.js +178 -123
- package/dist/renderer/agentExecution.d.ts +30 -2
- package/dist/renderer/agentExecution.js +248 -92
- package/dist/renderer/commands/helpers.d.ts +18 -2
- package/dist/renderer/commands/helpers.js +28 -5
- package/dist/renderer/commands.d.ts +2 -0
- package/dist/renderer/commands.js +180 -64
- package/dist/renderer/main.d.ts +41 -0
- package/dist/renderer/main.js +181 -80
- package/dist/utils/agent.d.ts +69 -4
- package/dist/utils/agent.js +416 -248
- package/dist/utils/agentChat.js +82 -10
- package/dist/utils/agents.d.ts +2 -1
- package/dist/utils/agents.js +100 -29
- package/dist/utils/auditLog.d.ts +4 -3
- package/dist/utils/auditLog.js +92 -9
- package/dist/utils/checkpoints.js +11 -6
- package/dist/utils/codeReview.js +28 -23
- package/dist/utils/codeepCloud.d.ts +14 -2
- package/dist/utils/codeepCloud.js +56 -20
- package/dist/utils/customCommands.js +7 -2
- package/dist/utils/git.d.ts +262 -4
- package/dist/utils/git.js +1928 -61
- package/dist/utils/gitHookInstaller.d.ts +32 -1
- package/dist/utils/gitHookInstaller.js +76 -8
- package/dist/utils/gitignore.d.ts +8 -0
- package/dist/utils/gitignore.js +41 -10
- package/dist/utils/headlessReview.d.ts +11 -0
- package/dist/utils/headlessReview.js +33 -5
- package/dist/utils/history.d.ts +22 -6
- package/dist/utils/history.js +140 -26
- package/dist/utils/logger.js +6 -7
- package/dist/utils/mcpConfig.d.ts +24 -0
- package/dist/utils/mcpConfig.js +36 -5
- package/dist/utils/mentions.d.ts +28 -5
- package/dist/utils/mentions.js +253 -45
- package/dist/utils/personalities.js +16 -6
- package/dist/utils/planMode.d.ts +13 -7
- package/dist/utils/planMode.js +32 -12
- package/dist/utils/projectIntelligence.d.ts +2 -0
- package/dist/utils/projectIntelligence.js +27 -8
- package/dist/utils/projectPaths.d.ts +53 -0
- package/dist/utils/projectPaths.js +146 -0
- package/dist/utils/shell.d.ts +119 -0
- package/dist/utils/shell.js +417 -45
- package/dist/utils/skillBundles.js +17 -7
- package/dist/utils/skillBundlesCloud.js +20 -3
- package/dist/utils/skills.d.ts +24 -2
- package/dist/utils/skills.js +235 -43
- package/dist/utils/smartContext.js +97 -23
- package/dist/utils/telegramApproval.d.ts +10 -2
- package/dist/utils/telegramApproval.js +22 -4
- package/dist/utils/toolExecution.d.ts +50 -2
- package/dist/utils/toolExecution.js +418 -16
- package/dist/utils/toolParsing.d.ts +7 -1
- package/dist/utils/toolParsing.js +12 -3
- package/dist/utils/userProfile.js +58 -16
- package/dist/utils/verify.d.ts +25 -4
- package/dist/utils/verify.js +259 -74
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/utils/shell.js
CHANGED
|
@@ -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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
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
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
91
|
-
|
|
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;
|