@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.
Files changed (65) hide show
  1. package/CHANGELOG.md +76 -1
  2. package/README.md +60 -30
  3. package/dist/agents/inspect.js +58 -51
  4. package/dist/cli/commands/explain.js +1 -1
  5. package/dist/cli/commands/fix.js +22 -6
  6. package/dist/cli/commands/scan.d.ts +5 -0
  7. package/dist/cli/commands/scan.js +25 -6
  8. package/dist/cli/commands/verify.d.ts +6 -0
  9. package/dist/cli/commands/verify.js +25 -11
  10. package/dist/cli/program.js +48 -7
  11. package/dist/constants.d.ts +1 -1
  12. package/dist/constants.js +1 -1
  13. package/dist/core/fix/apply.d.ts +5 -1
  14. package/dist/core/fix/apply.js +34 -14
  15. package/dist/core/fix/plan.d.ts +1 -1
  16. package/dist/core/fix/plan.js +110 -3
  17. package/dist/core/fix/render.d.ts +2 -0
  18. package/dist/core/fix/render.js +57 -9
  19. package/dist/core/fix/run.js +5 -1
  20. package/dist/core/fix/types.d.ts +1 -1
  21. package/dist/core/fix/types.js +5 -1
  22. package/dist/core/fix/writers/claude-settings.d.ts +16 -0
  23. package/dist/core/fix/writers/claude-settings.js +100 -0
  24. package/dist/core/fix/writers/codex-config.d.ts +22 -0
  25. package/dist/core/fix/writers/codex-config.js +183 -0
  26. package/dist/core/fix/writers/cursorignore.js +2 -8
  27. package/dist/core/path-resolution/index.d.ts +5 -0
  28. package/dist/core/path-resolution/index.js +5 -0
  29. package/dist/core/path-resolution/prepare.d.ts +33 -0
  30. package/dist/core/path-resolution/prepare.js +98 -0
  31. package/dist/core/policy/evaluate.d.ts +44 -0
  32. package/dist/core/policy/evaluate.js +120 -0
  33. package/dist/core/rules/claude-deny.d.ts +8 -0
  34. package/dist/core/rules/claude-deny.js +39 -0
  35. package/dist/core/rules/codex-deny.d.ts +5 -0
  36. package/dist/core/rules/codex-deny.js +42 -0
  37. package/dist/core/rules/context/generated-directory.js +43 -9
  38. package/dist/core/rules/context/large-log-file.js +27 -10
  39. package/dist/core/rules/ignore.js +2 -5
  40. package/dist/core/rules/instructions/missing-path-reference.d.ts +5 -1
  41. package/dist/core/rules/instructions/missing-path-reference.js +24 -6
  42. package/dist/core/rules/path-kind.d.ts +15 -2
  43. package/dist/core/rules/path-kind.js +113 -10
  44. package/dist/core/rules/security/env-file-exposure.js +9 -24
  45. package/dist/core/rules/text-cache.js +46 -40
  46. package/dist/discovery/files.js +7 -2
  47. package/dist/discovery/log-like.d.ts +2 -0
  48. package/dist/discovery/log-like.js +7 -0
  49. package/dist/index.d.ts +2 -0
  50. package/dist/index.js +1 -0
  51. package/dist/reporters/github/annotations.d.ts +8 -0
  52. package/dist/reporters/github/annotations.js +42 -0
  53. package/dist/reporters/github/emit.d.ts +23 -0
  54. package/dist/reporters/github/emit.js +31 -0
  55. package/dist/reporters/github/summary.d.ts +20 -0
  56. package/dist/reporters/github/summary.js +111 -0
  57. package/dist/reporters/terminal/report.d.ts +5 -0
  58. package/dist/reporters/terminal/report.js +68 -5
  59. package/dist/reporters/verify/terminal.d.ts +4 -0
  60. package/dist/reporters/verify/terminal.js +26 -0
  61. package/dist/utils/fs.d.ts +5 -0
  62. package/dist/utils/fs.js +34 -0
  63. package/dist/utils/path.d.ts +5 -0
  64. package/dist/utils/path.js +16 -0
  65. 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 { isSampleOrTestPath, isSourceNamedArtifactCollision } from "../path-kind.js";
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 isExcluded(context, relativePath) {
17
- const variants = [relativePath, `${relativePath}/`];
18
- return variants.some((candidate) => context.ignore.matchesGitignore(candidate) || context.ignore.matchesCursorignore(candidate));
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 affected = context.agents.filter((a) => a.configured || a.detected).map((a) => a.id);
32
- if (affected.length === 0) {
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 (isExcluded(context, relativePath)) {
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, for Cursor, .cursorignore if you need agent-specific exclusion beyond gitignore.`,
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 (isExcluded(context, relativePath)) {
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 affected = context.agents.filter((a) => a.configured || a.detected).map((a) => a.id);
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
- const base = file.relativePath.split("/").pop() ?? file.relativePath;
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
- // Skip if clearly ignored for Cursor and no other agents configured
26
- if (context.ignore.matchesGitignore(file.relativePath) ||
27
- context.ignore.matchesCursorignore(file.relativePath)) {
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.length > 0 ? affected : ["cursor", "claude-code", "codex"],
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.replace(/^\/+/, "").replace(/\/+$/, "");
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 (avoids nesting under `.cursor/rules/`)
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 (avoids nesting under `.cursor/rules/`)
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: [rootRelative],
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 resolved = resolveInstructionPathReference(file.relativePath, candidate);
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 \`${candidate}\`, which escapes the repository root`,
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=${candidate}` },
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 \`${candidate}\`, but that path does not exist`,
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 `internal/build` that are source trees
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
- * Parent dirs that host source code packages/modules, not generated output trees.
28
- * `packages/foo/dist` is still treated as generated (parent is the package name).
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 SOURCE_CODE_PARENT_DIRS = new Set([
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
- return normalized.split("/").some((segment) => SAMPLE_DIRECTORY_NAMES.has(segment.toLowerCase()));
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 `internal/build` that are source trees
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
- const parent = parts[parts.length - 2].toLowerCase();
76
- return SOURCE_CODE_PARENT_DIRS.has(parent);
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 claudeDeniesRead(context, file.relativePath)) {
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 or a Claude Code Read deny rule), keep the file out of version control, and rotate any credentials that may have been exposed.",
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,