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.
@@ -28,6 +28,21 @@ export interface AcpSession {
28
28
  export type AcpAgentRunOptions = Pick<AgentSessionOptions, 'onRequestPermission' | 'extraDangerousTools' | 'onExecuteCommand' | 'fs'> & {
29
29
  /** Ask the user a yes/no question. Unset: the mode runs without asking. */
30
30
  confirm?: (message: string) => Promise<boolean>;
31
+ /**
32
+ * Auto mode's answer to the agent's permission gate: yes to everything
33
+ * except a write to a file that decides what runs later, which is asked
34
+ * about in every mode. A command that runs the agent passes it in place of
35
+ * the missing `onRequestPermission`, because the agent refuses those writes
36
+ * outright when it has nobody to ask — so without this, /go and a skill's
37
+ * agent step could not touch `.git/config` at all in auto mode, while a
38
+ * plain prompt could after a confirmation.
39
+ *
40
+ * Kept under its own key rather than set on `onRequestPermission`, which
41
+ * this file reads as "this session asks the user" when it decides whether
42
+ * to gate a skill's shell lines. Auto mode runs those without asking, as
43
+ * that mode promises.
44
+ */
45
+ onAutoModePermission?: AgentSessionOptions['onRequestPermission'];
31
46
  };
32
47
  export interface CommandResult {
33
48
  /** true if the input was a slash command (even if it failed) */
@@ -15,6 +15,8 @@ import { existsSync, mkdirSync } from 'fs';
15
15
  import { join } from 'path';
16
16
  import { chat } from '../api/index.js';
17
17
  import { runAgent, classifyPermissionOutcome, buildDangerousTools } from '../utils/agent.js';
18
+ import { forgetHooksDirectory } from '../utils/toolExecution.js';
19
+ import { shellCommandEnv } from '../utils/shell.js';
18
20
  import { beginTurn } from './turns.js';
19
21
  /** Pending plans a /go is executing right now. */
20
22
  const plansRunning = new Set();
@@ -1535,12 +1537,35 @@ Anything else the agent should know — edge cases, gotchas, things to double-ch
1535
1537
  stopIfCancelled();
1536
1538
  }
1537
1539
  }
1540
+ // A raw process.env here handed the repository's own `.git/config`
1541
+ // back to git: `/commit` runs `git commit`, and a repo-scope
1542
+ // `gpg.program` that Codeep's own commit path neutralises executed
1543
+ // through this spawn instead.
1544
+ //
1545
+ // Built before the spawn, and caught: shellCommandEnv() refuses a
1546
+ // git line in a repository whose config names a program no override
1547
+ // switches off, and a refusal that escaped here would abort the
1548
+ // whole skill from inside executeSkill's callback. Failing the step
1549
+ // with the refusal's own wording is what a step that cannot run
1550
+ // looks like everywhere else in this handler.
1551
+ let env;
1552
+ try {
1553
+ env = shellCommandEnv(shellCmd, session.workspaceRoot);
1554
+ }
1555
+ catch (error) {
1556
+ throw new Error(`\`${shellCmd}\` was not run: ${error instanceof Error ? error.message : String(error)}`);
1557
+ }
1558
+ // A command line is the one thing in a skill that can move this
1559
+ // repository's hooks (`git config core.hooksPath .evil`), and the
1560
+ // write gate caches where they are for the run.
1561
+ forgetHooksDirectory();
1538
1562
  const proc = spawnSync(shellCmd, {
1539
1563
  cwd: session.workspaceRoot,
1540
1564
  encoding: 'utf-8',
1541
1565
  timeout: 60_000,
1542
1566
  shell: true,
1543
1567
  stdio: ['pipe', 'pipe', 'pipe'],
1568
+ env,
1544
1569
  });
1545
1570
  const out = ((proc.stdout || '') + (proc.stderr || '')).trim();
1546
1571
  const block = `\`${shellCmd}\`\n\`\`\`\n${out || '(no output)'}\n\`\`\`\n`;
