@praneeth_54/agentdoctor 0.2.0-beta → 1.0.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/CHANGELOG.md +107 -3
- package/README.md +86 -35
- package/dist/agents/inspect.js +58 -51
- package/dist/cli/commands/doctor.js +1 -1
- package/dist/cli/commands/explain.js +2 -2
- package/dist/cli/commands/fix.d.ts +7 -5
- package/dist/cli/commands/fix.js +73 -12
- package/dist/cli/commands/scan.d.ts +5 -0
- package/dist/cli/commands/scan.js +25 -6
- package/dist/cli/commands/verify.d.ts +19 -0
- package/dist/cli/commands/verify.js +70 -0
- package/dist/cli/program.js +97 -33
- package/dist/constants.d.ts +1 -1
- package/dist/constants.js +1 -1
- package/dist/core/fix/apply.d.ts +14 -0
- package/dist/core/fix/apply.js +47 -0
- package/dist/core/fix/patterns.d.ts +7 -0
- package/dist/core/fix/patterns.js +21 -0
- package/dist/core/fix/plan.d.ts +9 -0
- package/dist/core/fix/plan.js +247 -0
- package/dist/core/fix/render.d.ts +8 -0
- package/dist/core/fix/render.js +116 -0
- package/dist/core/fix/run.d.ts +15 -0
- package/dist/core/fix/run.js +58 -0
- package/dist/core/fix/types.d.ts +34 -0
- package/dist/core/fix/types.js +6 -0
- package/dist/core/fix/writers/claude-settings.d.ts +16 -0
- package/dist/core/fix/writers/claude-settings.js +100 -0
- package/dist/core/fix/writers/codex-config.d.ts +22 -0
- package/dist/core/fix/writers/codex-config.js +183 -0
- package/dist/core/fix/writers/cursorignore.d.ts +13 -0
- package/dist/core/fix/writers/cursorignore.js +68 -0
- package/dist/core/path-resolution/index.d.ts +5 -0
- package/dist/core/path-resolution/index.js +5 -0
- package/dist/core/path-resolution/prepare.d.ts +33 -0
- package/dist/core/path-resolution/prepare.js +98 -0
- package/dist/core/policy/evaluate.d.ts +44 -0
- package/dist/core/policy/evaluate.js +120 -0
- package/dist/core/rules/claude-deny.d.ts +8 -0
- package/dist/core/rules/claude-deny.js +39 -0
- package/dist/core/rules/codex-deny.d.ts +5 -0
- package/dist/core/rules/codex-deny.js +42 -0
- package/dist/core/rules/context/generated-directory.js +52 -8
- package/dist/core/rules/context/large-log-file.js +27 -10
- package/dist/core/rules/ignore.js +2 -5
- package/dist/core/rules/instructions/missing-path-reference.d.ts +5 -1
- package/dist/core/rules/instructions/missing-path-reference.js +114 -7
- package/dist/core/rules/path-kind.d.ts +27 -0
- package/dist/core/rules/path-kind.js +180 -0
- package/dist/core/rules/security/env-file-exposure.js +23 -38
- package/dist/core/rules/security/private-key-file.js +4 -0
- package/dist/core/rules/text-cache.js +46 -40
- package/dist/core/verify/compare.d.ts +29 -0
- package/dist/core/verify/compare.js +56 -0
- package/dist/core/verify/load-baseline.d.ts +15 -0
- package/dist/core/verify/load-baseline.js +71 -0
- package/dist/core/verify/verify.d.ts +26 -0
- package/dist/core/verify/verify.js +40 -0
- package/dist/discovery/files.js +7 -2
- package/dist/discovery/log-like.d.ts +2 -0
- package/dist/discovery/log-like.js +7 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +6 -0
- package/dist/reporters/github/annotations.d.ts +8 -0
- package/dist/reporters/github/annotations.js +42 -0
- package/dist/reporters/github/emit.d.ts +23 -0
- package/dist/reporters/github/emit.js +31 -0
- package/dist/reporters/github/summary.d.ts +20 -0
- package/dist/reporters/github/summary.js +111 -0
- package/dist/reporters/terminal/report.d.ts +5 -0
- package/dist/reporters/terminal/report.js +73 -4
- package/dist/reporters/verify/json.d.ts +5 -0
- package/dist/reporters/verify/json.js +51 -0
- package/dist/reporters/verify/terminal.d.ts +6 -0
- package/dist/reporters/verify/terminal.js +71 -0
- package/dist/utils/fs.d.ts +5 -0
- package/dist/utils/fs.js +34 -0
- package/dist/utils/path.d.ts +5 -0
- package/dist/utils/path.js +16 -0
- package/package.json +11 -1
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { readTextFile } from "../../utils/fs.js";
|
|
3
|
+
import { settingsTextDeniesPath } from "../rules/claude-deny.js";
|
|
4
|
+
import { configTextDeniesPath } from "../rules/codex-deny.js";
|
|
5
|
+
import { createIgnoreIndex, parseIgnoreFile, pathMatchesAny } from "../rules/ignore.js";
|
|
6
|
+
import { isSafeContextFixRule, patternForFinding } from "./patterns.js";
|
|
7
|
+
import { denyRuleForFixPattern } from "./writers/claude-settings.js";
|
|
8
|
+
import { assertWritableCodexConfig, denyKeyForFixPattern } from "./writers/codex-config.js";
|
|
9
|
+
const DEFAULT_MAX_IGNORE_BYTES = 512 * 1024;
|
|
10
|
+
function cursorConfigured(result) {
|
|
11
|
+
return result.agents.some((a) => a.id === "cursor" && (a.detected || a.configured));
|
|
12
|
+
}
|
|
13
|
+
function claudeConfigured(result) {
|
|
14
|
+
return result.agents.some((a) => a.id === "claude-code" && (a.detected || a.configured));
|
|
15
|
+
}
|
|
16
|
+
function codexConfigured(result) {
|
|
17
|
+
return result.agents.some((a) => a.id === "codex" && (a.detected || a.configured));
|
|
18
|
+
}
|
|
19
|
+
async function loadCursorignorePatterns(root) {
|
|
20
|
+
const filePath = path.join(root, ".cursorignore");
|
|
21
|
+
const text = await readTextFile(filePath, DEFAULT_MAX_IGNORE_BYTES);
|
|
22
|
+
if (text === null) {
|
|
23
|
+
return [];
|
|
24
|
+
}
|
|
25
|
+
return parseIgnoreFile(text);
|
|
26
|
+
}
|
|
27
|
+
async function loadClaudeSettingsText(root) {
|
|
28
|
+
return readTextFile(path.join(root, ".claude/settings.json"), DEFAULT_MAX_IGNORE_BYTES);
|
|
29
|
+
}
|
|
30
|
+
async function loadCodexConfigText(root) {
|
|
31
|
+
return readTextFile(path.join(root, ".codex/config.toml"), DEFAULT_MAX_IGNORE_BYTES);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Build a fix plan from a scan result.
|
|
35
|
+
* Safe context exclusions: Cursor `.cursorignore`, Claude Code Read deny, Codex filesystem deny.
|
|
36
|
+
*/
|
|
37
|
+
export async function buildFixPlan(result) {
|
|
38
|
+
const root = result.repository.root;
|
|
39
|
+
const skipped = [];
|
|
40
|
+
const actionsByKey = new Map();
|
|
41
|
+
const existingCursorPatterns = await loadCursorignorePatterns(root);
|
|
42
|
+
const cursorIndex = createIgnoreIndex({
|
|
43
|
+
gitignorePatterns: [],
|
|
44
|
+
cursorignorePatterns: existingCursorPatterns,
|
|
45
|
+
});
|
|
46
|
+
const claudeSettingsText = await loadClaudeSettingsText(root);
|
|
47
|
+
const codexConfigText = await loadCodexConfigText(root);
|
|
48
|
+
const hasCursor = cursorConfigured(result);
|
|
49
|
+
const hasClaude = claudeConfigured(result);
|
|
50
|
+
const hasCodex = codexConfigured(result);
|
|
51
|
+
for (const finding of result.findings) {
|
|
52
|
+
if (finding.fixability !== "safe") {
|
|
53
|
+
skipped.push({
|
|
54
|
+
findingId: finding.id,
|
|
55
|
+
ruleId: finding.ruleId,
|
|
56
|
+
reason: finding.fixability === "review"
|
|
57
|
+
? "Requires human review (not auto-fixed)"
|
|
58
|
+
: finding.fixability === "manual"
|
|
59
|
+
? "Manual remediation required (not auto-fixed)"
|
|
60
|
+
: "Not auto-fixable",
|
|
61
|
+
});
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (!isSafeContextFixRule(finding.ruleId)) {
|
|
65
|
+
skipped.push({
|
|
66
|
+
findingId: finding.id,
|
|
67
|
+
ruleId: finding.ruleId,
|
|
68
|
+
reason: "No automated fixer for this safe rule yet",
|
|
69
|
+
});
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
const pattern = patternForFinding(finding);
|
|
73
|
+
const evidencePath = finding.evidence?.path?.replace(/\\/g, "/");
|
|
74
|
+
if (!pattern || !evidencePath) {
|
|
75
|
+
skipped.push({
|
|
76
|
+
findingId: finding.id,
|
|
77
|
+
ruleId: finding.ruleId,
|
|
78
|
+
reason: "Finding has no usable evidence path",
|
|
79
|
+
});
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
const wantsCursor = finding.affectedAgents.includes("cursor");
|
|
83
|
+
const wantsClaude = finding.affectedAgents.includes("claude-code");
|
|
84
|
+
const wantsCodex = finding.affectedAgents.includes("codex");
|
|
85
|
+
if (wantsCursor && hasCursor) {
|
|
86
|
+
if (cursorIndex.matchesCursorignore(evidencePath) ||
|
|
87
|
+
cursorIndex.matchesCursorignore(pattern)) {
|
|
88
|
+
skipped.push({
|
|
89
|
+
findingId: finding.id,
|
|
90
|
+
ruleId: finding.ruleId,
|
|
91
|
+
reason: "Already excluded by .cursorignore",
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
mergeCursorAction(actionsByKey, finding, pattern, evidencePath);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
else if (wantsCursor && !hasCursor) {
|
|
99
|
+
skipped.push({
|
|
100
|
+
findingId: finding.id,
|
|
101
|
+
ruleId: finding.ruleId,
|
|
102
|
+
reason: "Cursor not detected; skipping .cursorignore fix",
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
if (wantsClaude && hasClaude) {
|
|
106
|
+
const denyRule = denyRuleForFixPattern(pattern, evidencePath);
|
|
107
|
+
if (claudeSettingsText && settingsTextDeniesPath(claudeSettingsText, evidencePath)) {
|
|
108
|
+
skipped.push({
|
|
109
|
+
findingId: finding.id,
|
|
110
|
+
ruleId: finding.ruleId,
|
|
111
|
+
reason: "Already excluded by Claude Code Read deny",
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
mergeClaudeAction(actionsByKey, finding, pattern, evidencePath, denyRule);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
else if (wantsClaude && !hasClaude) {
|
|
119
|
+
skipped.push({
|
|
120
|
+
findingId: finding.id,
|
|
121
|
+
ruleId: finding.ruleId,
|
|
122
|
+
reason: "Claude Code not detected; skipping settings deny fix",
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
if (wantsCodex && hasCodex) {
|
|
126
|
+
const denyKey = denyKeyForFixPattern(pattern, evidencePath);
|
|
127
|
+
if (codexConfigText && configTextDeniesPath(codexConfigText, evidencePath)) {
|
|
128
|
+
skipped.push({
|
|
129
|
+
findingId: finding.id,
|
|
130
|
+
ruleId: finding.ruleId,
|
|
131
|
+
reason: "Already excluded by Codex filesystem deny",
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
else if (codexConfigText) {
|
|
135
|
+
// Refuse unwritable/invalid config the same way Claude Fix refuses bad JSON —
|
|
136
|
+
// do not silently skip and leave Codex findings uncleared after a partial Cursor write.
|
|
137
|
+
assertWritableCodexConfig(codexConfigText);
|
|
138
|
+
mergeCodexAction(actionsByKey, finding, pattern, evidencePath, denyKey);
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
mergeCodexAction(actionsByKey, finding, pattern, evidencePath, denyKey);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
else if (wantsCodex && !hasCodex) {
|
|
145
|
+
skipped.push({
|
|
146
|
+
findingId: finding.id,
|
|
147
|
+
ruleId: finding.ruleId,
|
|
148
|
+
reason: "Codex not detected; skipping config.toml deny fix",
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
for (const agent of finding.affectedAgents) {
|
|
152
|
+
if (agent === "cursor" || agent === "claude-code" || agent === "codex") {
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
skipped.push({
|
|
156
|
+
findingId: finding.id,
|
|
157
|
+
ruleId: finding.ruleId,
|
|
158
|
+
reason: `Fix writer for ${agent} not implemented yet`,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return {
|
|
163
|
+
root,
|
|
164
|
+
actions: [...actionsByKey.values()].sort((a, b) => a.id.localeCompare(b.id)),
|
|
165
|
+
skipped,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
function mergeCursorAction(actionsByKey, finding, pattern, evidencePath) {
|
|
169
|
+
const key = `cursor:.cursorignore:${pattern}`;
|
|
170
|
+
const existing = actionsByKey.get(key);
|
|
171
|
+
if (existing) {
|
|
172
|
+
if (!existing.findingIds.includes(finding.id)) {
|
|
173
|
+
existing.findingIds.push(finding.id);
|
|
174
|
+
}
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
actionsByKey.set(key, {
|
|
178
|
+
id: key,
|
|
179
|
+
kind: "append-ignore-pattern",
|
|
180
|
+
agent: "cursor",
|
|
181
|
+
targetRelativePath: ".cursorignore",
|
|
182
|
+
pattern,
|
|
183
|
+
evidencePath,
|
|
184
|
+
findingIds: [finding.id],
|
|
185
|
+
description: `Exclude ${evidencePath} from Cursor agent context via .cursorignore`,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
function mergeClaudeAction(actionsByKey, finding, pattern, evidencePath, denyRule) {
|
|
189
|
+
const key = `claude-code:.claude/settings.json:${denyRule}`;
|
|
190
|
+
const existing = actionsByKey.get(key);
|
|
191
|
+
if (existing) {
|
|
192
|
+
if (!existing.findingIds.includes(finding.id)) {
|
|
193
|
+
existing.findingIds.push(finding.id);
|
|
194
|
+
}
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
actionsByKey.set(key, {
|
|
198
|
+
id: key,
|
|
199
|
+
kind: "append-ignore-pattern",
|
|
200
|
+
agent: "claude-code",
|
|
201
|
+
targetRelativePath: ".claude/settings.json",
|
|
202
|
+
pattern,
|
|
203
|
+
evidencePath,
|
|
204
|
+
findingIds: [finding.id],
|
|
205
|
+
description: `Exclude ${evidencePath} from Claude Code via permissions.deny ${denyRule}`,
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
function mergeCodexAction(actionsByKey, finding, pattern, evidencePath, denyKey) {
|
|
209
|
+
const key = `codex:.codex/config.toml:${denyKey}`;
|
|
210
|
+
const existing = actionsByKey.get(key);
|
|
211
|
+
if (existing) {
|
|
212
|
+
if (!existing.findingIds.includes(finding.id)) {
|
|
213
|
+
existing.findingIds.push(finding.id);
|
|
214
|
+
}
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
actionsByKey.set(key, {
|
|
218
|
+
id: key,
|
|
219
|
+
kind: "append-ignore-pattern",
|
|
220
|
+
agent: "codex",
|
|
221
|
+
targetRelativePath: ".codex/config.toml",
|
|
222
|
+
pattern,
|
|
223
|
+
evidencePath,
|
|
224
|
+
findingIds: [finding.id],
|
|
225
|
+
description: `Exclude ${evidencePath} from Codex via filesystem deny "${denyKey}"`,
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
/** Patterns that would be newly appended given current file contents. */
|
|
229
|
+
export function missingPatternsForCursorignore(currentContent, patterns) {
|
|
230
|
+
const existing = currentContent === null ? [] : parseIgnoreFile(currentContent);
|
|
231
|
+
const missing = [];
|
|
232
|
+
for (const pattern of patterns) {
|
|
233
|
+
if (existing.includes(pattern)) {
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
if (pathMatchesAny(pattern.replace(/\/$/, ""), existing) &&
|
|
237
|
+
existing.some((p) => p === pattern)) {
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
const evidenceGuess = pattern.endsWith("/") ? pattern.slice(0, -1) : pattern;
|
|
241
|
+
if (pathMatchesAny(evidenceGuess, existing) || pathMatchesAny(pattern, existing)) {
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
missing.push(pattern);
|
|
245
|
+
}
|
|
246
|
+
return missing;
|
|
247
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { FixApplyResult, FixPlan } from "./types.js";
|
|
2
|
+
export declare function renderFixPlanTerminal(plan: FixPlan, options: {
|
|
3
|
+
dryRun: boolean;
|
|
4
|
+
cursorContent: string | null;
|
|
5
|
+
claudeSettingsContent?: string | null;
|
|
6
|
+
codexConfigContent?: string | null;
|
|
7
|
+
applyResult?: FixApplyResult;
|
|
8
|
+
}): string;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { previewClaudeSettingsActions } from "./writers/claude-settings.js";
|
|
2
|
+
import { previewCodexConfigActions } from "./writers/codex-config.js";
|
|
3
|
+
import { previewCursorignoreActions } from "./writers/cursorignore.js";
|
|
4
|
+
export function renderFixPlanTerminal(plan, options) {
|
|
5
|
+
const lines = [];
|
|
6
|
+
lines.push("");
|
|
7
|
+
lines.push(options.dryRun ? "AgentDoctor fix (dry-run)" : "AgentDoctor fix");
|
|
8
|
+
lines.push("");
|
|
9
|
+
if (plan.actions.length === 0) {
|
|
10
|
+
lines.push(" No automatic fixes available for this scan.");
|
|
11
|
+
if (plan.skipped.length > 0) {
|
|
12
|
+
lines.push("");
|
|
13
|
+
lines.push(` ${plan.skipped.length} finding(s) need manual or review action:`);
|
|
14
|
+
const reasons = summarizeSkipReasons(plan.skipped.map((s) => s.reason));
|
|
15
|
+
for (const [reason, count] of reasons) {
|
|
16
|
+
lines.push(` - ${reason} (${count})`);
|
|
17
|
+
}
|
|
18
|
+
lines.push("");
|
|
19
|
+
lines.push("Next");
|
|
20
|
+
lines.push(" Inspect a finding: agentdoctor explain <rule-id> (ids in scan --json)");
|
|
21
|
+
lines.push(" Confirm with verify: agentdoctor verify --baseline agentdoctor-report.json");
|
|
22
|
+
lines.push(" No baseline yet? agentdoctor scan --json > agentdoctor-report.json");
|
|
23
|
+
}
|
|
24
|
+
else {
|
|
25
|
+
lines.push(" Scan found nothing that Fix can change.");
|
|
26
|
+
lines.push("");
|
|
27
|
+
lines.push("Next");
|
|
28
|
+
lines.push(" Re-scan: agentdoctor scan");
|
|
29
|
+
}
|
|
30
|
+
lines.push("");
|
|
31
|
+
return lines.join("\n");
|
|
32
|
+
}
|
|
33
|
+
lines.push(` Proposed actions: ${plan.actions.length}`);
|
|
34
|
+
for (const action of plan.actions) {
|
|
35
|
+
lines.push(` • [${action.agent}] ${action.description}`);
|
|
36
|
+
lines.push(` pattern: ${action.pattern}`);
|
|
37
|
+
lines.push(` findings: ${action.findingIds.length}`);
|
|
38
|
+
}
|
|
39
|
+
const cursorPreview = previewCursorignoreActions(options.cursorContent, plan.actions);
|
|
40
|
+
const claudePreview = previewClaudeSettingsActions(options.claudeSettingsContent ?? null, plan.actions);
|
|
41
|
+
const codexPreview = previewCodexConfigActions(options.codexConfigContent ?? null, plan.actions);
|
|
42
|
+
if (cursorPreview || claudePreview || codexPreview) {
|
|
43
|
+
lines.push("");
|
|
44
|
+
lines.push(" File changes:");
|
|
45
|
+
let shown = false;
|
|
46
|
+
if (cursorPreview) {
|
|
47
|
+
lines.push(` ${cursorPreview.targetRelativePath} (+${cursorPreview.patternsToAdd.length} pattern(s))`);
|
|
48
|
+
lines.push("");
|
|
49
|
+
for (const previewLine of cursorPreview.preview.split("\n")) {
|
|
50
|
+
lines.push(` ${previewLine}`);
|
|
51
|
+
}
|
|
52
|
+
shown = true;
|
|
53
|
+
}
|
|
54
|
+
if (claudePreview) {
|
|
55
|
+
if (shown) {
|
|
56
|
+
lines.push("");
|
|
57
|
+
}
|
|
58
|
+
lines.push(` ${claudePreview.targetRelativePath} (+${claudePreview.denyRulesToAdd.length} deny rule(s))`);
|
|
59
|
+
lines.push("");
|
|
60
|
+
for (const previewLine of claudePreview.preview.split("\n")) {
|
|
61
|
+
lines.push(` ${previewLine}`);
|
|
62
|
+
}
|
|
63
|
+
shown = true;
|
|
64
|
+
}
|
|
65
|
+
if (codexPreview) {
|
|
66
|
+
if (shown) {
|
|
67
|
+
lines.push("");
|
|
68
|
+
}
|
|
69
|
+
lines.push(` ${codexPreview.targetRelativePath} (+${codexPreview.denyKeysToAdd.length} deny key(s))`);
|
|
70
|
+
lines.push("");
|
|
71
|
+
for (const previewLine of codexPreview.preview.split("\n")) {
|
|
72
|
+
lines.push(` ${previewLine}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (options.applyResult && !options.dryRun) {
|
|
77
|
+
lines.push("");
|
|
78
|
+
if (options.applyResult.writtenFiles.length > 0) {
|
|
79
|
+
lines.push(` Wrote: ${options.applyResult.writtenFiles.join(", ")}`);
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
lines.push(" No files written.");
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
else if (options.dryRun) {
|
|
86
|
+
lines.push("");
|
|
87
|
+
lines.push(" No files were modified (dry-run).");
|
|
88
|
+
}
|
|
89
|
+
if (plan.skipped.length > 0) {
|
|
90
|
+
lines.push("");
|
|
91
|
+
lines.push(` Skipped: ${plan.skipped.length} finding(s):`);
|
|
92
|
+
const reasons = summarizeSkipReasons(plan.skipped.map((s) => s.reason));
|
|
93
|
+
for (const [reason, count] of reasons) {
|
|
94
|
+
lines.push(` - ${reason} (${count})`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
lines.push("");
|
|
98
|
+
lines.push("Next");
|
|
99
|
+
if (options.dryRun && plan.actions.length > 0) {
|
|
100
|
+
lines.push(" Apply these changes: agentdoctor fix -y");
|
|
101
|
+
}
|
|
102
|
+
if (plan.skipped.length > 0) {
|
|
103
|
+
lines.push(" Review/manual findings stay open — inspect with: agentdoctor explain <rule-id>");
|
|
104
|
+
}
|
|
105
|
+
lines.push(" Confirm with verify: agentdoctor verify --baseline agentdoctor-report.json");
|
|
106
|
+
lines.push(" No baseline yet? agentdoctor scan --json > agentdoctor-report.json");
|
|
107
|
+
lines.push("");
|
|
108
|
+
return lines.join("\n");
|
|
109
|
+
}
|
|
110
|
+
function summarizeSkipReasons(reasons) {
|
|
111
|
+
const map = new Map();
|
|
112
|
+
for (const reason of reasons) {
|
|
113
|
+
map.set(reason, (map.get(reason) ?? 0) + 1);
|
|
114
|
+
}
|
|
115
|
+
return [...map.entries()].sort((a, b) => b[1] - a[1]);
|
|
116
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { FixApplyResult, FixPlan } from "./types.js";
|
|
2
|
+
export interface RunFixOptions {
|
|
3
|
+
cwd: string;
|
|
4
|
+
dryRun: boolean;
|
|
5
|
+
yes: boolean;
|
|
6
|
+
}
|
|
7
|
+
export interface RunFixResult {
|
|
8
|
+
plan: FixPlan;
|
|
9
|
+
applyResult: FixApplyResult;
|
|
10
|
+
cancelled: boolean;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Scan → plan → (confirm) → apply Safe Repository Mutation actions.
|
|
14
|
+
*/
|
|
15
|
+
export declare function runFix(options: RunFixOptions): Promise<RunFixResult>;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import readline from "node:readline/promises";
|
|
2
|
+
import { stdin as input, stdout as output } from "node:process";
|
|
3
|
+
import { scan } from "../scanner/scan.js";
|
|
4
|
+
import { applyFixPlan, readClaudeSettings, readCodexConfig, readCursorignore } from "./apply.js";
|
|
5
|
+
import { buildFixPlan } from "./plan.js";
|
|
6
|
+
import { renderFixPlanTerminal } from "./render.js";
|
|
7
|
+
/**
|
|
8
|
+
* Scan → plan → (confirm) → apply Safe Repository Mutation actions.
|
|
9
|
+
*/
|
|
10
|
+
export async function runFix(options) {
|
|
11
|
+
const result = await scan({ cwd: options.cwd });
|
|
12
|
+
const plan = await buildFixPlan(result);
|
|
13
|
+
const cursorContent = await readCursorignore(plan.root);
|
|
14
|
+
const claudeSettingsContent = await readClaudeSettings(plan.root);
|
|
15
|
+
const codexConfigContent = await readCodexConfig(plan.root);
|
|
16
|
+
if (plan.actions.length === 0 || options.dryRun) {
|
|
17
|
+
const applyResult = await applyFixPlan(plan, { dryRun: true });
|
|
18
|
+
return { plan, applyResult, cancelled: false };
|
|
19
|
+
}
|
|
20
|
+
if (!options.yes) {
|
|
21
|
+
const previewText = renderFixPlanTerminal(plan, {
|
|
22
|
+
dryRun: true,
|
|
23
|
+
cursorContent,
|
|
24
|
+
claudeSettingsContent,
|
|
25
|
+
codexConfigContent,
|
|
26
|
+
});
|
|
27
|
+
process.stdout.write(previewText);
|
|
28
|
+
const confirmed = await confirm("Apply these changes?");
|
|
29
|
+
if (!confirmed) {
|
|
30
|
+
return {
|
|
31
|
+
plan,
|
|
32
|
+
applyResult: {
|
|
33
|
+
plan,
|
|
34
|
+
dryRun: true,
|
|
35
|
+
writtenFiles: [],
|
|
36
|
+
changedFiles: [],
|
|
37
|
+
},
|
|
38
|
+
cancelled: true,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const applyResult = await applyFixPlan(plan, { dryRun: false });
|
|
43
|
+
return { plan, applyResult, cancelled: false };
|
|
44
|
+
}
|
|
45
|
+
async function confirm(question) {
|
|
46
|
+
if (!input.isTTY || !output.isTTY) {
|
|
47
|
+
process.stderr.write("Error: confirmation required but stdin is not a TTY. Re-run with --yes or --dry-run.\n");
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
const rl = readline.createInterface({ input, output });
|
|
51
|
+
try {
|
|
52
|
+
const answer = await rl.question(`${question} [y/N] `);
|
|
53
|
+
return /^y(es)?$/i.test(answer.trim());
|
|
54
|
+
}
|
|
55
|
+
finally {
|
|
56
|
+
rl.close();
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { AgentId } from "../../types/index.js";
|
|
2
|
+
export type FixKind = "append-ignore-pattern";
|
|
3
|
+
/** Allowlisted relative paths fix may create or modify. */
|
|
4
|
+
export declare const FIX_PATH_ALLOWLIST: readonly [".cursorignore", ".claude/settings.json", ".codex/config.toml"];
|
|
5
|
+
export type FixAllowlistedPath = (typeof FIX_PATH_ALLOWLIST)[number];
|
|
6
|
+
export interface FixAction {
|
|
7
|
+
id: string;
|
|
8
|
+
kind: FixKind;
|
|
9
|
+
agent: AgentId;
|
|
10
|
+
targetRelativePath: FixAllowlistedPath;
|
|
11
|
+
/** Ignore pattern to ensure is present (gitignore-style). */
|
|
12
|
+
pattern: string;
|
|
13
|
+
/** Evidence path this pattern is meant to exclude. */
|
|
14
|
+
evidencePath: string;
|
|
15
|
+
findingIds: string[];
|
|
16
|
+
description: string;
|
|
17
|
+
}
|
|
18
|
+
export interface SkippedFix {
|
|
19
|
+
findingId: string;
|
|
20
|
+
ruleId: string;
|
|
21
|
+
reason: string;
|
|
22
|
+
}
|
|
23
|
+
export interface FixPlan {
|
|
24
|
+
root: string;
|
|
25
|
+
actions: FixAction[];
|
|
26
|
+
skipped: SkippedFix[];
|
|
27
|
+
}
|
|
28
|
+
export interface FixApplyResult {
|
|
29
|
+
plan: FixPlan;
|
|
30
|
+
dryRun: boolean;
|
|
31
|
+
writtenFiles: string[];
|
|
32
|
+
/** Relative paths that would be / were written */
|
|
33
|
+
changedFiles: string[];
|
|
34
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { FixAction } from "../types.js";
|
|
2
|
+
declare const SETTINGS_RELATIVE = ".claude/settings.json";
|
|
3
|
+
export interface ClaudeSettingsWritePreview {
|
|
4
|
+
targetRelativePath: typeof SETTINGS_RELATIVE;
|
|
5
|
+
denyRulesToAdd: string[];
|
|
6
|
+
before: string;
|
|
7
|
+
after: string;
|
|
8
|
+
preview: string;
|
|
9
|
+
}
|
|
10
|
+
export declare function denyRuleForFixPattern(pattern: string, evidencePath: string): string;
|
|
11
|
+
export declare function missingClaudeDenyRules(currentContent: string | null, denyRules: string[]): string[];
|
|
12
|
+
export declare function buildClaudeSettingsContent(currentContent: string | null, denyRulesToAdd: string[]): string;
|
|
13
|
+
export declare function previewClaudeSettingsActions(currentContent: string | null, actions: FixAction[]): ClaudeSettingsWritePreview | null;
|
|
14
|
+
export declare function readClaudeSettings(root: string): Promise<string | null>;
|
|
15
|
+
export declare function writeClaudeSettings(root: string, content: string): Promise<void>;
|
|
16
|
+
export {};
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { atomicWriteTextFile, readTextFile } from "../../../utils/fs.js";
|
|
3
|
+
import { claudeReadDenyRule } from "../../rules/claude-deny.js";
|
|
4
|
+
const MAX_BYTES = 512 * 1024;
|
|
5
|
+
const SETTINGS_RELATIVE = ".claude/settings.json";
|
|
6
|
+
export function denyRuleForFixPattern(pattern, evidencePath) {
|
|
7
|
+
const isDirectory = pattern.endsWith("/");
|
|
8
|
+
return claudeReadDenyRule(evidencePath, isDirectory ? "directory" : "file");
|
|
9
|
+
}
|
|
10
|
+
export function missingClaudeDenyRules(currentContent, denyRules) {
|
|
11
|
+
const existing = parseDenyList(currentContent);
|
|
12
|
+
const missing = [];
|
|
13
|
+
for (const rule of denyRules) {
|
|
14
|
+
if (existing.includes(rule)) {
|
|
15
|
+
continue;
|
|
16
|
+
}
|
|
17
|
+
missing.push(rule);
|
|
18
|
+
}
|
|
19
|
+
return missing;
|
|
20
|
+
}
|
|
21
|
+
export function buildClaudeSettingsContent(currentContent, denyRulesToAdd) {
|
|
22
|
+
if (denyRulesToAdd.length === 0) {
|
|
23
|
+
return currentContent ?? `${JSON.stringify({ permissions: { deny: [] } }, null, 2)}\n`;
|
|
24
|
+
}
|
|
25
|
+
let parsed = {};
|
|
26
|
+
if (currentContent && currentContent.trim().length > 0) {
|
|
27
|
+
try {
|
|
28
|
+
parsed = JSON.parse(currentContent);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
throw new Error(`${SETTINGS_RELATIVE} is not valid JSON; refusing to apply Claude Fix`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
const permissions = parsed.permissions && typeof parsed.permissions === "object" ? { ...parsed.permissions } : {};
|
|
35
|
+
const deny = Array.isArray(permissions.deny)
|
|
36
|
+
? permissions.deny.filter((item) => typeof item === "string")
|
|
37
|
+
: [];
|
|
38
|
+
for (const rule of denyRulesToAdd) {
|
|
39
|
+
if (!deny.includes(rule)) {
|
|
40
|
+
deny.push(rule);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
permissions.deny = deny;
|
|
44
|
+
parsed.permissions = permissions;
|
|
45
|
+
return `${JSON.stringify(parsed, null, 2)}\n`;
|
|
46
|
+
}
|
|
47
|
+
export function previewClaudeSettingsActions(currentContent, actions) {
|
|
48
|
+
const claudeActions = actions.filter((a) => a.agent === "claude-code" && a.targetRelativePath === SETTINGS_RELATIVE);
|
|
49
|
+
if (claudeActions.length === 0) {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
const requested = [
|
|
53
|
+
...new Set(claudeActions.map((a) => denyRuleForFixPattern(a.pattern, a.evidencePath))),
|
|
54
|
+
];
|
|
55
|
+
const denyRulesToAdd = missingClaudeDenyRules(currentContent, requested);
|
|
56
|
+
if (denyRulesToAdd.length === 0) {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
const before = currentContent ?? "";
|
|
60
|
+
const after = buildClaudeSettingsContent(currentContent, denyRulesToAdd);
|
|
61
|
+
return {
|
|
62
|
+
targetRelativePath: SETTINGS_RELATIVE,
|
|
63
|
+
denyRulesToAdd,
|
|
64
|
+
before,
|
|
65
|
+
after,
|
|
66
|
+
preview: formatSimpleDiff(SETTINGS_RELATIVE, before, after),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
export async function readClaudeSettings(root) {
|
|
70
|
+
return readTextFile(path.join(root, SETTINGS_RELATIVE), MAX_BYTES);
|
|
71
|
+
}
|
|
72
|
+
export async function writeClaudeSettings(root, content) {
|
|
73
|
+
await atomicWriteTextFile(path.join(root, SETTINGS_RELATIVE), content);
|
|
74
|
+
}
|
|
75
|
+
function parseDenyList(currentContent) {
|
|
76
|
+
if (!currentContent || currentContent.trim().length === 0) {
|
|
77
|
+
return [];
|
|
78
|
+
}
|
|
79
|
+
try {
|
|
80
|
+
const parsed = JSON.parse(currentContent);
|
|
81
|
+
const deny = parsed.permissions?.deny;
|
|
82
|
+
if (!Array.isArray(deny)) {
|
|
83
|
+
return [];
|
|
84
|
+
}
|
|
85
|
+
return deny.filter((item) => typeof item === "string");
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return [];
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
function formatSimpleDiff(fileLabel, before, after) {
|
|
92
|
+
const lines = [];
|
|
93
|
+
lines.push(`--- a/${fileLabel}`);
|
|
94
|
+
lines.push(`+++ b/${fileLabel}`);
|
|
95
|
+
lines.push(before.trim().length === 0 ? "@@ new file @@" : "@@ update @@");
|
|
96
|
+
for (const line of after.split(/\r?\n/)) {
|
|
97
|
+
lines.push(`+${line}`);
|
|
98
|
+
}
|
|
99
|
+
return lines.join("\n");
|
|
100
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { FixAction } from "../types.js";
|
|
2
|
+
declare const CONFIG_RELATIVE = ".codex/config.toml";
|
|
3
|
+
export interface CodexConfigWritePreview {
|
|
4
|
+
targetRelativePath: typeof CONFIG_RELATIVE;
|
|
5
|
+
denyKeysToAdd: string[];
|
|
6
|
+
before: string;
|
|
7
|
+
after: string;
|
|
8
|
+
preview: string;
|
|
9
|
+
}
|
|
10
|
+
export declare function denyKeyForFixPattern(_pattern: string, evidencePath: string): string;
|
|
11
|
+
export declare function missingCodexDenyKeys(currentContent: string | null, denyKeys: string[]): string[];
|
|
12
|
+
/**
|
|
13
|
+
* Merge filesystem deny keys into project `.codex/config.toml`.
|
|
14
|
+
* Refuses when sandbox_mode is present (permission profiles do not compose).
|
|
15
|
+
* Refuses when default_permissions selects a built-in `:…` profile.
|
|
16
|
+
*/
|
|
17
|
+
export declare function buildCodexConfigContent(currentContent: string | null, denyKeysToAdd: string[]): string;
|
|
18
|
+
export declare function previewCodexConfigActions(currentContent: string | null, actions: FixAction[]): CodexConfigWritePreview | null;
|
|
19
|
+
export declare function readCodexConfig(root: string): Promise<string | null>;
|
|
20
|
+
export declare function writeCodexConfig(root: string, content: string): Promise<void>;
|
|
21
|
+
export declare function assertWritableCodexConfig(content: string): void;
|
|
22
|
+
export {};
|