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.
- package/dist/acp/commands.d.ts +15 -0
- package/dist/acp/commands.js +30 -1
- package/dist/acp/server.js +237 -23
- package/dist/acp/session.d.ts +13 -2
- package/dist/acp/transport.d.ts +6 -0
- package/dist/acp/transport.js +98 -3
- package/dist/renderer/agentExecution.js +116 -69
- package/dist/renderer/commands.js +19 -3
- package/dist/renderer/main.d.ts +24 -0
- package/dist/renderer/main.js +57 -2
- package/dist/utils/agent.d.ts +17 -2
- package/dist/utils/agent.js +62 -5
- package/dist/utils/checkpoints.js +3 -0
- package/dist/utils/codeReview.js +28 -23
- 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/headlessReview.js +26 -5
- package/dist/utils/shell.d.ts +108 -0
- package/dist/utils/shell.js +364 -5
- package/dist/utils/telegramApproval.d.ts +10 -2
- package/dist/utils/telegramApproval.js +22 -4
- package/dist/utils/toolExecution.d.ts +41 -0
- package/dist/utils/toolExecution.js +357 -1
- 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,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
|
|
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
|