@@ -1603,7 +1628,11 @@ async function runCommandAgent(task, session, onChunk, abortSignal, agentRun) {
1603
1628
  abortSignal,
1604
1629
  onIteration: (_i, msg) => { onChunk(msg + '\n'); },
1605
1630
  onThinking: (text) => { onChunk(text); },
1606
- onRequestPermission: agentRun?.onRequestPermission,
1631
+ // Manual mode's dialog, or — in auto mode, where there is none — the
1632
+ // answer that mode gives. Passing nothing would leave the run with no way
1633
+ // to confirm a write to a file that decides what runs later, and the agent
1634
+ // refuses those rather than doing them unasked.
1635
+ onRequestPermission: agentRun?.onRequestPermission ?? agentRun?.onAutoModePermission,
1607
1636
  extraDangerousTools: agentRun?.extraDangerousTools,
1608
1637
  onExecuteCommand: agentRun?.onExecuteCommand,
1609
1638
  fs: agentRun?.fs,
@@ -10,9 +10,10 @@ import { loadCustomCommands } from '../utils/customCommands.js';
10
10
  import { registerSessionServers, disposeAllSessions as disposeAllMcpSessions } from '../utils/mcpRegistry.js';
11
11
  import { selectSessionMcpServers } from '../utils/mcpConfig.js';
12
12
  import { handleMcpSamplingRequest } from '../utils/mcpSamplingBridge.js';
13
- import { executeCommandAsync, validateCommandAsync } from '../utils/shell.js';
13
+ import { executeCommandAsync, validateCommandAsync, commandEnv } from '../utils/shell.js';
14
14
  import { checkCommandRateLimit } from '../utils/ratelimit.js';
15
15
  import { recordCommand } from '../utils/history.js';
16
+ import { trustBearingWrite } from '../utils/toolExecution.js';
16
17
  import { initWorkspace, loadWorkspace, handleCommand } from './commands.js';
17
18
  import { beginTurn } from './turns.js';
18
19
  import { handleSetMode as handleSetModeExternal, handleSetConfigOption as handleSetConfigOptionExternal, handleSessionList as handleSessionListExternal, handleSessionDelete as handleSessionDeleteExternal, handleListProviders as handleListProvidersExternal, } from './serverHandlers.js';
@@ -421,6 +422,158 @@ export function exitCodeFromWaitResult(result) {
421
422
  return 1;
422
423
  return null;
423
424
  }
425
+ /**
426
+ * `GIT_CONFIG_COUNT` and the `GIT_CONFIG_KEY_<n>` / `GIT_CONFIG_VALUE_<n>`
427
+ * pairs it counts — the numbered half of what hardenedGitEnv() produces.
428
+ */
429
+ const GIT_CONFIG_ENV_NAME = /^GIT_CONFIG_(?:COUNT|KEY_\d+|VALUE_\d+)$/;
430
+ /**
431
+ * The rest of what the terminal has to be given by name — see acpEnvList().
432
+ *
433
+ * `GIT_PAGER` and `GIT_TERMINAL_PROMPT` are hardenedGitEnv()'s two
434
+ * non-numbered outputs. `GIT_CONFIG_GLOBAL` / `GIT_CONFIG_SYSTEM` /
435
+ * `GIT_CONFIG_NOSYSTEM` are the variables the SCAN itself read the config
436
+ * through: leaving them behind would let the terminal's git resolve a
437
+ * different global config than the one Codeep just decided was safe.
438
+ *
439
+ * Everything after them is there for a client that REPLACES its environment
440
+ * with this list rather than extending it, and it is the half the first cut
441
+ * of this hotfix got wrong: the list was `PATH` and `HOME` — enough for the
442
+ * hardened `git status` it was written for — while acpEnvList() is applied
443
+ * to EVERY command handed to terminal/create. Against a replacing client
444
+ * that cost an ordinary command things that are not secrets and that it had
445
+ * in the user's own shell: `git push` over SSH had no agent socket to sign
446
+ * with and fell back to asking for a password on a terminal whose
447
+ * GIT_TERMINAL_PROMPT is `0`, which fails it outright; a test that sorts
448
+ * strings or formats a date ran under the C locale instead of the user's; a
449
+ * build had nowhere but the default /tmp to put its temporaries; anything
450
+ * behind a corporate proxy could not reach the network at all; and a
451
+ * toolchain installed under a version manager lost the variable its shim
452
+ * reads to pick a version.
453
+ *
454
+ * It stays an ALLOWLIST rather than becoming "process.env minus the names
455
+ * that look like credentials", because what made the change necessary is
456
+ * that `env` is not a private channel (see acpEnvList) and a name-shaped
457
+ * denylist does not recognise `DATABASE_URL`, a company's own `ACME_CREDS`,
458
+ * or anything else whose name does not say what it holds. A name gets in
459
+ * here only when it is known not to hold one.
460
+ *
461
+ * The one value below that CAN carry a credential is a proxy URL
462
+ * (`https_proxy=http://user:pass@proxy`), which is why redactCredentials()
463
+ * in src/acp/transport.ts has a rule for that exact shape. A command behind
464
+ * a proxy cannot reach the network without it.
465
+ */
466
+ const ACP_TERMINAL_ENV_NAMES = new Set([
467
+ // hardenedGitEnv()'s own, and what the scan read the config through.
468
+ 'GIT_PAGER',
469
+ 'GIT_TERMINAL_PROMPT',
470
+ 'GIT_CONFIG_GLOBAL',
471
+ 'GIT_CONFIG_SYSTEM',
472
+ 'GIT_CONFIG_NOSYSTEM',
473
+ // Where programs are found, and whose account runs them. No `PWD`: the
474
+ // terminal's working directory is the `cwd` of the terminal/create, and
475
+ // this process's would tell a shell script it is somewhere it is not.
476
+ 'PATH',
477
+ 'HOME',
478
+ 'SHELL',
479
+ 'USER',
480
+ 'LOGNAME',
481
+ // The agent socket `git push` and `git fetch` over SSH sign with.
482
+ 'SSH_AUTH_SOCK',
483
+ // Where a build puts its temporaries.
484
+ 'TMPDIR',
485
+ 'TMP',
486
+ 'TEMP',
487
+ // The locale a test that sorts strings or formats a date asserts against.
488
+ 'LANG',
489
+ 'LANGUAGE',
490
+ // What the command may draw with, and what it thinks the time is.
491
+ 'TERM',
492
+ 'COLORTERM',
493
+ 'TERM_PROGRAM',
494
+ 'TZ',
495
+ // Toolchains under a version manager: the shim is on PATH, but the shim
496
+ // reads one of these to find the version to run.
497
+ 'ASDF_DIR',
498
+ 'ASDF_DATA_DIR',
499
+ 'NVM_DIR',
500
+ 'NVM_BIN',
501
+ 'PYENV_ROOT',
502
+ 'RBENV_ROOT',
503
+ 'SDKMAN_DIR',
504
+ 'VOLTA_HOME',
505
+ 'PNPM_HOME',
506
+ 'BUN_INSTALL',
507
+ 'VIRTUAL_ENV',
508
+ 'CONDA_PREFIX',
509
+ 'CARGO_HOME',
510
+ 'RUSTUP_HOME',
511
+ 'GOPATH',
512
+ 'GOROOT',
513
+ 'JAVA_HOME',
514
+ ]);
515
+ /**
516
+ * The same allowlist for the two families whose members cannot be listed:
517
+ * the locale categories (`LC_ALL`, `LC_TIME`, `LC_COLLATE`, …), and the
518
+ * proxy variables, which every tool spells in whichever case it was written
519
+ * in — curl and most of Unix read the lowercase ones, Windows-born tools the
520
+ * uppercase, and a machine behind a proxy usually sets both.
521
+ */
522
+ const ACP_TERMINAL_ENV_FAMILY = /^(?:LC_[A-Z]+|(?:HTTP|HTTPS|FTP|ALL|NO)_PROXY|(?:http|https|ftp|all|no)_proxy)$/;
523
+ /**
524
+ * An environment in the shape `terminal/create` takes it: ACP spells it as a
525
+ * list of `{ name, value }`, not as the map Node keeps in `process.env`.
526
+ *
527
+ * An allowlist goes in it — the hardening's own variables and the shell
528
+ * essentials above — and not the WHOLE of `process.env`, which is what this
529
+ * used to serialise. `env` is not a private channel: src/acp/transport.ts
530
+ * mirrors every outbound frame verbatim into ~/.cache/codeep/acp-debug.log
531
+ * when CODEEP_ACP_DEBUG is set, so every `ANTHROPIC_API_KEY`,
532
+ * `GITHUB_TOKEN`, `AWS_SECRET_ACCESS_KEY` and session cookie in the user's
533
+ * shell was written to a plaintext file on disk — and handed to the editor,
534
+ * which is free to log the protocol traffic itself. None of them makes the
535
+ * hardening work; the numbered GIT_CONFIG_* pairs do.
536
+ *
537
+ * What else the terminal inherits is the CLIENT's decision, not ours: ACP
538
+ * does not say whether `env` extends the client's environment or replaces it.
539
+ * A client that extends gives the command the user's shell environment
540
+ * anyway; a client that replaces gives it only this list — which is why the
541
+ * list has to cover what an ORDINARY command needs to run at all and not
542
+ * only what a hardened git spawn does. A terminal that got `GIT_CONFIG_COUNT`
543
+ * and nothing else would be running without a PATH.
544
+ *
545
+ * Unset variables are dropped rather than sent as `value: undefined` — that
546
+ * is what `process.env` holds for a variable that is not set, and JSON has no
547
+ * way to carry it.
548
+ *
549
+ * `hardened` says whether commandEnv() actually hardened this spawn, which it
550
+ * does for `git` and for nothing else. It only decides the last entry below:
551
+ * a command that cannot reach git is left with the environment it would have
552
+ * had, which is the same line the refusal path draws.
553
+ */
554
+ function acpEnvList(env, hardened) {
555
+ const list = Object.entries(env)
556
+ .filter((entry) => typeof entry[1] === 'string')
557
+ .filter(([name]) => ACP_TERMINAL_ENV_NAMES.has(name)
558
+ || ACP_TERMINAL_ENV_FAMILY.test(name)
559
+ || GIT_CONFIG_ENV_NAME.test(name))
560
+ .map(([name, value]) => ({ name, value }));
561
+ // The one thing this list cannot express is a REMOVAL — an absent name is
562
+ // not a request to unset one — and hardenedGitEnv() removes exactly one
563
+ // variable, so it is sent EMPTY instead.
564
+ //
565
+ // `GIT_CONFIG_PARAMETERS` is read after the numbered `GIT_CONFIG_*` pairs
566
+ // and beats them: with `GIT_CONFIG_KEY_0=core.fsmonitor` and an empty value
567
+ // right there, a `GIT_CONFIG_PARAMETERS='core.fsmonitor=<program>'` still
568
+ // ran the program on `git status` (verified, git 2.54). So against a client
569
+ // that EXTENDS its own environment rather than replacing it, one variable
570
+ // the editor happened to inherit switched this whole hardening off. Git
571
+ // parses an empty value as no parameters at all (verified, same version),
572
+ // which is the unset this list has no other way to ask for.
573
+ if (hardened)
574
+ list.push({ name: 'GIT_CONFIG_PARAMETERS', value: '' });
575
+ return list;
576
+ }
424
577
  /**
425
578
  * Run an execute_command tool call for an ACP session, in the client's
426
579
  * terminal when it offers one, otherwise locally.
@@ -461,6 +614,33 @@ export async function executeAcpCommand(command, args, cwd, ctx) {
461
614
  // capability in initialize. Otherwise execute locally.
462
615
  if (!ctx.clientSupportsTerminal)
463
616
  return runLocally();
617
+ // The client's terminal is a spawn like any other, and it inherits none of
618
+ // the hardening executeCommandAsync puts on the local one — so over ACP,
619
+ // which is how Zed runs Codeep, a `git status` in a hostile repository ran
620
+ // that repository's `core.fsmonitor` and `filter.<d>.clean` exactly as it
621
+ // did before this hotfix. The validation above stops the argv forms that
622
+ // redirect git, but nothing was stopping its config.
623
+ //
624
+ // Same helper as the local runner, so there is one answer to "what does a
625
+ // spawn that may reach git run with" — see commandEnv(). A refusal fails
626
+ // the command with git's own wording rather than handing it to a terminal
627
+ // this process cannot harden.
628
+ //
629
+ // commandEnv() and NOT shellCommandEnv(): this call site has the argv, and
630
+ // shellCommandEnv() can only scan the spawn's `cwd`. That made one argument
631
+ // the whole difference between the two runners — `git -C vendor/lib status`
632
+ // over ACP got the outer project scanned, so the nested checkout was left
633
+ // with only the always-on GIT_EXECUTING_CONFIG pairs behind it and its
634
+ // `filter.<driver>.clean` still ran (proven, git 2.54), while the same
635
+ // command locally reads `-C` out of the argv and scans where git will
636
+ // actually run.
637
+ let env;
638
+ try {
639
+ env = commandEnv(command, args, cwd);
640
+ }
641
+ catch (error) {
642
+ return fail(error instanceof Error ? error.message : String(error));
643
+ }
464
644
  const { transport, sessionId, signal } = ctx;
465
645
  let terminalId;
466
646
  try {
@@ -469,6 +649,7 @@ export async function executeAcpCommand(command, args, cwd, ctx) {
469
649
  command,
470
650
  args,
471
651
  cwd,
652
+ env: acpEnvList(env, command === 'git'),
472
653
  outputByteLimit: 1_000_000,
473
654
  });
474
655
  if (!created || typeof created.terminalId !== 'string') {
@@ -1205,6 +1386,55 @@ export function startAcpServer(transport = new StdioTransport()) {
1205
1386
  // run the agent (/go, custom commands, skill agent steps) run it exactly
1206
1387
  // like a plain prompt.
1207
1388
  const manualMode = session.currentModeId === 'manual';
1389
+ // The one permission dialog this session puts in front of the user.
1390
+ const askAboutToolCall = async (toolCall,
1391
+ // What the agent gate already worked out about this call. Passed rather
1392
+ // than worked out again: trustBearingWrite() stats the path, resolves a
1393
+ // symlinked ancestor and may ask git where this repository keeps its
1394
+ // hooks. `null` is an answer ("writes no such file"); undefined means
1395
+ // the question came from somewhere that has not looked, which is the
1396
+ // only case that pays for the lookup here.
1397
+ known) => {
1398
+ // A write to a file that decides what runs later says so in the
1399
+ // dialog — the editor shows `toolInput`, and "this file controls what
1400
+ // commands git runs" is the part that makes the answer an informed one.
1401
+ const trustBearing = known !== undefined ? known : trustBearingWrite(toolCall, session.workspaceRoot);
1402
+ const result = await askUser({
1403
+ toolCallId: `perm_${randomUUID()}`,
1404
+ toolName: toolCall.tool,
1405
+ toolInput: {
1406
+ ...formatToolInputForPermission(toolCall.tool, toolCall.parameters),
1407
+ ...(trustBearing ? { warning: trustBearing.reason } : {}),
1408
+ },
1409
+ status: 'pending',
1410
+ content: [],
1411
+ }, [
1412
+ { optionId: 'allow_once', name: 'Allow once', kind: 'allow_once' },
1413
+ // No "always" for one of those files: the agent answers about this
1414
+ // file only and would not remember the answer anyway.
1415
+ ...(trustBearing ? [] : [{ optionId: 'allow_always', name: 'Allow always', kind: 'allow_always' }]),
1416
+ { optionId: 'reject_once', name: 'Reject once', kind: 'reject_once' },
1417
+ { optionId: 'reject_always', name: 'Reject always', kind: 'reject_always' },
1418
+ ]);
1419
+ // Map ACP outcome back to PermissionOutcome. No answer
1420
+ // (error, cancelled prompt) denies.
1421
+ if (!result || result.outcome.type === 'cancelled')
1422
+ return 'reject_once';
1423
+ return result.outcome.optionId;
1424
+ };
1425
+ // Auto mode's answer to the agent's permission gate: yes to everything
1426
+ // except a write to a file that decides what runs later, which is asked
1427
+ // about in every mode. Without it the agent would have to refuse those
1428
+ // writes outright, having nobody to ask. It travels under its own key on
1429
+ // `agentRun` and never as `onRequestPermission`: a slash command reads
1430
+ // that key being set as "this session asks the user" (see
1431
+ // acp/commands.ts), and auto mode still runs a skill's shell lines
1432
+ // without asking, as it promises.
1433
+ const autoModeAnswer = async (toolCall, known) => {
1434
+ const trustBearing = known !== undefined ? known : trustBearingWrite(toolCall, session.workspaceRoot);
1435
+ // Handed on, so the dialog does not look the same file up a third time.
1436
+ return trustBearing ? askAboutToolCall(toolCall, trustBearing) : 'allow_once';
1437
+ };
1208
1438
  const agentRun = {
1209
1439
  // Manual mode gates write_file/edit_file for this run only, per call —
1210
1440
  // NOT by mutating the global `agentConfirmWriteFile` config, which
@@ -1212,27 +1442,11 @@ export function startAcpServer(transport = new StdioTransport()) {
1212
1442
  // a non-atomic restore.
1213
1443
  extraDangerousTools: manualMode ? ['write_file', 'edit_file'] : undefined,
1214
1444
  // Only request permission in Manual mode
1215
- onRequestPermission: manualMode
1216
- ? async (toolCall) => {
1217
- const result = await askUser({
1218
- toolCallId: `perm_${randomUUID()}`,
1219
- toolName: toolCall.tool,
1220
- toolInput: formatToolInputForPermission(toolCall.tool, toolCall.parameters),
1221
- status: 'pending',
1222
- content: [],
1223
- }, [
1224
- { optionId: 'allow_once', name: 'Allow once', kind: 'allow_once' },
1225
- { optionId: 'allow_always', name: 'Allow always', kind: 'allow_always' },
1226
- { optionId: 'reject_once', name: 'Reject once', kind: 'reject_once' },
1227
- { optionId: 'reject_always', name: 'Reject always', kind: 'reject_always' },
1228
- ]);
1229
- // Map ACP outcome back to PermissionOutcome. No answer
1230
- // (error, cancelled prompt) denies.
1231
- if (!result || result.outcome.type === 'cancelled')
1232
- return 'reject_once';
1233
- return result.outcome.optionId;
1234
- }
1235
- : undefined,
1445
+ onRequestPermission: manualMode ? askAboutToolCall : undefined,
1446
+ // …and in auto mode, the answer a command that runs the agent uses in
1447
+ // its place, so /go and a skill's agent step get the same one prompt a
1448
+ // plain prompt gets instead of a refusal.
1449
+ onAutoModePermission: manualMode ? undefined : autoModeAnswer,
1236
1450
  // A skill's confirm step ("Deploy to production?") — a one-off
1237
1451
  // question, so no "always" answers.
1238
1452
  confirm: manualMode
@@ -1395,7 +1609,7 @@ export function startAcpServer(transport = new StdioTransport()) {
1395
1609
  }
1396
1610
  }
1397
1611
  },
1398
- onRequestPermission: agentRun.onRequestPermission,
1612
+ onRequestPermission: agentRun.onRequestPermission ?? agentRun.onAutoModePermission,
1399
1613
  extraDangerousTools: agentRun.extraDangerousTools,
1400
1614
  fs: agentRun.fs,
1401
1615
  onExecuteCommand: agentRun.onExecuteCommand,
@@ -1,7 +1,7 @@
1
1
  import { PermissionOutcome } from '../utils/agent.js';
2
2
  import { ProjectContext } from '../utils/project.js';
3
3
  import { ToolCall } from '../utils/tools.js';
4
- import type { FsCallbacks } from '../utils/toolExecution.js';
4
+ import type { FsCallbacks, TrustBearingWrite } from '../utils/toolExecution.js';
5
5
  import type { Message } from '../config/index.js';
6
6
  export interface AgentSessionOptions {
7
7
  prompt: string;
@@ -11,7 +11,18 @@ export interface AgentSessionOptions {
11
11
  onChunk: (text: string) => void;
12
12
  onThought?: (text: string) => void;
13
13
  onToolCall?: (toolCallId: string, toolName: string, kind: string, title: string, status: 'pending' | 'running' | 'finished' | 'error', locations?: string[], rawOutput?: string) => void;
14
- onRequestPermission?: (toolCall: ToolCall) => Promise<PermissionOutcome>;
14
+ /**
15
+ * Ask the user about one tool call. Handed straight to runAgent, so the
16
+ * signature is runAgent's: `trustBearing` is what the agent's own gate
17
+ * already worked out about the call — the file it would write that decides
18
+ * what runs later, or null when it writes no such file — and the dialog
19
+ * words itself from that instead of resolving the path a second time.
20
+ *
21
+ * Declared with one parameter, this type said the second argument did not
22
+ * exist while runAgent passed it on every call, so the one caller that
23
+ * needs it had to cast its way back to the truth.
24
+ */
25
+ onRequestPermission?: (toolCall: ToolCall, trustBearing?: TrustBearingWrite | null) => Promise<PermissionOutcome>;
15
26
  /** Tools to force into the per-run dangerous set (ACP manual mode). */
16
27
  extraDangerousTools?: string[];
17
28
  onExecuteCommand?: (command: string, args: string[], cwd: string) => Promise<{
@@ -1,4 +1,10 @@
1
1
  import { JsonRpcRequest, JsonRpcResponse, JsonRpcNotification } from './protocol.js';
2
+ /**
3
+ * A frame with its obvious credentials blanked.
4
+ *
5
+ * Exported for unit testing (see transport.test.ts).
6
+ */
7
+ export declare function redactCredentials(frame: string): string;
2
8
  type MessageHandler = (msg: JsonRpcRequest | JsonRpcNotification) => void | Promise<unknown>;
3
9
  export interface RequestOptions {
4
10
  /**
@@ -1,29 +1,124 @@
1
1
  // acp/transport.ts
2
2
  // Newline-delimited JSON-RPC over stdio
3
- import { appendFileSync, mkdirSync } from 'node:fs';
3
+ import { appendFileSync, chmodSync, mkdirSync, renameSync, statSync } from 'node:fs';
4
4
  import { homedir } from 'node:os';
5
5
  import { join, dirname } from 'node:path';
6
6
  // Debug log destination — when CODEEP_ACP_DEBUG is set we mirror every
7
7
  // inbound and outbound JSON-RPC frame here. Using a file (not stderr) because
8
8
  // most ACP clients (Zed included) do not pipe agent stderr to anywhere the
9
9
  // user can easily read; a known on-disk path is reliable everywhere.
10
+ //
11
+ // WHAT IS IN IT, because a user asked for it in a bug report will attach the
12
+ // whole file: every frame of the session. That is the prompts, the model's
13
+ // replies, the contents of every file read or written through fs/*, the
14
+ // commands run in the client's terminal and their output, and the `env` of
15
+ // every terminal/create. redactCredentials() blanks the obvious credential
16
+ // shapes on the way in, but it is a filter over text and not a guarantee — a
17
+ // secret that does not look like one survives it. So: session-private, 0600
18
+ // in a 0700 directory, and not something to paste anywhere unread.
10
19
  const ACP_DEBUG_PATH = process.env.CODEEP_ACP_DEBUG_FILE
11
20
  || join(homedir(), '.cache', 'codeep', 'acp-debug.log');
12
21
  const ACP_DEBUG = !!process.env.CODEEP_ACP_DEBUG;
22
+ /**
23
+ * Roll the log over at 8MB, keeping one previous file.
24
+ *
25
+ * It had no limit at all: every frame was appended and nothing ever truncated
26
+ * or removed the file, and a frame carries whole file contents and whole
27
+ * command outputs — so a user who left CODEEP_ACP_DEBUG set grew it until the
28
+ * disk stopped them. One previous file rather than a truncate because the
29
+ * frames that explain a broken session are usually the handshake at the top,
30
+ * which is exactly what a truncate throws away. Bounded at twice this, then.
31
+ */
32
+ const ACP_DEBUG_MAX_BYTES = 8 * 1024 * 1024;
13
33
  if (ACP_DEBUG) {
34
+ // 0700: the directory holds a file with the whole session in it.
14
35
  try {
15
- mkdirSync(dirname(ACP_DEBUG_PATH), { recursive: true });
36
+ mkdirSync(dirname(ACP_DEBUG_PATH), { recursive: true, mode: 0o700 });
16
37
  }
17
38
  catch { /* ignore */ }
18
39
  }
40
+ /** Bytes written so far, so the size check costs no syscall per frame. Null
41
+ * until the first write reads what an earlier run left on disk. */
42
+ let acpDebugBytes = null;
19
43
  function debugLog(direction, payload) {
20
44
  if (!ACP_DEBUG)
21
45
  return;
46
+ const line = `${new Date().toISOString()} [ACP${direction}client] ${redactCredentials(payload)}\n`;
47
+ const bytes = Buffer.byteLength(line);
22
48
  try {
23
- appendFileSync(ACP_DEBUG_PATH, `${new Date().toISOString()} [ACP${direction}client] ${payload}\n`);
49
+ if (acpDebugBytes === null)
50
+ acpDebugBytes = adoptExistingLog();
51
+ if (acpDebugBytes > 0 && acpDebugBytes + bytes > ACP_DEBUG_MAX_BYTES) {
52
+ renameSync(ACP_DEBUG_PATH, `${ACP_DEBUG_PATH}.1`);
53
+ acpDebugBytes = 0;
54
+ }
55
+ // `mode` applies only when the file is created, which after the rename
56
+ // above is every rollover as well as the first frame of the first run.
57
+ appendFileSync(ACP_DEBUG_PATH, line, { mode: 0o600 });
58
+ acpDebugBytes += bytes;
24
59
  }
25
60
  catch { /* swallow — never break the protocol over a logging failure */ }
26
61
  }
62
+ /** The size of the log already on disk, 0 when there is none. */
63
+ function adoptExistingLog() {
64
+ try {
65
+ const stat = statSync(ACP_DEBUG_PATH);
66
+ // A log this build did not create is one an older Codeep created 0644 —
67
+ // world-readable, with everything listed above in it. Tighten it, but
68
+ // only at our own path: CODEEP_ACP_DEBUG_FILE may name something whose
69
+ // mode is not ours to change (a fifo, a tty, a shared file).
70
+ if (!process.env.CODEEP_ACP_DEBUG_FILE && (stat.mode & 0o077) !== 0) {
71
+ chmodSync(ACP_DEBUG_PATH, 0o600);
72
+ }
73
+ return stat.size;
74
+ }
75
+ catch {
76
+ return 0;
77
+ }
78
+ }
79
+ /**
80
+ * Credential shapes blanked before a frame is mirrored to the debug log.
81
+ *
82
+ * The log exists to debug the protocol, so this is deliberately narrow: it
83
+ * blanks what is unmistakably a secret and leaves everything else readable.
84
+ * Matched on the frame TEXT rather than on a parsed object because an inbound
85
+ * frame is logged before it is parsed and may not be JSON at all.
86
+ *
87
+ * Nothing here changes the frame on the wire — only the copy on disk.
88
+ */
89
+ const ACP_DEBUG_REDACTIONS = [
90
+ // `"apiKey": "…"`, `"authorization": "…"` — the MEMBER NAME says it is a
91
+ // secret, whatever the value looks like.
92
+ [/("[A-Za-z0-9_.-]*(?:api[_-]?key|access[_-]?key|secret|token|password|passwd|credential|authorization|cookie|private[_-]?key)[A-Za-z0-9_.-]*"\s*:\s*)"(?:[^"\\]|\\.)*"/gi, '$1"[redacted]"'],
93
+ // ACP spells an environment as `{"name":…,"value":…}`, so the secret-looking
94
+ // string is the VALUE of `name` and the rule above cannot see it. This is
95
+ // the shape terminal/create used to leak the whole of process.env in.
96
+ [/("name"\s*:\s*"[A-Za-z0-9_.-]*(?:KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|AUTH|COOKIE)[A-Za-z0-9_.-]*"\s*,\s*"value"\s*:\s*)"(?:[^"\\]|\\.)*"/gi, '$1"[redacted]"'],
97
+ // And the shapes that are a credential wherever they turn up — a command
98
+ // line the agent ran, a terminal's own output, a file it read.
99
+ [/\bsk-[A-Za-z0-9_-]{16,}/g, '[redacted]'], // OpenAI / Anthropic
100
+ [/\bgh[pousr]_[A-Za-z0-9]{20,}/g, '[redacted]'], // GitHub
101
+ [/\bgithub_pat_[A-Za-z0-9_]{20,}/g, '[redacted]'],
102
+ [/\bglpat-[A-Za-z0-9_-]{16,}/g, '[redacted]'], // GitLab
103
+ [/\bxox[abprs]-[A-Za-z0-9-]{10,}/g, '[redacted]'], // Slack
104
+ [/\bAKIA[0-9A-Z]{16}\b/g, '[redacted]'], // AWS access key id
105
+ [/\bAIza[0-9A-Za-z_-]{20,}/g, '[redacted]'], // Google
106
+ [/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g, '[redacted]'], // JWT
107
+ [/\bBearer\s+[A-Za-z0-9._~+/-]{16,}={0,2}/gi, 'Bearer [redacted]'],
108
+ // `https://user:password@host` — keep the structure, drop the password.
109
+ [/((?:https?|ssh|git):\/\/[^\s"'/@]+:)[^\s"'/@]+@/g, '$1[redacted]@'],
110
+ ];
111
+ /**
112
+ * A frame with its obvious credentials blanked.
113
+ *
114
+ * Exported for unit testing (see transport.test.ts).
115
+ */
116
+ export function redactCredentials(frame) {
117
+ let out = frame;
118
+ for (const [pattern, replacement] of ACP_DEBUG_REDACTIONS)
119
+ out = out.replace(pattern, replacement);
120
+ return out;
121
+ }
27
122
  const MAX_BUFFER_SIZE = 10 * 1024 * 1024; // 10MB
28
123
  const REQUEST_TIMEOUT_MS = 30_000; // 30s
29
124
  /** The client answered one of our requests with a JSON-RPC error. */