codeep 3.4.0 → 3.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/acp/commands.d.ts +15 -0
- package/dist/acp/commands.js +30 -1
- package/dist/acp/server.js +237 -23
- package/dist/acp/session.d.ts +13 -2
- package/dist/acp/transport.d.ts +6 -0
- package/dist/acp/transport.js +98 -3
- package/dist/renderer/agentExecution.js +116 -69
- package/dist/renderer/commands.js +19 -3
- package/dist/renderer/main.d.ts +24 -0
- package/dist/renderer/main.js +57 -2
- package/dist/utils/agent.d.ts +17 -2
- package/dist/utils/agent.js +62 -5
- package/dist/utils/checkpoints.js +3 -0
- package/dist/utils/codeReview.js +28 -23
- package/dist/utils/git.d.ts +262 -4
- package/dist/utils/git.js +1928 -61
- package/dist/utils/gitHookInstaller.d.ts +32 -1
- package/dist/utils/gitHookInstaller.js +76 -8
- package/dist/utils/headlessReview.js +26 -5
- package/dist/utils/shell.d.ts +108 -0
- package/dist/utils/shell.js +364 -5
- package/dist/utils/telegramApproval.d.ts +10 -2
- package/dist/utils/telegramApproval.js +22 -4
- package/dist/utils/toolExecution.d.ts +41 -0
- package/dist/utils/toolExecution.js +357 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -14,7 +14,38 @@ export declare function parseHookArgs(argv: string[]): HookArgs;
|
|
|
14
14
|
export declare function buildHookScript(hookType: HookType, failOn: FailOn): string;
|
|
15
15
|
/** True when a hook file was created by Codeep (safe to overwrite/remove). */
|
|
16
16
|
export declare function isCodeepHook(content: string): boolean;
|
|
17
|
-
/**
|
|
17
|
+
/**
|
|
18
|
+
* Where this repository keeps its hooks — as a three-way answer, because two
|
|
19
|
+
* of the three used to come back as the same `null`.
|
|
20
|
+
*
|
|
21
|
+
* `none` means git answered and there is no hook directory to speak of (not a
|
|
22
|
+
* repository, no git on PATH). `unknown` means git was REFUSED: hardenedGitEnv
|
|
23
|
+
* would not build an environment for this repository, so nobody can say where
|
|
24
|
+
* its hooks live or whether a path is one.
|
|
25
|
+
*
|
|
26
|
+
* Folding `unknown` into `none` is a fail-OPEN, and it is a live hole rather
|
|
27
|
+
* than a theoretical one: the write gate in utils/toolExecution.ts reads a
|
|
28
|
+
* null hook directory as "this repository has no hook directory" and stops
|
|
29
|
+
* gating, so the one repository whose config Codeep refuses to scan is
|
|
30
|
+
* exactly the one whose `.githooks/pre-commit` an agent could write
|
|
31
|
+
* unprompted. A caller that has to decide something must branch on `kind`.
|
|
32
|
+
*/
|
|
33
|
+
export type HooksDirResult = {
|
|
34
|
+
kind: 'hooks';
|
|
35
|
+
dir: string;
|
|
36
|
+
} | {
|
|
37
|
+
kind: 'none';
|
|
38
|
+
} | {
|
|
39
|
+
kind: 'unknown';
|
|
40
|
+
reason: string;
|
|
41
|
+
};
|
|
42
|
+
export declare function resolveHooksDirResult(cwd: string): HooksDirResult;
|
|
43
|
+
/**
|
|
44
|
+
* Resolve the git hooks directory (honors worktrees + core.hooksPath). Null if
|
|
45
|
+
* not a repo — and THROWS `GitHardeningError` when git was refused, so a
|
|
46
|
+
* refusal can never be mistaken for "no hooks here". Callers that must not
|
|
47
|
+
* throw (the write gate) use resolveHooksDirResult() above instead.
|
|
48
|
+
*/
|
|
18
49
|
export declare function resolveHooksDir(cwd: string): string | null;
|
|
19
50
|
export interface HookDeps {
|
|
20
51
|
resolveHooksDir: (cwd: string) => string | null;
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import { readFileSync, writeFileSync, mkdirSync, chmodSync, rmSync } from 'fs';
|
|
8
8
|
import { join, dirname, isAbsolute } from 'path';
|
|
9
9
|
import { execSync } from 'child_process';
|
|
10
|
+
import { hardenedGitEnv, GitHardeningError } from './git.js';
|
|
10
11
|
const FAIL_ON_VALUES = ['error', 'warning', 'info', 'none'];
|
|
11
12
|
const MARKER_START = '# >>> codeep hook >>>';
|
|
12
13
|
const MARKER_END = '# <<< codeep hook <<<';
|
|
@@ -91,16 +92,71 @@ export function buildHookScript(hookType, failOn) {
|
|
|
91
92
|
export function isCodeepHook(content) {
|
|
92
93
|
return content.includes(MARKER_START);
|
|
93
94
|
}
|
|
94
|
-
|
|
95
|
-
|
|
95
|
+
export function resolveHooksDirResult(cwd) {
|
|
96
|
+
let env;
|
|
96
97
|
try {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
98
|
+
// noHooks stays off: answering where the hooks live is this function's
|
|
99
|
+
// entire job, and an override would have git report the no-hooks path and
|
|
100
|
+
// send `codeep hook install` there.
|
|
101
|
+
env = hardenedGitEnv({ cwd });
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
if (error instanceof GitHardeningError)
|
|
105
|
+
return { kind: 'unknown', reason: error.message };
|
|
106
|
+
throw error;
|
|
100
107
|
}
|
|
101
|
-
|
|
102
|
-
|
|
108
|
+
try {
|
|
109
|
+
// stderr is piped rather than ignored so the catch below can tell git's
|
|
110
|
+
// own answer apart from everything else. Ignoring it is what made the two
|
|
111
|
+
// indistinguishable in the first place.
|
|
112
|
+
execSync('git rev-parse --is-inside-work-tree', { cwd, stdio: ['ignore', 'ignore', 'pipe'], env });
|
|
113
|
+
const hooks = execSync('git rev-parse --git-path hooks', {
|
|
114
|
+
cwd,
|
|
115
|
+
encoding: 'utf8',
|
|
116
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
117
|
+
env,
|
|
118
|
+
}).trim();
|
|
119
|
+
return { kind: 'hooks', dir: isAbsolute(hooks) ? hooks : join(cwd, hooks) };
|
|
103
120
|
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
// Only git's own "there is no repository here" is `none`. The bare
|
|
123
|
+
// `catch { kind: 'none' }` this replaces folded a timeout, an EACCES on
|
|
124
|
+
// `cwd` and a git that died mid-answer into the same result — and `none`
|
|
125
|
+
// is what the write gate in utils/toolExecution.ts reads as "this
|
|
126
|
+
// repository has no hook directory, stop gating", so every one of those
|
|
127
|
+
// failures turned the gate off. `unknown` keeps it on, which is the only
|
|
128
|
+
// safe way round to be wrong.
|
|
129
|
+
const err = error;
|
|
130
|
+
const stderr = String(err.stderr ?? '');
|
|
131
|
+
// git's own answer for "there is no repository here". A BARE repository
|
|
132
|
+
// is deliberately not in this branch: `--is-inside-work-tree` prints
|
|
133
|
+
// `false` and exits 0 there (verified, git 2.54), so it comes back as a
|
|
134
|
+
// hooks directory, which is what it has.
|
|
135
|
+
if (/not a git repository/i.test(stderr))
|
|
136
|
+
return { kind: 'none' };
|
|
137
|
+
// git never started: not on PATH, or `cwd` is gone. The caller's own git
|
|
138
|
+
// calls cannot run either, so there is no hook directory to speak of.
|
|
139
|
+
if (err.code === 'ENOENT' || err.code === 'EACCES' || err.code === 'ENOTDIR')
|
|
140
|
+
return { kind: 'none' };
|
|
141
|
+
const detail = stderr.trim() || err.code || `exit ${err.status ?? '?'}`;
|
|
142
|
+
return {
|
|
143
|
+
kind: 'unknown',
|
|
144
|
+
reason: `Cannot tell where ${cwd} keeps its git hooks: git failed to answer (${detail}). ` +
|
|
145
|
+
'Codeep treats that as "there may be hooks here" rather than as "there are none".',
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Resolve the git hooks directory (honors worktrees + core.hooksPath). Null if
|
|
151
|
+
* not a repo — and THROWS `GitHardeningError` when git was refused, so a
|
|
152
|
+
* refusal can never be mistaken for "no hooks here". Callers that must not
|
|
153
|
+
* throw (the write gate) use resolveHooksDirResult() above instead.
|
|
154
|
+
*/
|
|
155
|
+
export function resolveHooksDir(cwd) {
|
|
156
|
+
const result = resolveHooksDirResult(cwd);
|
|
157
|
+
if (result.kind === 'unknown')
|
|
158
|
+
throw new GitHardeningError(result.reason);
|
|
159
|
+
return result.kind === 'hooks' ? result.dir : null;
|
|
104
160
|
}
|
|
105
161
|
export function runHookCommand(argv, deps = defaultHookDeps()) {
|
|
106
162
|
const args = parseHookArgs(argv);
|
|
@@ -108,7 +164,19 @@ export function runHookCommand(argv, deps = defaultHookDeps()) {
|
|
|
108
164
|
deps.write(HOOK_HELP);
|
|
109
165
|
return 0;
|
|
110
166
|
}
|
|
111
|
-
|
|
167
|
+
// `resolveHooksDir` throws rather than answer null when git was refused, so
|
|
168
|
+
// the two failures get the two different messages they need: "you are not in
|
|
169
|
+
// a repo" is not a useful thing to tell someone whose `.git/config` names a
|
|
170
|
+
// program Codeep will not run through.
|
|
171
|
+
let hooksDir;
|
|
172
|
+
try {
|
|
173
|
+
hooksDir = deps.resolveHooksDir(process.cwd());
|
|
174
|
+
}
|
|
175
|
+
catch (error) {
|
|
176
|
+
deps.write(`${error instanceof Error ? error.message : String(error)}\n` +
|
|
177
|
+
'Fix that config (or run the hook installer yourself) and try again.');
|
|
178
|
+
return 1;
|
|
179
|
+
}
|
|
112
180
|
if (!hooksDir) {
|
|
113
181
|
deps.write('Not a git repository — run `codeep hook` inside a repo.');
|
|
114
182
|
return 1;
|
|
@@ -227,6 +227,7 @@ export async function runFixPlan(plan, context) {
|
|
|
227
227
|
return `${summariseFixPlan(plan)} No API key is configured for the current provider, so the fix agent could not start.`;
|
|
228
228
|
}
|
|
229
229
|
const { runAgent } = await import('./agent.js');
|
|
230
|
+
const { NO_CONFIRMER_REFUSAL } = await import('./toolExecution.js');
|
|
230
231
|
// No cast here. `as never` on this call once hid the fact that
|
|
231
232
|
// personalityOverride did not exist, which would have run the CI fix with
|
|
232
233
|
// no boundary at all while the tests happily asserted otherwise.
|
|
@@ -239,24 +240,44 @@ export async function runFixPlan(plan, context) {
|
|
|
239
240
|
// the plan, which buildFixPlan caps, and the action's wall-clock.
|
|
240
241
|
maxIterations: 25,
|
|
241
242
|
});
|
|
243
|
+
// `result === 'success'` matters: a write the agent ATTEMPTED is logged
|
|
244
|
+
// whether or not it happened, so a refused `.git/config` was being
|
|
245
|
+
// reported to CI as a file this run edited — the exact opposite of the
|
|
246
|
+
// truth, and the file is not in the diff for anyone to check against.
|
|
242
247
|
const edited = new Set(result.actions
|
|
243
|
-
.filter(a => a.type === 'write' || a.type === 'edit')
|
|
248
|
+
.filter(a => (a.type === 'write' || a.type === 'edit') && a.result === 'success')
|
|
244
249
|
.map(a => a.target));
|
|
245
250
|
const activity = describeAgentActivity(result.actions);
|
|
251
|
+
// A headless run passes no permission callback, so a write to a file that
|
|
252
|
+
// decides what runs later — `.git/config`, a hook, an MCP server list —
|
|
253
|
+
// is refused rather than done unasked. That is the right answer for CI:
|
|
254
|
+
// an unattended run must not be the thing that installs a hook. But the
|
|
255
|
+
// refusal only ever reached the model, so the run looked like it had
|
|
256
|
+
// simply chosen not to fix that finding, and the one action a human has
|
|
257
|
+
// to take was in nobody's log. Name it in the summary, which is what the
|
|
258
|
+
// action prints. An opt-in flag was the alternative and is worse: it
|
|
259
|
+
// would exist to be set in a YAML file once and then never read again.
|
|
260
|
+
const refused = [...new Set(result.actions
|
|
261
|
+
.filter(a => a.result === 'error' && a.details?.includes(NO_CONFIRMER_REFUSAL))
|
|
262
|
+
.map(a => a.target))];
|
|
263
|
+
const refusalNote = refused.length
|
|
264
|
+
? ` It was refused ${refused.length} write${refused.length === 1 ? '' : 's'} to ${refused.join(', ')}: ` +
|
|
265
|
+
'those files decide what runs later, and a headless run has nobody to confirm them. Edit them yourself.'
|
|
266
|
+
: '';
|
|
246
267
|
const editedList = `Edited ${edited.size} file${edited.size === 1 ? '' : 's'}: ${[...edited].join(', ')}.`;
|
|
247
268
|
if (!result.success) {
|
|
248
269
|
// The run did its work and the checks after it failed. The edits are in
|
|
249
270
|
// the working tree all the same, so name them next to the checks.
|
|
250
271
|
if (result.failedChecks?.length) {
|
|
251
272
|
const changed = edited.size > 0 ? editedList : activity;
|
|
252
|
-
return `${summariseFixPlan(plan)} ${changed} These checks still fail afterwards: ${result.failedChecks.join(', ')}.`;
|
|
273
|
+
return `${summariseFixPlan(plan)} ${changed}${refusalNote} These checks still fail afterwards: ${result.failedChecks.join(', ')}.`;
|
|
253
274
|
}
|
|
254
|
-
return `${summariseFixPlan(plan)} The run did not finish: ${result.error ?? 'unknown error'}. ${activity}`;
|
|
275
|
+
return `${summariseFixPlan(plan)} The run did not finish: ${result.error ?? 'unknown error'}. ${activity}${refusalNote}`;
|
|
255
276
|
}
|
|
256
277
|
if (edited.size === 0) {
|
|
257
|
-
return `${summariseFixPlan(plan)} Nothing was changed. ${activity}`;
|
|
278
|
+
return `${summariseFixPlan(plan)} Nothing was changed. ${activity}${refusalNote}`;
|
|
258
279
|
}
|
|
259
|
-
return `${summariseFixPlan(plan)} ${editedList}`;
|
|
280
|
+
return `${summariseFixPlan(plan)} ${editedList}${refusalNote}`;
|
|
260
281
|
}
|
|
261
282
|
catch (error) {
|
|
262
283
|
// A missing key or an unreachable provider must not fail the review. The
|
package/dist/utils/shell.d.ts
CHANGED
|
@@ -35,6 +35,114 @@ export declare function validateCommand(command: string, args: string[], options
|
|
|
35
35
|
valid: boolean;
|
|
36
36
|
reason?: string;
|
|
37
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;
|
|
38
146
|
/**
|
|
39
147
|
* Execute a shell command with safety checks
|
|
40
148
|
*/
|