@praneeth_54/agentdoctor 0.3.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 +76 -1
- package/README.md +60 -30
- package/dist/agents/inspect.js +58 -51
- package/dist/cli/commands/explain.js +1 -1
- package/dist/cli/commands/fix.js +22 -6
- package/dist/cli/commands/scan.d.ts +5 -0
- package/dist/cli/commands/scan.js +25 -6
- package/dist/cli/commands/verify.d.ts +6 -0
- package/dist/cli/commands/verify.js +25 -11
- package/dist/cli/program.js +48 -7
- package/dist/constants.d.ts +1 -1
- package/dist/constants.js +1 -1
- package/dist/core/fix/apply.d.ts +5 -1
- package/dist/core/fix/apply.js +34 -14
- package/dist/core/fix/plan.d.ts +1 -1
- package/dist/core/fix/plan.js +110 -3
- package/dist/core/fix/render.d.ts +2 -0
- package/dist/core/fix/render.js +57 -9
- package/dist/core/fix/run.js +5 -1
- package/dist/core/fix/types.d.ts +1 -1
- package/dist/core/fix/types.js +5 -1
- 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.js +2 -8
- 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 +43 -9
- 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 +24 -6
- package/dist/core/rules/path-kind.d.ts +15 -2
- package/dist/core/rules/path-kind.js +113 -10
- package/dist/core/rules/security/env-file-exposure.js +9 -24
- package/dist/core/rules/text-cache.js +46 -40
- 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 +2 -0
- package/dist/index.js +1 -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 +68 -5
- package/dist/reporters/verify/terminal.d.ts +4 -0
- package/dist/reporters/verify/terminal.js +26 -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,39 @@
|
|
|
1
|
+
import { toRepoRelativePosix } from "../../utils/path.js";
|
|
2
|
+
/**
|
|
3
|
+
* Claude Code Read deny rule for a repository-relative path.
|
|
4
|
+
* Directories use a recursive Read(./path/**) form.
|
|
5
|
+
*/
|
|
6
|
+
export function claudeReadDenyRule(relativePath, kind = "file") {
|
|
7
|
+
const normalized = toRepoRelativePosix(relativePath);
|
|
8
|
+
if (kind === "directory") {
|
|
9
|
+
return `Read(./${normalized}/**)`;
|
|
10
|
+
}
|
|
11
|
+
return `Read(./${normalized})`;
|
|
12
|
+
}
|
|
13
|
+
export function settingsTextDeniesPath(settingsText, relativePath) {
|
|
14
|
+
const normalized = toRepoRelativePosix(relativePath);
|
|
15
|
+
const base = normalized.split("/").pop() ?? normalized;
|
|
16
|
+
const patterns = [
|
|
17
|
+
`Read(./${normalized})`,
|
|
18
|
+
`Read(${normalized})`,
|
|
19
|
+
`Read(./${normalized}/**)`,
|
|
20
|
+
`Read(./${normalized}/)`,
|
|
21
|
+
`Read(/**/${base})`,
|
|
22
|
+
];
|
|
23
|
+
return patterns.some((p) => settingsText.includes(p));
|
|
24
|
+
}
|
|
25
|
+
export async function claudeDeniesPath(context, relativePath) {
|
|
26
|
+
const settingsFiles = context.agents
|
|
27
|
+
.find((a) => a.id === "claude-code")
|
|
28
|
+
?.configFiles.filter((f) => f.kind === "claude-settings" || f.kind === "claude-settings-local") ?? [];
|
|
29
|
+
for (const file of settingsFiles) {
|
|
30
|
+
const cached = await context.textCache.read(file.relativePath);
|
|
31
|
+
if (!cached.text) {
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (settingsTextDeniesPath(cached.text, relativePath)) {
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { RuleContext } from "./types.js";
|
|
2
|
+
export declare function codexDenyKey(relativePath: string): string;
|
|
3
|
+
/** True when config.toml already denies this path (or path/**) under a filesystem table. */
|
|
4
|
+
export declare function configTextDeniesPath(configText: string, relativePath: string): boolean;
|
|
5
|
+
export declare function codexDeniesPath(context: RuleContext, relativePath: string): Promise<boolean>;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { toRepoRelativePosix } from "../../utils/path.js";
|
|
2
|
+
export function codexDenyKey(relativePath) {
|
|
3
|
+
return toRepoRelativePosix(relativePath);
|
|
4
|
+
}
|
|
5
|
+
/** True when config.toml already denies this path (or path/**) under a filesystem table. */
|
|
6
|
+
export function configTextDeniesPath(configText, relativePath) {
|
|
7
|
+
const normalized = toRepoRelativePosix(relativePath);
|
|
8
|
+
if (!normalized) {
|
|
9
|
+
return false;
|
|
10
|
+
}
|
|
11
|
+
const escaped = escapeRegExp(normalized);
|
|
12
|
+
const patterns = [
|
|
13
|
+
new RegExp(`^\\s*"${escaped}"\\s*=\\s*"deny"\\s*$`, "m"),
|
|
14
|
+
new RegExp(`^\\s*"${escaped}/\\*\\*"\\s*=\\s*"deny"\\s*$`, "m"),
|
|
15
|
+
new RegExp(`^\\s*'${escaped}'\\s*=\\s*'deny'\\s*$`, "m"),
|
|
16
|
+
new RegExp(`^\\s*'${escaped}/\\*\\*'\\s*=\\s*'deny'\\s*$`, "m"),
|
|
17
|
+
];
|
|
18
|
+
return patterns.some((re) => re.test(configText));
|
|
19
|
+
}
|
|
20
|
+
export async function codexDeniesPath(context, relativePath) {
|
|
21
|
+
const configFiles = context.agents
|
|
22
|
+
.find((a) => a.id === "codex")
|
|
23
|
+
?.configFiles.filter((f) => f.kind === "codex-config") ?? [];
|
|
24
|
+
for (const file of configFiles) {
|
|
25
|
+
const cached = await context.textCache.read(file.relativePath);
|
|
26
|
+
if (!cached.text) {
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
if (configTextDeniesPath(cached.text, relativePath)) {
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
// Config may exist but not yet be attached when only AGENTS.md detected; check path directly.
|
|
34
|
+
const direct = await context.textCache.read(".codex/config.toml");
|
|
35
|
+
if (direct.text && configTextDeniesPath(direct.text, relativePath)) {
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
function escapeRegExp(value) {
|
|
41
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
42
|
+
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { claudeDeniesPath } from "../claude-deny.js";
|
|
2
|
+
import { codexDeniesPath } from "../codex-deny.js";
|
|
3
|
+
import { isCheckedInGithubActionDist, isSampleOrTestPath, isSourceNamedArtifactCollision, } from "../path-kind.js";
|
|
2
4
|
/** Directories commonly generated/build-related. vendor/ is ecosystem-aware. */
|
|
3
5
|
const GENERATED = [
|
|
4
6
|
{ name: "dist", label: "build output" },
|
|
@@ -13,9 +15,27 @@ const GENERATED = [
|
|
|
13
15
|
function isGeneratedPath(relativePath, name) {
|
|
14
16
|
return relativePath === name || relativePath.endsWith(`/${name}`);
|
|
15
17
|
}
|
|
16
|
-
function
|
|
17
|
-
|
|
18
|
-
|
|
18
|
+
function isGitIgnored(context, relativePath) {
|
|
19
|
+
return (context.ignore.matchesGitignore(relativePath) ||
|
|
20
|
+
context.ignore.matchesGitignore(`${relativePath}/`));
|
|
21
|
+
}
|
|
22
|
+
async function agentsStillExposed(context, relativePath, configured) {
|
|
23
|
+
const exposed = [];
|
|
24
|
+
for (const agent of configured) {
|
|
25
|
+
if (agent === "cursor" &&
|
|
26
|
+
(context.ignore.matchesCursorignore(relativePath) ||
|
|
27
|
+
context.ignore.matchesCursorignore(`${relativePath}/`))) {
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
if (agent === "claude-code" && (await claudeDeniesPath(context, relativePath))) {
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (agent === "codex" && (await codexDeniesPath(context, relativePath))) {
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
exposed.push(agent);
|
|
37
|
+
}
|
|
38
|
+
return exposed;
|
|
19
39
|
}
|
|
20
40
|
export const generatedDirectoryRule = {
|
|
21
41
|
id: "context/generated-directory",
|
|
@@ -28,8 +48,8 @@ export const generatedDirectoryRule = {
|
|
|
28
48
|
recommendation: "Ensure generated directories are listed in .gitignore and agent ignore configuration where applicable.",
|
|
29
49
|
async check(context) {
|
|
30
50
|
const findings = [];
|
|
31
|
-
const
|
|
32
|
-
if (
|
|
51
|
+
const configured = context.agents.filter((a) => a.configured || a.detected).map((a) => a.id);
|
|
52
|
+
if (configured.length === 0) {
|
|
33
53
|
return [];
|
|
34
54
|
}
|
|
35
55
|
for (const entry of GENERATED) {
|
|
@@ -41,7 +61,14 @@ export const generatedDirectoryRule = {
|
|
|
41
61
|
if (isSourceNamedArtifactCollision(relativePath)) {
|
|
42
62
|
continue;
|
|
43
63
|
}
|
|
44
|
-
if (
|
|
64
|
+
if (isCheckedInGithubActionDist(relativePath)) {
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (isGitIgnored(context, relativePath)) {
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
const affected = await agentsStillExposed(context, relativePath, configured);
|
|
71
|
+
if (affected.length === 0) {
|
|
45
72
|
continue;
|
|
46
73
|
}
|
|
47
74
|
findings.push({
|
|
@@ -51,7 +78,7 @@ export const generatedDirectoryRule = {
|
|
|
51
78
|
title: "Generated directory may enter agent context",
|
|
52
79
|
message: `${relativePath}/ (${entry.label}) is present and no project ignore pattern was detected`,
|
|
53
80
|
whyItMatters: "Generated directories are usually low-value for coding agents and can bloat indexing/context when not excluded.",
|
|
54
|
-
recommendation: `Add an ignore pattern covering ${relativePath}/ to .gitignore and
|
|
81
|
+
recommendation: `Add an ignore pattern covering ${relativePath}/ to .gitignore and agent exclusions (.cursorignore, Claude Code Read deny, and/or Codex filesystem deny).`,
|
|
55
82
|
affectedAgents: affected,
|
|
56
83
|
evidence: { path: relativePath },
|
|
57
84
|
fixability: "safe",
|
|
@@ -67,7 +94,14 @@ export const generatedDirectoryRule = {
|
|
|
67
94
|
if (isSampleOrTestPath(relativePath)) {
|
|
68
95
|
continue;
|
|
69
96
|
}
|
|
70
|
-
if (
|
|
97
|
+
if (isSourceNamedArtifactCollision(relativePath)) {
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (isGitIgnored(context, relativePath)) {
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
const affected = await agentsStillExposed(context, relativePath, configured);
|
|
104
|
+
if (affected.length === 0) {
|
|
71
105
|
continue;
|
|
72
106
|
}
|
|
73
107
|
findings.push({
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { isLogLikePath } from "../../../discovery/log-like.js";
|
|
2
|
+
import { claudeDeniesPath } from "../claude-deny.js";
|
|
3
|
+
import { codexDeniesPath } from "../codex-deny.js";
|
|
1
4
|
import { THRESHOLDS } from "../thresholds.js";
|
|
2
|
-
const LOG_LIKE = /\.(log|out|dump)$/i;
|
|
3
|
-
const HEAVY_NAME = /(^|\/)(debug|trace|coverage-final|chrome-devtools|heapdump)/i;
|
|
4
5
|
export const largeLogFileRule = {
|
|
5
6
|
id: "context/large-log-file",
|
|
6
7
|
title: "Large log or dump file in repository",
|
|
@@ -12,19 +13,35 @@ export const largeLogFileRule = {
|
|
|
12
13
|
recommendation: "Delete or ignore large logs; add them to .gitignore and agent ignore files.",
|
|
13
14
|
async check(context) {
|
|
14
15
|
const findings = [];
|
|
15
|
-
const
|
|
16
|
+
const configured = context.agents
|
|
17
|
+
.filter((a) => a.configured || a.detected)
|
|
18
|
+
.map((a) => a.id);
|
|
16
19
|
for (const file of context.discovery.files) {
|
|
17
|
-
|
|
18
|
-
const looksLog = LOG_LIKE.test(base) || HEAVY_NAME.test(file.relativePath);
|
|
19
|
-
if (!looksLog) {
|
|
20
|
+
if (!isLogLikePath(file.relativePath)) {
|
|
20
21
|
continue;
|
|
21
22
|
}
|
|
22
23
|
if (file.sizeBytes < THRESHOLDS.largeLogBytes) {
|
|
23
24
|
continue;
|
|
24
25
|
}
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
if (context.ignore.matchesGitignore(file.relativePath)) {
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
const defaultAgents = ["cursor", "claude-code", "codex"];
|
|
30
|
+
const candidates = configured.length > 0 ? configured : defaultAgents;
|
|
31
|
+
const affected = [];
|
|
32
|
+
for (const agent of candidates) {
|
|
33
|
+
if (agent === "cursor" && context.ignore.matchesCursorignore(file.relativePath)) {
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
if (agent === "claude-code" && (await claudeDeniesPath(context, file.relativePath))) {
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
if (agent === "codex" && (await codexDeniesPath(context, file.relativePath))) {
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
affected.push(agent);
|
|
43
|
+
}
|
|
44
|
+
if (affected.length === 0) {
|
|
28
45
|
continue;
|
|
29
46
|
}
|
|
30
47
|
findings.push({
|
|
@@ -35,7 +52,7 @@ export const largeLogFileRule = {
|
|
|
35
52
|
message: `${file.relativePath} is ${(file.sizeBytes / 1024).toFixed(0)} KB`,
|
|
36
53
|
whyItMatters: "Large logs and dumps rarely help coding agents and can crowd out useful source context.",
|
|
37
54
|
recommendation: "Remove or ignore this file for both git and AI agent tooling.",
|
|
38
|
-
affectedAgents: affected
|
|
55
|
+
affectedAgents: affected,
|
|
39
56
|
evidence: { path: file.relativePath, detail: `${file.sizeBytes} bytes` },
|
|
40
57
|
fixability: "safe",
|
|
41
58
|
});
|
|
@@ -1,7 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
* Minimal gitignore-style pattern matching for .gitignore / .cursorignore.
|
|
3
|
-
* Supports common patterns used in agent ignore files — not a full gitignore clone.
|
|
4
|
-
*/
|
|
1
|
+
import { toRepoRelativePosix } from "../../utils/path.js";
|
|
5
2
|
function normalizePattern(pattern) {
|
|
6
3
|
const trimmed = pattern.trim();
|
|
7
4
|
if (!trimmed || trimmed.startsWith("#")) {
|
|
@@ -48,7 +45,7 @@ export function matchIgnorePattern(relativePath, pattern) {
|
|
|
48
45
|
if (pat.startsWith("!")) {
|
|
49
46
|
pat = pat.slice(1);
|
|
50
47
|
}
|
|
51
|
-
const pathNorm = relativePath
|
|
48
|
+
const pathNorm = toRepoRelativePosix(relativePath);
|
|
52
49
|
let matched = false;
|
|
53
50
|
if (pat.endsWith("/")) {
|
|
54
51
|
const dir = pat.slice(0, -1);
|
|
@@ -16,7 +16,11 @@ export declare function extractPathCandidates(text: string): string[];
|
|
|
16
16
|
/**
|
|
17
17
|
* Resolve a candidate path reference with repository-aware semantics:
|
|
18
18
|
* - `./` and `../` → relative to the instruction file directory
|
|
19
|
-
* - otherwise → repository-root relative
|
|
19
|
+
* - otherwise → repository-root relative, plus the same path under the
|
|
20
|
+
* instruction file directory when nested (monorepo package docs)
|
|
21
|
+
*
|
|
22
|
+
* Does not search sibling packages: a root `AGENTS.md` reference must still
|
|
23
|
+
* exist at the repository root (or via `./` / `../`).
|
|
20
24
|
*/
|
|
21
25
|
export declare function resolveInstructionPathReference(instructionRelativePath: string, candidate: string): ResolvedPathReference;
|
|
22
26
|
export declare const missingPathReferenceRule: RuleDefinition;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { pathExistsInsideRoot } from "../../../agents/inspect.js";
|
|
3
|
+
import { preparePathReference } from "../../path-resolution/index.js";
|
|
3
4
|
import { isPathInsideRoot } from "../../../utils/path.js";
|
|
4
5
|
const INSTRUCTION_KINDS = new Set([
|
|
5
6
|
"cursor-rule-mdc",
|
|
@@ -164,7 +165,11 @@ function isEscapingRepoRelative(relativePath) {
|
|
|
164
165
|
/**
|
|
165
166
|
* Resolve a candidate path reference with repository-aware semantics:
|
|
166
167
|
* - `./` and `../` → relative to the instruction file directory
|
|
167
|
-
* - otherwise → repository-root relative
|
|
168
|
+
* - otherwise → repository-root relative, plus the same path under the
|
|
169
|
+
* instruction file directory when nested (monorepo package docs)
|
|
170
|
+
*
|
|
171
|
+
* Does not search sibling packages: a root `AGENTS.md` reference must still
|
|
172
|
+
* exist at the repository root (or via `./` / `../`).
|
|
168
173
|
*/
|
|
169
174
|
export function resolveInstructionPathReference(instructionRelativePath, candidate) {
|
|
170
175
|
const instructionDir = path.posix.dirname(instructionRelativePath);
|
|
@@ -185,9 +190,18 @@ export function resolveInstructionPathReference(instructionRelativePath, candida
|
|
|
185
190
|
if (isEscapingRepoRelative(rootRelative) || rootRelative.startsWith("/")) {
|
|
186
191
|
return { status: "escape", attempted: rootRelative };
|
|
187
192
|
}
|
|
193
|
+
const pathsToCheck = [rootRelative];
|
|
194
|
+
if (instructionDir !== ".") {
|
|
195
|
+
const fromInstruction = path.posix.normalize(path.posix.join(instructionDir, rootRelative));
|
|
196
|
+
if (!isEscapingRepoRelative(fromInstruction) &&
|
|
197
|
+
!fromInstruction.startsWith("/") &&
|
|
198
|
+
fromInstruction !== rootRelative) {
|
|
199
|
+
pathsToCheck.push(fromInstruction);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
188
202
|
return {
|
|
189
203
|
status: "ok",
|
|
190
|
-
pathsToCheck
|
|
204
|
+
pathsToCheck,
|
|
191
205
|
primaryRelative: rootRelative,
|
|
192
206
|
};
|
|
193
207
|
}
|
|
@@ -218,20 +232,24 @@ export const missingPathReferenceRule = {
|
|
|
218
232
|
}
|
|
219
233
|
const candidates = extractPathCandidates(cached.text);
|
|
220
234
|
for (const candidate of candidates) {
|
|
221
|
-
const
|
|
235
|
+
const prepared = preparePathReference(candidate);
|
|
236
|
+
if (prepared.status === "reject") {
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
const resolved = resolveInstructionPathReference(file.relativePath, prepared.normalized);
|
|
222
240
|
if (resolved.status === "escape") {
|
|
223
241
|
findings.push({
|
|
224
242
|
ruleId: "instructions/missing-path-reference",
|
|
225
243
|
category: "instructions",
|
|
226
244
|
severity: "warning",
|
|
227
245
|
title: "Instruction references a missing path",
|
|
228
|
-
message: `${file.relativePath} references \`${
|
|
246
|
+
message: `${file.relativePath} references \`${prepared.original}\`, which escapes the repository root`,
|
|
229
247
|
whyItMatters: "Path references outside the repository cannot be validated and may confuse agents.",
|
|
230
248
|
recommendation: "Use repository-relative paths only.",
|
|
231
249
|
affectedAgents: context.agents
|
|
232
250
|
.filter((a) => a.configPaths.includes(file.relativePath))
|
|
233
251
|
.map((a) => a.id),
|
|
234
|
-
evidence: { path: file.relativePath, detail: `ref=${
|
|
252
|
+
evidence: { path: file.relativePath, detail: `ref=${prepared.original}` },
|
|
235
253
|
fixability: "manual",
|
|
236
254
|
});
|
|
237
255
|
continue;
|
|
@@ -255,7 +273,7 @@ export const missingPathReferenceRule = {
|
|
|
255
273
|
category: "instructions",
|
|
256
274
|
severity: "warning",
|
|
257
275
|
title: "Instruction references a missing path",
|
|
258
|
-
message: `${file.relativePath} references \`${
|
|
276
|
+
message: `${file.relativePath} references \`${prepared.original}\`, but that path does not exist`,
|
|
259
277
|
whyItMatters: "Agents may follow documented paths that no longer exist, causing failed reads and wasted context.",
|
|
260
278
|
recommendation: "Fix the path or remove the stale reference.",
|
|
261
279
|
affectedAgents: context.agents
|
|
@@ -2,13 +2,26 @@
|
|
|
2
2
|
* Shared path classification for rule precision.
|
|
3
3
|
* Paths under intentional sample/test/demo trees are not production secrets for agent readiness.
|
|
4
4
|
*/
|
|
5
|
+
/**
|
|
6
|
+
* True when a single path segment denotes sample/test/fixture material.
|
|
7
|
+
* Handles exact names, leading-underscore variants, hyphen/underscore compounds,
|
|
8
|
+
* and camelCase forms (dockerTest, testFixtures) without matching production roots.
|
|
9
|
+
*/
|
|
10
|
+
export declare function isSampleOrTestSegment(segment: string): boolean;
|
|
5
11
|
/**
|
|
6
12
|
* True when any path segment marks fixture, test, example, or sample material.
|
|
7
13
|
* Uses POSIX relative paths (discovery output).
|
|
14
|
+
* Filenames never qualify alone (root `test-private-key.pem` must still flag).
|
|
8
15
|
*/
|
|
9
16
|
export declare function isSampleOrTestPath(relativePath: string): boolean;
|
|
10
17
|
/**
|
|
11
|
-
* True for paths like `scripts/build` or `
|
|
12
|
-
* named after a common artifact directory, not generated output.
|
|
18
|
+
* True for paths like `scripts/build`, `internal/build`, or `packages/foo/src/core/build`
|
|
19
|
+
* that are source trees named after a common artifact directory, not generated output.
|
|
20
|
+
* Any source-marker ancestor qualifies (not only the immediate parent).
|
|
13
21
|
*/
|
|
14
22
|
export declare function isSourceNamedArtifactCollision(relativePath: string): boolean;
|
|
23
|
+
/**
|
|
24
|
+
* True for checked-in GitHub Action package output (`.github/actions/<name>/dist`).
|
|
25
|
+
* These are intentional publish artifacts, not ambient generated junk.
|
|
26
|
+
*/
|
|
27
|
+
export declare function isCheckedInGithubActionDist(relativePath: string): boolean;
|
|
@@ -17,17 +17,61 @@ const SAMPLE_DIRECTORY_NAMES = new Set([
|
|
|
17
17
|
"samples",
|
|
18
18
|
"test",
|
|
19
19
|
"tests",
|
|
20
|
+
"testing",
|
|
20
21
|
"__tests__",
|
|
21
22
|
"testdata",
|
|
22
23
|
"test_data",
|
|
24
|
+
"spec",
|
|
25
|
+
"specs",
|
|
26
|
+
"demo",
|
|
27
|
+
"demos",
|
|
28
|
+
"e2e",
|
|
29
|
+
"bench",
|
|
30
|
+
"benchmark",
|
|
31
|
+
"benchmarks",
|
|
32
|
+
"playground",
|
|
33
|
+
"playgrounds",
|
|
34
|
+
"sandbox",
|
|
35
|
+
"sandboxes",
|
|
36
|
+
]);
|
|
37
|
+
/** Strong tokens that mark intentional non-production material when present in a segment. */
|
|
38
|
+
const STRONG_TEST_TOKENS = new Set([
|
|
39
|
+
"test",
|
|
40
|
+
"tests",
|
|
41
|
+
"testing",
|
|
42
|
+
"fixture",
|
|
43
|
+
"fixtures",
|
|
44
|
+
"mock",
|
|
45
|
+
"mocks",
|
|
46
|
+
"testdata",
|
|
47
|
+
"spec",
|
|
48
|
+
"specs",
|
|
49
|
+
"e2e",
|
|
50
|
+
"bench",
|
|
51
|
+
"benchmark",
|
|
52
|
+
"benchmarks",
|
|
53
|
+
"playground",
|
|
54
|
+
"playgrounds",
|
|
55
|
+
"sandbox",
|
|
56
|
+
"sandboxes",
|
|
57
|
+
"example",
|
|
58
|
+
"examples",
|
|
59
|
+
"sample",
|
|
60
|
+
"samples",
|
|
61
|
+
"demo",
|
|
62
|
+
"demos",
|
|
23
63
|
]);
|
|
24
|
-
/** Artifact dir names that commonly collide with source packages (`scripts/build`, `internal/build`). */
|
|
25
|
-
const ARTIFACT_NAMES_WITH_SOURCE_COLLISIONS = new Set(["build", "target"]);
|
|
26
64
|
/**
|
|
27
|
-
*
|
|
28
|
-
*
|
|
65
|
+
* Artifact dir names that commonly collide with source packages
|
|
66
|
+
* (scripts/build, packages/foo/src/core/build, src/.../vendor).
|
|
67
|
+
* Intentionally excludes dist — paths like src/js/dist are often real build output.
|
|
29
68
|
*/
|
|
30
|
-
const
|
|
69
|
+
const ARTIFACT_NAMES_WITH_SOURCE_COLLISIONS = new Set(["build", "target", "vendor"]);
|
|
70
|
+
/**
|
|
71
|
+
* Dir names that host source code packages/modules, not generated output trees.
|
|
72
|
+
* packages/foo/dist is still treated as generated (no source-marker ancestor).
|
|
73
|
+
*/
|
|
74
|
+
const SOURCE_CODE_MARKER_DIRS = new Set([
|
|
31
75
|
"bin",
|
|
32
76
|
"cmd",
|
|
33
77
|
"internal",
|
|
@@ -37,9 +81,49 @@ const SOURCE_CODE_PARENT_DIRS = new Set([
|
|
|
37
81
|
"scripts",
|
|
38
82
|
"src",
|
|
39
83
|
]);
|
|
84
|
+
function splitCamelCase(segment) {
|
|
85
|
+
return segment
|
|
86
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1\0$2")
|
|
87
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1\0$2")
|
|
88
|
+
.split("\0")
|
|
89
|
+
.filter(Boolean);
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* True when a single path segment denotes sample/test/fixture material.
|
|
93
|
+
* Handles exact names, leading-underscore variants, hyphen/underscore compounds,
|
|
94
|
+
* and camelCase forms (dockerTest, testFixtures) without matching production roots.
|
|
95
|
+
*/
|
|
96
|
+
export function isSampleOrTestSegment(segment) {
|
|
97
|
+
if (!segment) {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
const lower = segment.toLowerCase();
|
|
101
|
+
if (SAMPLE_DIRECTORY_NAMES.has(lower)) {
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
// _fixture, _fixtures, _testdata, __fixture
|
|
105
|
+
const stripped = lower.replace(/^_+/, "");
|
|
106
|
+
if (stripped !== lower && SAMPLE_DIRECTORY_NAMES.has(stripped)) {
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
109
|
+
// integration-test, smoke-test, docker-test, integration_tests, test-certs
|
|
110
|
+
const hyphenTokens = lower.split(/[-_]+/).filter(Boolean);
|
|
111
|
+
if (hyphenTokens.length >= 2 && hyphenTokens.some((token) => STRONG_TEST_TOKENS.has(token))) {
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
// dockerTest, testFixtures, httpTestServer, integrationTests
|
|
115
|
+
if (/[A-Z]/.test(segment)) {
|
|
116
|
+
const camelTokens = splitCamelCase(segment).map((part) => part.toLowerCase());
|
|
117
|
+
if (camelTokens.some((token) => STRONG_TEST_TOKENS.has(token))) {
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
40
123
|
/**
|
|
41
124
|
* True when any path segment marks fixture, test, example, or sample material.
|
|
42
125
|
* Uses POSIX relative paths (discovery output).
|
|
126
|
+
* Filenames never qualify alone (root `test-private-key.pem` must still flag).
|
|
43
127
|
*/
|
|
44
128
|
export function isSampleOrTestPath(relativePath) {
|
|
45
129
|
if (!relativePath) {
|
|
@@ -49,11 +133,20 @@ export function isSampleOrTestPath(relativePath) {
|
|
|
49
133
|
if (!normalized) {
|
|
50
134
|
return false;
|
|
51
135
|
}
|
|
52
|
-
|
|
136
|
+
const parts = normalized.split("/").filter(Boolean);
|
|
137
|
+
return parts.some((segment, index) => {
|
|
138
|
+
const isLast = index === parts.length - 1;
|
|
139
|
+
const looksLikeFile = isLast && /\.[A-Za-z0-9_+-]+$/.test(segment);
|
|
140
|
+
if (looksLikeFile) {
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
return isSampleOrTestSegment(segment);
|
|
144
|
+
});
|
|
53
145
|
}
|
|
54
146
|
/**
|
|
55
|
-
* True for paths like `scripts/build` or `
|
|
56
|
-
* named after a common artifact directory, not generated output.
|
|
147
|
+
* True for paths like `scripts/build`, `internal/build`, or `packages/foo/src/core/build`
|
|
148
|
+
* that are source trees named after a common artifact directory, not generated output.
|
|
149
|
+
* Any source-marker ancestor qualifies (not only the immediate parent).
|
|
57
150
|
*/
|
|
58
151
|
export function isSourceNamedArtifactCollision(relativePath) {
|
|
59
152
|
if (!relativePath) {
|
|
@@ -72,6 +165,16 @@ export function isSourceNamedArtifactCollision(relativePath) {
|
|
|
72
165
|
if (!ARTIFACT_NAMES_WITH_SOURCE_COLLISIONS.has(leaf)) {
|
|
73
166
|
return false;
|
|
74
167
|
}
|
|
75
|
-
|
|
76
|
-
|
|
168
|
+
return parts.slice(0, -1).some((segment) => SOURCE_CODE_MARKER_DIRS.has(segment.toLowerCase()));
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* True for checked-in GitHub Action package output (`.github/actions/<name>/dist`).
|
|
172
|
+
* These are intentional publish artifacts, not ambient generated junk.
|
|
173
|
+
*/
|
|
174
|
+
export function isCheckedInGithubActionDist(relativePath) {
|
|
175
|
+
if (!relativePath) {
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
const normalized = relativePath.replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+$/, "");
|
|
179
|
+
return /^\.github\/actions\/[^/]+\/dist$/i.test(normalized);
|
|
77
180
|
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { claudeDeniesPath } from "../claude-deny.js";
|
|
2
|
+
import { codexDeniesPath } from "../codex-deny.js";
|
|
1
3
|
import { isSampleOrTestPath } from "../path-kind.js";
|
|
2
4
|
const TEMPLATE_BASENAMES = new Set([".env.example", ".env.sample", ".env.template", ".env.dist"]);
|
|
3
5
|
const BACKUP_BASENAMES = new Set([".env_backup", ".env_old", ".env_local"]);
|
|
@@ -62,28 +64,6 @@ function agentsWithoutClearExclusion(context, relativePath) {
|
|
|
62
64
|
}
|
|
63
65
|
return affected;
|
|
64
66
|
}
|
|
65
|
-
async function claudeDeniesRead(context, relativePath) {
|
|
66
|
-
const settingsFiles = context.agents
|
|
67
|
-
.find((a) => a.id === "claude-code")
|
|
68
|
-
?.configFiles.filter((f) => f.kind === "claude-settings" || f.kind === "claude-settings-local") ?? [];
|
|
69
|
-
for (const file of settingsFiles) {
|
|
70
|
-
const cached = await context.textCache.read(file.relativePath);
|
|
71
|
-
if (!cached.text)
|
|
72
|
-
continue;
|
|
73
|
-
const patterns = [
|
|
74
|
-
`Read(./${relativePath})`,
|
|
75
|
-
`Read(${relativePath})`,
|
|
76
|
-
`Read(/**/${relativePath.split("/").pop()})`,
|
|
77
|
-
];
|
|
78
|
-
if (patterns.some((p) => cached.text.includes(p))) {
|
|
79
|
-
return true;
|
|
80
|
-
}
|
|
81
|
-
if (/"deny"\s*:\s*\[[^\]]*"Read"\s*[,\]]/.test(cached.text)) {
|
|
82
|
-
return true;
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
return false;
|
|
86
|
-
}
|
|
87
67
|
export const envFileExposureRule = {
|
|
88
68
|
id: "security/env-file-exposure",
|
|
89
69
|
title: "Sensitive environment file may enter agent context",
|
|
@@ -128,10 +108,15 @@ export const envFileExposureRule = {
|
|
|
128
108
|
}
|
|
129
109
|
let affected = agentsWithoutClearExclusion(context, file.relativePath);
|
|
130
110
|
if (affected.includes("claude-code")) {
|
|
131
|
-
if (await
|
|
111
|
+
if (await claudeDeniesPath(context, file.relativePath)) {
|
|
132
112
|
affected = affected.filter((a) => a !== "claude-code");
|
|
133
113
|
}
|
|
134
114
|
}
|
|
115
|
+
if (affected.includes("codex")) {
|
|
116
|
+
if (await codexDeniesPath(context, file.relativePath)) {
|
|
117
|
+
affected = affected.filter((a) => a !== "codex");
|
|
118
|
+
}
|
|
119
|
+
}
|
|
135
120
|
if (affected.length === 0) {
|
|
136
121
|
continue;
|
|
137
122
|
}
|
|
@@ -142,7 +127,7 @@ export const envFileExposureRule = {
|
|
|
142
127
|
title: "Sensitive environment file may enter agent context",
|
|
143
128
|
message: `Sensitive environment file detected and no relevant exclusion was found for: ${file.relativePath}`,
|
|
144
129
|
whyItMatters: "Environment files frequently hold API keys and credentials. If readable by an AI coding agent, those values may be included in prompts or logs.",
|
|
145
|
-
recommendation: "Add an agent-specific exclusion (for example .cursorignore
|
|
130
|
+
recommendation: "Add an agent-specific exclusion (for example .cursorignore, a Claude Code Read deny rule, or a Codex filesystem deny), keep the file out of version control, and rotate any credentials that may have been exposed.",
|
|
146
131
|
affectedAgents: affected,
|
|
147
132
|
evidence: {
|
|
148
133
|
path: file.relativePath,
|