codeep 3.3.3 → 3.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/dist/acp/commands.d.ts +50 -1
  2. package/dist/acp/commands.js +545 -109
  3. package/dist/acp/protocol.d.ts +14 -5
  4. package/dist/acp/server.d.ts +36 -1
  5. package/dist/acp/server.js +581 -155
  6. package/dist/acp/serverHandlers.d.ts +2 -1
  7. package/dist/acp/serverHandlers.js +3 -0
  8. package/dist/acp/session.d.ts +28 -2
  9. package/dist/acp/session.js +25 -6
  10. package/dist/acp/transport.d.ts +40 -4
  11. package/dist/acp/transport.js +218 -25
  12. package/dist/acp/turns.d.ts +20 -0
  13. package/dist/acp/turns.js +30 -0
  14. package/dist/api/index.js +2 -0
  15. package/dist/api/ollamaNative.d.ts +3 -0
  16. package/dist/api/ollamaNative.js +35 -3
  17. package/dist/config/index.d.ts +21 -4
  18. package/dist/config/index.js +178 -123
  19. package/dist/renderer/agentExecution.d.ts +30 -2
  20. package/dist/renderer/agentExecution.js +248 -92
  21. package/dist/renderer/commands/helpers.d.ts +18 -2
  22. package/dist/renderer/commands/helpers.js +28 -5
  23. package/dist/renderer/commands.d.ts +2 -0
  24. package/dist/renderer/commands.js +180 -64
  25. package/dist/renderer/main.d.ts +41 -0
  26. package/dist/renderer/main.js +181 -80
  27. package/dist/utils/agent.d.ts +69 -4
  28. package/dist/utils/agent.js +416 -248
  29. package/dist/utils/agentChat.js +82 -10
  30. package/dist/utils/agents.d.ts +2 -1
  31. package/dist/utils/agents.js +100 -29
  32. package/dist/utils/auditLog.d.ts +4 -3
  33. package/dist/utils/auditLog.js +92 -9
  34. package/dist/utils/checkpoints.js +11 -6
  35. package/dist/utils/codeReview.js +28 -23
  36. package/dist/utils/codeepCloud.d.ts +14 -2
  37. package/dist/utils/codeepCloud.js +56 -20
  38. package/dist/utils/customCommands.js +7 -2
  39. package/dist/utils/git.d.ts +262 -4
  40. package/dist/utils/git.js +1928 -61
  41. package/dist/utils/gitHookInstaller.d.ts +32 -1
  42. package/dist/utils/gitHookInstaller.js +76 -8
  43. package/dist/utils/gitignore.d.ts +8 -0
  44. package/dist/utils/gitignore.js +41 -10
  45. package/dist/utils/headlessReview.d.ts +11 -0
  46. package/dist/utils/headlessReview.js +33 -5
  47. package/dist/utils/history.d.ts +22 -6
  48. package/dist/utils/history.js +140 -26
  49. package/dist/utils/logger.js +6 -7
  50. package/dist/utils/mcpConfig.d.ts +24 -0
  51. package/dist/utils/mcpConfig.js +36 -5
  52. package/dist/utils/mentions.d.ts +28 -5
  53. package/dist/utils/mentions.js +253 -45
  54. package/dist/utils/personalities.js +16 -6
  55. package/dist/utils/planMode.d.ts +13 -7
  56. package/dist/utils/planMode.js +32 -12
  57. package/dist/utils/projectIntelligence.d.ts +2 -0
  58. package/dist/utils/projectIntelligence.js +27 -8
  59. package/dist/utils/projectPaths.d.ts +53 -0
  60. package/dist/utils/projectPaths.js +146 -0
  61. package/dist/utils/shell.d.ts +119 -0
  62. package/dist/utils/shell.js +417 -45
  63. package/dist/utils/skillBundles.js +17 -7
  64. package/dist/utils/skillBundlesCloud.js +20 -3
  65. package/dist/utils/skills.d.ts +24 -2
  66. package/dist/utils/skills.js +235 -43
  67. package/dist/utils/smartContext.js +97 -23
  68. package/dist/utils/telegramApproval.d.ts +10 -2
  69. package/dist/utils/telegramApproval.js +22 -4
  70. package/dist/utils/toolExecution.d.ts +50 -2
  71. package/dist/utils/toolExecution.js +418 -16
  72. package/dist/utils/toolParsing.d.ts +7 -1
  73. package/dist/utils/toolParsing.js +12 -3
  74. package/dist/utils/userProfile.js +58 -16
  75. package/dist/utils/verify.d.ts +25 -4
  76. package/dist/utils/verify.js +259 -74
  77. package/dist/version.d.ts +1 -1
  78. package/dist/version.js +1 -1
  79. package/package.json +1 -1
