codeep 3.3.3 → 3.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/acp/commands.d.ts +50 -1
- package/dist/acp/commands.js +545 -109
- package/dist/acp/protocol.d.ts +14 -5
- package/dist/acp/server.d.ts +36 -1
- package/dist/acp/server.js +581 -155
- package/dist/acp/serverHandlers.d.ts +2 -1
- package/dist/acp/serverHandlers.js +3 -0
- package/dist/acp/session.d.ts +28 -2
- package/dist/acp/session.js +25 -6
- package/dist/acp/transport.d.ts +40 -4
- package/dist/acp/transport.js +218 -25
- package/dist/acp/turns.d.ts +20 -0
- package/dist/acp/turns.js +30 -0
- package/dist/api/index.js +2 -0
- package/dist/api/ollamaNative.d.ts +3 -0
- package/dist/api/ollamaNative.js +35 -3
- package/dist/config/index.d.ts +21 -4
- package/dist/config/index.js +178 -123
- package/dist/renderer/agentExecution.d.ts +30 -2
- package/dist/renderer/agentExecution.js +248 -92
- package/dist/renderer/commands/helpers.d.ts +18 -2
- package/dist/renderer/commands/helpers.js +28 -5
- package/dist/renderer/commands.d.ts +2 -0
- package/dist/renderer/commands.js +180 -64
- package/dist/renderer/main.d.ts +41 -0
- package/dist/renderer/main.js +181 -80
- package/dist/utils/agent.d.ts +69 -4
- package/dist/utils/agent.js +416 -248
- package/dist/utils/agentChat.js +82 -10
- package/dist/utils/agents.d.ts +2 -1
- package/dist/utils/agents.js +100 -29
- package/dist/utils/auditLog.d.ts +4 -3
- package/dist/utils/auditLog.js +92 -9
- package/dist/utils/checkpoints.js +11 -6
- package/dist/utils/codeReview.js +28 -23
- package/dist/utils/codeepCloud.d.ts +14 -2
- package/dist/utils/codeepCloud.js +56 -20
- package/dist/utils/customCommands.js +7 -2
- package/dist/utils/git.d.ts +262 -4
- package/dist/utils/git.js +1928 -61
- package/dist/utils/gitHookInstaller.d.ts +32 -1
- package/dist/utils/gitHookInstaller.js +76 -8
- package/dist/utils/gitignore.d.ts +8 -0
- package/dist/utils/gitignore.js +41 -10
- package/dist/utils/headlessReview.d.ts +11 -0
- package/dist/utils/headlessReview.js +33 -5
- package/dist/utils/history.d.ts +22 -6
- package/dist/utils/history.js +140 -26
- package/dist/utils/logger.js +6 -7
- package/dist/utils/mcpConfig.d.ts +24 -0
- package/dist/utils/mcpConfig.js +36 -5
- package/dist/utils/mentions.d.ts +28 -5
- package/dist/utils/mentions.js +253 -45
- package/dist/utils/personalities.js +16 -6
- package/dist/utils/planMode.d.ts +13 -7
- package/dist/utils/planMode.js +32 -12
- package/dist/utils/projectIntelligence.d.ts +2 -0
- package/dist/utils/projectIntelligence.js +27 -8
- package/dist/utils/projectPaths.d.ts +53 -0
- package/dist/utils/projectPaths.js +146 -0
- package/dist/utils/shell.d.ts +119 -0
- package/dist/utils/shell.js +417 -45
- package/dist/utils/skillBundles.js +17 -7
- package/dist/utils/skillBundlesCloud.js +20 -3
- package/dist/utils/skills.d.ts +24 -2
- package/dist/utils/skills.js +235 -43
- package/dist/utils/smartContext.js +97 -23
- package/dist/utils/telegramApproval.d.ts +10 -2
- package/dist/utils/telegramApproval.js +22 -4
- package/dist/utils/toolExecution.d.ts +50 -2
- package/dist/utils/toolExecution.js +418 -16
- package/dist/utils/toolParsing.d.ts +7 -1
- package/dist/utils/toolParsing.js +12 -3
- package/dist/utils/userProfile.js +58 -16
- package/dist/utils/verify.d.ts +25 -4
- package/dist/utils/verify.js +259 -74
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/utils/planMode.d.ts
CHANGED
|
@@ -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
|
|
8
|
-
*
|
|
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
|
|
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
|
-
|
|
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 /
|
package/dist/utils/planMode.js
CHANGED
|
@@ -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
|
|
8
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
88
|
+
export function getPendingPlan(scope = DEFAULT_PLAN_SCOPE) {
|
|
89
|
+
return pendingByScope.get(scope) ?? null;
|
|
77
90
|
}
|
|
78
|
-
|
|
79
|
-
|
|
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,
|
|
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
|
-
|
|
118
|
-
|
|
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
|
+
}
|
package/dist/utils/shell.d.ts
CHANGED
|
@@ -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
|
*/
|