@@ -4,8 +4,9 @@
4
4
  * Flow:
5
5
  * 1. User runs `/plan <task>` — we ask the LLM for a numbered plan
6
6
  * (no tool calls, no file changes) and surface it to the user.
7
- * 2. We hold the (task, plan) pair as the *pending* plan, scoped to
8
- * the current process.
7
+ * 2. We hold the (task, plan) pair as the *pending* plan. Each
8
+ * conversation has its own: callers that serve several at once (ACP
9
+ * threads) pass a scope, and everything else shares the default one.
9
10
  * 3. User runs `/go` to execute, or `/plan <revised task>` to refine.
10
11
  * `/go` hands the original task + approved plan to the regular
11
12
  * agent loop as a single prompt, so the existing tool execution,
@@ -27,14 +28,19 @@ export interface PendingPlan {
27
28
  plan: string;
28
29
  createdAt: number;
29
30
  }
31
+ /** The scope of callers that hold one conversation per process (the TUI). */
32
+ export declare const DEFAULT_PLAN_SCOPE = "default";
30
33
  /**
31
34
  * Ask the model for a plan for the given task. Stores the (task, plan)
32
- * pair as the pending plan so a subsequent `/go` can execute it.
33
- * Throws on chat failure — caller renders the error.
35
+ * pair as the pending plan of `scope` so a subsequent `/go` there can
36
+ * execute it. Throws on chat failure — caller renders the error.
37
+ * `abortSignal` cancels the request; a cancelled plan is not stored.
34
38
  */
35
- export declare function generatePlan(task: string, onChunk?: (text: string) => void): Promise<string>;
36
- export declare function getPendingPlan(): PendingPlan | null;
37
- export declare function clearPendingPlan(): void;
39
+ export declare function generatePlan(task: string, onChunk?: (text: string) => void, scope?: string, abortSignal?: AbortSignal): Promise<string>;
40
+ export declare function getPendingPlan(scope?: string): PendingPlan | null;
41
+ /** Replace the pending plan of `scope`; `null` clears it. */
42
+ export declare function setPendingPlan(plan: PendingPlan | null, scope?: string): void;
43
+ export declare function clearPendingPlan(scope?: string): void;
38
44
  /**
39
45
  * Compose the prompt the agent loop receives when the user runs `/go`.
40
46
  * The agent treats this as a normal task, so tool calls / verification /
@@ -4,8 +4,9 @@
4
4
  * Flow:
5
5
  * 1. User runs `/plan <task>` — we ask the LLM for a numbered plan
6
6
  * (no tool calls, no file changes) and surface it to the user.
7
- * 2. We hold the (task, plan) pair as the *pending* plan, scoped to
8
- * the current process.
7
+ * 2. We hold the (task, plan) pair as the *pending* plan. Each
8
+ * conversation has its own: callers that serve several at once (ACP
9
+ * threads) pass a scope, and everything else shares the default one.
9
10
  * 3. User runs `/go` to execute, or `/plan <revised task>` to refine.
10
11
  * `/go` hands the original task + approved plan to the regular
11
12
  * agent loop as a single prompt, so the existing tool execution,
@@ -58,25 +59,44 @@ Rules:
58
59
  itself. Code generation belongs in execution, not planning.
59
60
  - If the task is trivial (single-file rename, single-line edit), say so
60
61
  in one sentence and skip the formal plan — don't bloat tiny work.`;
61
- let pending = null;
62
+ /** The scope of callers that hold one conversation per process (the TUI). */
63
+ export const DEFAULT_PLAN_SCOPE = 'default';
64
+ // One pending plan per conversation. A process-wide slot let /go in one ACP
65
+ // thread run the plan another thread had just made. Plans are returned as
66
+ // stored, not copied: callers tell plans apart by identity.
67
+ const pendingByScope = new Map();
62
68
  /**
63
69
  * Ask the model for a plan for the given task. Stores the (task, plan)
64
- * pair as the pending plan so a subsequent `/go` can execute it.
65
- * Throws on chat failure — caller renders the error.
70
+ * pair as the pending plan of `scope` so a subsequent `/go` there can
71
+ * execute it. Throws on chat failure — caller renders the error.
72
+ * `abortSignal` cancels the request; a cancelled plan is not stored.
66
73
  */
67
- export async function generatePlan(task, onChunk) {
74
+ export async function generatePlan(task, onChunk, scope = DEFAULT_PLAN_SCOPE, abortSignal) {
68
75
  const history = [
69
76
  { role: 'system', content: PLAN_SYSTEM_PROMPT },
70
77
  ];
71
- const plan = await chat(task, history, onChunk);
72
- pending = { task, plan, createdAt: Date.now() };
78
+ const plan = await chat(task, history, onChunk, undefined, undefined, abortSignal);
79
+ // A reply that arrives after the cancel is not a plan the user can /go.
80
+ if (abortSignal?.aborted) {
81
+ const abortError = new Error('Plan generation cancelled');
82
+ abortError.name = 'AbortError';
83
+ throw abortError;
84
+ }
85
+ pendingByScope.set(scope, { task, plan, createdAt: Date.now() });
73
86
  return plan;
74
87
  }
75
- export function getPendingPlan() {
76
- return pending;
88
+ export function getPendingPlan(scope = DEFAULT_PLAN_SCOPE) {
89
+ return pendingByScope.get(scope) ?? null;
77
90
  }
78
- export function clearPendingPlan() {
79
- pending = null;
91
+ /** Replace the pending plan of `scope`; `null` clears it. */
92
+ export function setPendingPlan(plan, scope = DEFAULT_PLAN_SCOPE) {
93
+ if (plan)
94
+ pendingByScope.set(scope, plan);
95
+ else
96
+ pendingByScope.delete(scope);
97
+ }
98
+ export function clearPendingPlan(scope = DEFAULT_PLAN_SCOPE) {
99
+ pendingByScope.delete(scope);
80
100
  }
81
101
  /**
82
102
  * Compose the prompt the agent loop receives when the user runs `/go`.
@@ -52,6 +52,8 @@ export interface ProjectIntelligence {
52
52
  * Scan project and generate intelligence
53
53
  */
54
54
  export declare function scanProject(projectPath: string): Promise<ProjectIntelligence>;
55
+ /** What to tell the user when saveProjectIntelligence returns false. */
56
+ export declare const INTELLIGENCE_NOT_SAVED = "Not saved: .codeep/intelligence.json could not be written (is .codeep a symlink, or read-only?).";
55
57
  /**
56
58
  * Save intelligence to .codeep/intelligence.json
57
59
  */
@@ -2,7 +2,8 @@
2
2
  * Project Intelligence - Deep project analysis and caching
3
3
  * Scans project once and caches important information for faster AI context
4
4
  */
5
- import { existsSync, readFileSync, writeFileSync, mkdirSync, statSync, readdirSync } from 'fs';
5
+ import { existsSync, readFileSync, statSync, readdirSync } from 'fs';
6
+ import { writeProjectFile } from './projectPaths.js';
6
7
  import { join, basename, extname, relative } from 'path';
7
8
  import { loadIgnoreRules, isIgnored } from './gitignore.js';
8
9
  // ============================================================================
@@ -46,6 +47,24 @@ const FRAMEWORK_INDICATORS = {
46
47
  // ============================================================================
47
48
  // Main Functions
48
49
  // ============================================================================
50
+ /**
51
+ * Notes from an existing intelligence file. Parsed directly rather than through
52
+ * loadProjectIntelligence, which returns null for an older schema version —
53
+ * the rescan that follows a version bump would otherwise drop every note.
54
+ */
55
+ function readSavedNotes(projectPath) {
56
+ try {
57
+ const filePath = join(projectPath, '.codeep', INTELLIGENCE_FILE);
58
+ if (!existsSync(filePath))
59
+ return [];
60
+ const data = JSON.parse(readFileSync(filePath, 'utf-8'));
61
+ const notes = data && typeof data === 'object' ? data.notes : undefined;
62
+ return Array.isArray(notes) ? notes.filter((n) => typeof n === 'string') : [];
63
+ }
64
+ catch {
65
+ return [];
66
+ }
67
+ }
49
68
  /**
50
69
  * Scan project and generate intelligence
51
70
  */
@@ -89,7 +108,9 @@ export async function scanProject(projectPath) {
89
108
  testDirectory: null,
90
109
  hasTests: false,
91
110
  },
92
- notes: [],
111
+ // /memory notes live in the same file a rescan overwrites; they are the
112
+ // user's, not something a scan can rediscover
113
+ notes: readSavedNotes(projectPath),
93
114
  };
94
115
  // Load .gitignore rules once — used by scan and endpoint detection
95
116
  const ignoreRules = loadIgnoreRules(projectPath);
@@ -109,17 +130,15 @@ export async function scanProject(projectPath) {
109
130
  detectTesting(projectPath, intelligence);
110
131
  return intelligence;
111
132
  }
133
+ /** What to tell the user when saveProjectIntelligence returns false. */
134
+ export const INTELLIGENCE_NOT_SAVED = 'Not saved: .codeep/intelligence.json could not be written (is .codeep a symlink, or read-only?).';
112
135
  /**
113
136
  * Save intelligence to .codeep/intelligence.json
114
137
  */
115
138
  export function saveProjectIntelligence(projectPath, intelligence) {
116
139
  try {
117
- const codeepDir = join(projectPath, '.codeep');
118
- if (!existsSync(codeepDir)) {
119
- mkdirSync(codeepDir, { recursive: true });
120
- }
121
- const filePath = join(codeepDir, INTELLIGENCE_FILE);
122
- writeFileSync(filePath, JSON.stringify(intelligence, null, 2));
140
+ // .codeep/ can come with a cloned repo: never write through a symlink.
141
+ writeProjectFile(projectPath, join(projectPath, '.codeep', INTELLIGENCE_FILE), JSON.stringify(intelligence, null, 2));
123
142
  return true;
124
143
  }
125
144
  catch {
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Guards for files Codeep writes inside a project.
3
+ *
4
+ * A project's `.codeep/` directory usually arrives with a cloned repo, so the
5
+ * repo decides what its entries are. writeFileSync and mkdirSync follow
6
+ * symlinks: a committed `.codeep/progress.md -> ~/.zshrc`, or a symlinked
7
+ * `.codeep/` itself, would have Codeep overwrite, append to or delete files the
8
+ * user never pointed it at.
9
+ */
10
+ export declare class UnsafeProjectPathError extends Error {
11
+ readonly path: string;
12
+ constructor(path: string);
13
+ }
14
+ /**
15
+ * True when `dirPath` is a real directory tree under `projectRoot`: it lies
16
+ * inside the root by name, and every part of it that exists — itself
17
+ * included — is a directory, not a symlink to one. Missing parts are fine:
18
+ * mkdirSync creates real directories.
19
+ *
20
+ * The root itself may be a symlink; the user chose to open it.
21
+ */
22
+ export declare function isSafeProjectDir(projectRoot: string, dirPath: string): boolean;
23
+ /**
24
+ * True when writing `filePath` lands exactly where the path says: the file
25
+ * lies inside `projectRoot`, no existing directory between the root and the
26
+ * file is a symlink, and the file itself — if it exists — is a regular file,
27
+ * not a symlink.
28
+ */
29
+ export declare function isSafeProjectWriteTarget(projectRoot: string, filePath: string): boolean;
30
+ /** Create `dirPath` under `projectRoot`, refusing a path through a symlink. */
31
+ export declare function ensureProjectDir(projectRoot: string, dirPath: string): void;
32
+ /** Write a file under `projectRoot`, creating its directory, never through a symlink. */
33
+ export declare function writeProjectFile(projectRoot: string, filePath: string, data: string): void;
34
+ /** Append to a file under `projectRoot`, creating its directory, never through a symlink. */
35
+ export declare function appendProjectFile(projectRoot: string, filePath: string, data: string): void;
36
+ /**
37
+ * writeFileSync that refuses a symlink as the file itself. For files whose
38
+ * directory was already checked (or is the user's own), where only the last
39
+ * component can still be a link.
40
+ */
41
+ export declare function writeFileNoFollow(filePath: string, data: string): void;
42
+ /**
43
+ * A notice for a project whose `.codeep/` (or its sessions directory) is a
44
+ * symlink, or null. Codeep does not write through it, so without this the
45
+ * user would find their sessions, notes and logs silently not saved there.
46
+ */
47
+ export declare function symlinkedCodeepNotice(projectRoot: string): string | null;
48
+ /**
49
+ * True when `filePath` exists and, with symlinks resolved, lies outside the
50
+ * (resolved) project root — a committed `CODEEP.md -> ~/.aws/credentials`.
51
+ * A path that does not resolve is not judged here; reading it fails on its own.
52
+ */
53
+ export declare function leadsOutsideProject(filePath: string, projectRoot: string): boolean;
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Guards for files Codeep writes inside a project.
3
+ *
4
+ * A project's `.codeep/` directory usually arrives with a cloned repo, so the
5
+ * repo decides what its entries are. writeFileSync and mkdirSync follow
6
+ * symlinks: a committed `.codeep/progress.md -> ~/.zshrc`, or a symlinked
7
+ * `.codeep/` itself, would have Codeep overwrite, append to or delete files the
8
+ * user never pointed it at.
9
+ */
10
+ import { closeSync, constants, lstatSync, mkdirSync, openSync, realpathSync, writeFileSync, } from 'fs';
11
+ import { dirname, join, relative, resolve, isAbsolute, sep } from 'path';
12
+ export class UnsafeProjectPathError extends Error {
13
+ path;
14
+ constructor(path) {
15
+ super(`Refusing to write ${path}: it is a symlink or lies under one`);
16
+ this.path = path;
17
+ this.name = 'UnsafeProjectPathError';
18
+ }
19
+ }
20
+ /** True if `target` lies strictly inside `root` by name. */
21
+ function insideByName(root, target) {
22
+ const rel = relative(root, target);
23
+ return !!rel && rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
24
+ }
25
+ /**
26
+ * True when `dirPath` is a real directory tree under `projectRoot`: it lies
27
+ * inside the root by name, and every part of it that exists — itself
28
+ * included — is a directory, not a symlink to one. Missing parts are fine:
29
+ * mkdirSync creates real directories.
30
+ *
31
+ * The root itself may be a symlink; the user chose to open it.
32
+ */
33
+ export function isSafeProjectDir(projectRoot, dirPath) {
34
+ const root = resolve(projectRoot);
35
+ const dir = resolve(dirPath);
36
+ if (!insideByName(root, dir))
37
+ return false;
38
+ // Stops at the filesystem root too, so a caller that skips the check above
39
+ // can never spin on dirname('/') === '/'.
40
+ for (let d = dir; d !== root && d !== dirname(d); d = dirname(d)) {
41
+ const st = lstatSync(d, { throwIfNoEntry: false });
42
+ if (st && !st.isDirectory())
43
+ return false; // a symlinked directory is not a directory to lstat
44
+ }
45
+ return true;
46
+ }
47
+ /**
48
+ * True when writing `filePath` lands exactly where the path says: the file
49
+ * lies inside `projectRoot`, no existing directory between the root and the
50
+ * file is a symlink, and the file itself — if it exists — is a regular file,
51
+ * not a symlink.
52
+ */
53
+ export function isSafeProjectWriteTarget(projectRoot, filePath) {
54
+ const root = resolve(projectRoot);
55
+ const target = resolve(filePath);
56
+ if (!insideByName(root, target))
57
+ return false;
58
+ const final = lstatSync(target, { throwIfNoEntry: false });
59
+ if (final && !final.isFile())
60
+ return false; // symlinks, directories, devices, FIFOs
61
+ const dir = dirname(target);
62
+ return dir === root || isSafeProjectDir(root, dir);
63
+ }
64
+ /** Create `dirPath` under `projectRoot`, refusing a path through a symlink. */
65
+ export function ensureProjectDir(projectRoot, dirPath) {
66
+ if (!isSafeProjectDir(projectRoot, dirPath))
67
+ throw new UnsafeProjectPathError(dirPath);
68
+ mkdirSync(dirPath, { recursive: true });
69
+ }
70
+ // O_NOFOLLOW makes the open itself fail on a symlinked last component, so
71
+ // nothing can swap one in between the check and the write. Windows has no
72
+ // such flag; there the lstat check in isSafeProjectWriteTarget is what holds.
73
+ const NOFOLLOW = constants.O_NOFOLLOW ?? 0;
74
+ const WRITE_FLAGS = constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC | NOFOLLOW;
75
+ const APPEND_FLAGS = constants.O_WRONLY | constants.O_CREAT | constants.O_APPEND | NOFOLLOW;
76
+ function checkedTarget(projectRoot, filePath) {
77
+ const dir = dirname(resolve(filePath));
78
+ if (dir !== resolve(projectRoot))
79
+ ensureProjectDir(projectRoot, dir);
80
+ if (!isSafeProjectWriteTarget(projectRoot, filePath))
81
+ throw new UnsafeProjectPathError(filePath);
82
+ }
83
+ function writeThroughFd(filePath, data, flags) {
84
+ const fd = openSync(filePath, flags, 0o644);
85
+ try {
86
+ writeFileSync(fd, data, 'utf-8');
87
+ }
88
+ finally {
89
+ closeSync(fd);
90
+ }
91
+ }
92
+ /** Write a file under `projectRoot`, creating its directory, never through a symlink. */
93
+ export function writeProjectFile(projectRoot, filePath, data) {
94
+ checkedTarget(projectRoot, filePath);
95
+ writeThroughFd(filePath, data, WRITE_FLAGS);
96
+ }
97
+ /** Append to a file under `projectRoot`, creating its directory, never through a symlink. */
98
+ export function appendProjectFile(projectRoot, filePath, data) {
99
+ checkedTarget(projectRoot, filePath);
100
+ writeThroughFd(filePath, data, APPEND_FLAGS);
101
+ }
102
+ /**
103
+ * writeFileSync that refuses a symlink as the file itself. For files whose
104
+ * directory was already checked (or is the user's own), where only the last
105
+ * component can still be a link.
106
+ */
107
+ export function writeFileNoFollow(filePath, data) {
108
+ const st = lstatSync(filePath, { throwIfNoEntry: false });
109
+ if (st && !st.isFile())
110
+ throw new UnsafeProjectPathError(filePath);
111
+ writeThroughFd(filePath, data, WRITE_FLAGS);
112
+ }
113
+ /**
114
+ * A notice for a project whose `.codeep/` (or its sessions directory) is a
115
+ * symlink, or null. Codeep does not write through it, so without this the
116
+ * user would find their sessions, notes and logs silently not saved there.
117
+ */
118
+ export function symlinkedCodeepNotice(projectRoot) {
119
+ const codeep = lstatSync(join(projectRoot, '.codeep'), { throwIfNoEntry: false });
120
+ if (codeep?.isSymbolicLink()) {
121
+ return '.codeep in this project is a symlink, so Codeep does not write into it: sessions are saved in ~/.codeep/sessions, and project notes, the progress log and the audit log are not written.';
122
+ }
123
+ const sessions = lstatSync(join(projectRoot, '.codeep', 'sessions'), { throwIfNoEntry: false });
124
+ if (sessions?.isSymbolicLink()) {
125
+ return '.codeep/sessions in this project is a symlink, so sessions are saved in ~/.codeep/sessions instead.';
126
+ }
127
+ return null;
128
+ }
129
+ /**
130
+ * True when `filePath` exists and, with symlinks resolved, lies outside the
131
+ * (resolved) project root — a committed `CODEEP.md -> ~/.aws/credentials`.
132
+ * A path that does not resolve is not judged here; reading it fails on its own.
133
+ */
134
+ export function leadsOutsideProject(filePath, projectRoot) {
135
+ let real;
136
+ let realRoot;
137
+ try {
138
+ real = realpathSync(filePath);
139
+ realRoot = realpathSync(projectRoot);
140
+ }
141
+ catch {
142
+ return false;
143
+ }
144
+ const rel = relative(realRoot, real);
145
+ return rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel);
146
+ }
@@ -9,12 +9,23 @@ export interface CommandResult {
9
9
  duration: number;
10
10
  command: string;
11
11
  args: string[];
12
+ /** Set when the command was stopped through `CommandOptions.signal`. */
13
+ cancelled?: boolean;
14
+ /** Set when the command was killed at `CommandOptions.timeout`. Its partial
15
+ * stdout and stderr are kept; the timeout note follows the stderr. */
16
+ timedOut?: boolean;
12
17
  }
13
18
  export interface CommandOptions {
14
19
  cwd?: string;
15
20
  timeout?: number;
16
21
  env?: Record<string, string>;
17
22
  projectRoot?: string;
23
+ /**
24
+ * Stops the command when it fires: the child is killed and the result
25
+ * comes back at once with `cancelled: true`. Honoured by
26
+ * executeCommandAsync only — the sync runner cannot be interrupted.
27
+ */
28
+ signal?: AbortSignal;
18
29
  }
19
30
  /**
20
31
  * Validate if a command is safe to execute (synchronous checks).
@@ -24,6 +35,114 @@ export declare function validateCommand(command: string, args: string[], options
24
35
  valid: boolean;
25
36
  reason?: string;
26
37
  };
38
+ /**
39
+ * The environment a validated command runs in.
40
+ *
41
+ * `git` is on ALLOWED_COMMANDS, so a skill's shell line, a `!` command or the
42
+ * agent's own execute_command reaches git with whatever the repository put in
43
+ * its `.git/config` — and several of those settings make git RUN a program:
44
+ * a `filter.<driver>.clean` fires during the index refresh `git status` does,
45
+ * before anything looks like it executed code. Route git through the same
46
+ * hardening Codeep's own git calls use.
47
+ *
48
+ * Hooks are deliberately left alone here. The command was approved as
49
+ * written, so `git commit` through this path runs the repository's
50
+ * pre-commit hook exactly as it would in the user's terminal.
51
+ *
52
+ * A caller's own `env` goes in as the BASE rather than on top of the result:
53
+ * spread afterwards, their GIT_CONFIG_COUNT would replace ours and silently
54
+ * drop every override above their count.
55
+ *
56
+ * The bare name is the whole test because it has to be: validateCommand()
57
+ * only lets a command through when ALLOWED_COMMANDS holds it, and that set
58
+ * holds `git`, not `/usr/bin/git`. A path-spelled git never reaches here.
59
+ *
60
+ * The directory scanned comes from the ARGV, not from the spawn's cwd: `git
61
+ * -C vendor/lib status` reads the vendored checkout's config, so that is the
62
+ * config that has to be neutralised. The argv forms that redirect git
63
+ * somewhere this cannot follow (`--git-dir`, `--work-tree`, `--exec-path`,
64
+ * `--config-env`) never get here — validateCommand() refuses them.
65
+ *
66
+ * Throws `GitHardeningError` when the repository's config cannot be scanned —
67
+ * both runners below turn that into a failed CommandResult, because a refusal
68
+ * is this command's own failure and the user reads it as such.
69
+ *
70
+ * EXPORTED, and this signature is the contract, because the ACP terminal
71
+ * path spawns its own children and has to harden the SAME repository this
72
+ * does. Call it with the parsed command, its argv, the cwd the spawn will
73
+ * get and the caller's own env in `options.env`, and hand the result to the
74
+ * spawn as `env` — do not spread anything over it, or a later
75
+ * GIT_CONFIG_COUNT replaces ours and silently drops every override above it.
76
+ * The argv is not optional there: `git -C vendor/lib status` scans
77
+ * `vendor/lib`, and a caller that passes only the cwd hardens the wrong
78
+ * repository. A shell LINE rather than an argv belongs to shellCommandEnv()
79
+ * below instead. Both throw, and a refusal that escapes a promise executor
80
+ * never settles it.
81
+ */
82
+ export declare function commandEnv(command: string, args: string[], cwd: string, options?: CommandOptions): NodeJS.ProcessEnv;
83
+ /**
84
+ * The environment for a whole SHELL COMMAND LINE that may reach git.
85
+ *
86
+ * commandEnv() above can check a parsed binary name; a line handed to a shell
87
+ * can reach git from anywhere inside it — `cd sub && git status`, `make && git
88
+ * commit`, `foo | git apply` — so it needs its own entry point. This is that
89
+ * entry point for the callers that spawn with `shell: true`: the skill runner
90
+ * in src/acp/commands.ts and the one in src/renderer/agentExecution.ts, both
91
+ * of which used to reach git raw. A hostile `gpg.program` that createCommit
92
+ * neutralises still executed through those two spawns (proven, git 2.54).
93
+ *
94
+ * This is the ONE helper for that job — an earlier cut of this hotfix also
95
+ * had a `hardenedShellEnv()` in utils/toolExecution.ts, which hardened every
96
+ * skill step unconditionally and therefore refused an `echo` in a repository
97
+ * whose config cannot be scanned. Keep it one: two helpers with two different
98
+ * answers to "does a refusal stop this line?" is how one of them ends up
99
+ * wrong and unused.
100
+ *
101
+ * The contract, since those two call sites are not this file's to edit:
102
+ *
103
+ * - Pass the command line, the cwd the shell will get and any env of your
104
+ * own, and hand the RESULT to the spawn as `env`. Do not spread anything
105
+ * over it — a later `GIT_CONFIG_COUNT` replaces ours and silently drops
106
+ * every override above it.
107
+ * - It THROWS `GitHardeningError` when the repository's config cannot be
108
+ * scanned, or names a program no override can switch off. Catch it and fail
109
+ * the command with `error.message`, which is written for the user. Letting
110
+ * it escape a `spawnSync` call site turns a refusal into a crash; letting
111
+ * it escape inside a promise executor leaves the caller hanging.
112
+ * - Hooks are left alone, as they are for executeCommand(): the line was
113
+ * approved as written, so `git commit` in it runs the repository's
114
+ * pre-commit hook exactly as it would in the user's terminal.
115
+ * - A line that cannot reach git comes back unhardened, so a repository with
116
+ * an unreadable config does not also break `echo`. That is also why a
117
+ * refusal never reaches a non-git line: an `echo` must not stop working
118
+ * because some repository in the project sets `remote.origin.uploadpack`.
119
+ *
120
+ * WHAT THIS CAN AND CANNOT PROMISE, because a shell line is not an argv:
121
+ *
122
+ * - Scanned: the repository at `cwd`, AND every submodule of it — the ones
123
+ * its index records as gitlinks and the ones its config records by name,
124
+ * wherever each keeps its git directory (see listSubmoduleConfig in
125
+ * utils/git.ts). Every key in REPO_EXECUTING_RULES
126
+ * that any of them sets is neutralised, and because the overrides ride in
127
+ * the ENVIRONMENT rather than in an argv, they apply wherever in the line
128
+ * git ends up — so `cd vendor/lib && git add` is covered in full when
129
+ * `vendor/lib` is a submodule, which is the shape a skill step usually has.
130
+ * - Not scanned: a repository that is not `cwd` and not one of its
131
+ * submodules — an independent checkout under `vendor/`, a sibling clone,
132
+ * anywhere a `make` target cds to. There is no way to know where a shell
133
+ * line ends up without running it, so this does not pretend to. What still
134
+ * covers those is the always-on GIT_EXECUTING_CONFIG layer, which is why
135
+ * `core.fsmonitor` is blanket there rather than scope-aware. The gap is the
136
+ * keys GIT_CONFIG_* cannot wildcard — `filter.*` above all — in an
137
+ * unrelated repository below the one scanned. Proven with git 2.54: `cd
138
+ * vendor/lib && git status`, with `vendor/lib` a plain nested clone rather
139
+ * than a submodule, did not run the nested `core.fsmonitor` and did run the
140
+ * nested `filter.<d>.clean`.
141
+ * - executeCommand()'s argv path has no such gap: it reads `-C` out of the
142
+ * argv and scans where git will actually run, and refuses `--git-dir` /
143
+ * `--work-tree` / `--exec-path` / `--config-env` outright.
144
+ */
145
+ export declare function shellCommandEnv(commandLine: string, cwd: string, env?: Record<string, string>): NodeJS.ProcessEnv;
27
146
  /**
28
147
  * Execute a shell command with safety checks
29
148
  */