@praneeth_54/agentdoctor 0.1.2-beta → 0.1.4-beta

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.
@@ -1,7 +1,36 @@
1
- const ENV_BASENAME = /^\.env(\..+)?$/i;
1
+ const TEMPLATE_BASENAMES = new Set([".env.example", ".env.sample", ".env.template", ".env.dist"]);
2
+ const BACKUP_BASENAMES = new Set([".env_backup", ".env_old", ".env_local"]);
3
+ /**
4
+ * Classify environment-like filenames. Precision over recall.
5
+ * Does not inspect file contents.
6
+ */
7
+ export function classifyEnvBasename(base) {
8
+ const normalized = base.trim();
9
+ if (!normalized.toLowerCase().startsWith(".env")) {
10
+ return null;
11
+ }
12
+ const lower = normalized.toLowerCase();
13
+ if (TEMPLATE_BASENAMES.has(lower)) {
14
+ return "template";
15
+ }
16
+ if (BACKUP_BASENAMES.has(lower)) {
17
+ return "backup";
18
+ }
19
+ if (lower === ".env") {
20
+ return "runtime";
21
+ }
22
+ // .env.<name> but not templates above
23
+ if (/^\.env\.[A-Za-z0-9_.-]+$/.test(normalized)) {
24
+ return "runtime";
25
+ }
26
+ return null;
27
+ }
2
28
  function isEnvFile(relativePath) {
3
29
  const base = relativePath.split("/").pop() ?? relativePath;
4
- return ENV_BASENAME.test(base);
30
+ return classifyEnvBasename(base);
31
+ }
32
+ function hasConfiguredOrDetectedAgents(context) {
33
+ return context.agents.some((a) => a.detected || a.configured);
5
34
  }
6
35
  function agentsWithoutClearExclusion(context, relativePath) {
7
36
  const affected = [];
@@ -50,16 +79,53 @@ async function claudeDeniesRead(context, relativePath) {
50
79
  export const envFileExposureRule = {
51
80
  id: "security/env-file-exposure",
52
81
  title: "Sensitive environment file may enter agent context",
53
- description: "Detects repository .env files when configured AI agents may be able to access them.",
82
+ description: "Detects repository .env files as repository risk and, when agents are configured, possible agent context exposure.",
54
83
  category: "security",
55
84
  severity: "critical",
56
85
  fixability: "review",
57
- rationale: "Environment files often contain credentials. If an AI coding agent can read them, secrets may enter model context.",
58
- recommendation: "Exclude the file from AI agent context (for example .cursorignore / Claude Code Read deny rules) and ensure it is not committed.",
86
+ rationale: "Environment files often contain credentials. Repository presence is a risk; agent readability can add exposure.",
87
+ recommendation: "Keep runtime .env files out of version control, exclude them from AI agent context, and rotate credentials if exposed.",
59
88
  async check(context) {
60
89
  const findings = [];
61
- const candidates = context.discovery.files.filter((f) => isEnvFile(f.relativePath));
62
- for (const file of candidates) {
90
+ const agentsPresent = hasConfiguredOrDetectedAgents(context);
91
+ for (const file of context.discovery.files) {
92
+ const kind = isEnvFile(file.relativePath);
93
+ if (!kind) {
94
+ continue;
95
+ }
96
+ if (kind === "template") {
97
+ findings.push({
98
+ ruleId: "security/env-file-exposure",
99
+ category: "security",
100
+ severity: "info",
101
+ title: "Environment template file present",
102
+ message: `Environment template file detected at ${file.relativePath}`,
103
+ whyItMatters: "Template env files are usually placeholders, but they can still document secret names. Filename classification only — contents were not inspected.",
104
+ recommendation: "Keep templates free of real credentials. Prefer documenting variable names without values.",
105
+ affectedAgents: [],
106
+ evidence: { path: file.relativePath, detail: "template" },
107
+ fixability: "review",
108
+ });
109
+ continue;
110
+ }
111
+ if (!agentsPresent) {
112
+ findings.push({
113
+ ruleId: "security/env-file-exposure",
114
+ category: "security",
115
+ severity: kind === "backup" ? "warning" : "critical",
116
+ title: "Sensitive environment file present in repository",
117
+ message: `Sensitive environment file present in the repository: ${file.relativePath}`,
118
+ whyItMatters: "No supported coding-agent configuration was detected, so agent-specific exposure was not asserted. The file is still high-risk repository material if tracked or shared.",
119
+ recommendation: "Keep runtime environment files out of version control and rotate credentials if the file may have been shared.",
120
+ affectedAgents: [],
121
+ evidence: {
122
+ path: file.relativePath,
123
+ detail: kind === "backup" ? "backup-no-agent" : "runtime-no-agent",
124
+ },
125
+ fixability: "review",
126
+ });
127
+ continue;
128
+ }
63
129
  let affected = agentsWithoutClearExclusion(context, file.relativePath);
64
130
  if (affected.includes("claude-code")) {
65
131
  if (await claudeDeniesRead(context, file.relativePath)) {
@@ -72,13 +138,16 @@ export const envFileExposureRule = {
72
138
  findings.push({
73
139
  ruleId: "security/env-file-exposure",
74
140
  category: "security",
75
- severity: "critical",
141
+ severity: kind === "backup" ? "warning" : "critical",
76
142
  title: "Sensitive environment file may enter agent context",
77
143
  message: `Sensitive environment file detected and no relevant exclusion was found for: ${file.relativePath}`,
78
144
  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.",
79
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.",
80
146
  affectedAgents: affected,
81
- evidence: { path: file.relativePath },
147
+ evidence: {
148
+ path: file.relativePath,
149
+ detail: kind === "backup" ? "backup" : "runtime",
150
+ },
82
151
  fixability: "review",
83
152
  });
84
153
  }
@@ -39,22 +39,24 @@ export const privateKeyFileRule = {
39
39
  async check(context) {
40
40
  const findings = [];
41
41
  const affected = context.agents.filter((a) => a.detected || a.configured).map((a) => a.id);
42
- const agents = affected.length > 0 ? affected : ["cursor", "claude-code", "codex"];
43
42
  for (const file of context.discovery.files) {
44
43
  const base = file.relativePath.split("/").pop() ?? file.relativePath;
45
44
  const label = classify(base);
46
45
  if (!label) {
47
46
  continue;
48
47
  }
48
+ const agentsPresent = affected.length > 0;
49
49
  findings.push({
50
50
  ruleId: "security/private-key-file",
51
51
  category: "security",
52
52
  severity: "critical",
53
53
  title: "Private key or credential file present in repository",
54
54
  message: `Possible ${label} detected at ${file.relativePath}`,
55
- whyItMatters: "Credential material in the working tree can be read by AI coding agents and may leak into model context or logs. Filename heuristics only — contents were not inspected.",
55
+ whyItMatters: agentsPresent
56
+ ? "Credential material in the working tree can be read by AI coding agents and may leak into model context or logs. Filename heuristics only — contents were not inspected."
57
+ : "Credential material in the working tree is high-risk repository content. No supported coding-agent configuration was detected, so agent-specific exposure was not asserted. Filename heuristics only — contents were not inspected.",
56
58
  recommendation: "Remove the file from the repository, rotate the credential, add ignore rules, and never commit replacements.",
57
- affectedAgents: [...agents],
59
+ affectedAgents: affected,
58
60
  evidence: { path: file.relativePath, detail: label },
59
61
  fixability: "manual",
60
62
  });
@@ -46,6 +46,11 @@ export async function scan(options = {}) {
46
46
  for (const error of ruleErrors) {
47
47
  warnings.push(sanitizeTerminalText(error));
48
48
  }
49
+ const agentsPresent = agents.some((a) => a.detected || a.configured);
50
+ const agentSecurityAnalysis = agentsPresent ? "full" : "limited";
51
+ if (agentSecurityAnalysis === "limited") {
52
+ warnings.push("No supported coding-agent configuration detected; agent-specific security exposure checks are limited.");
53
+ }
49
54
  return {
50
55
  version: PACKAGE_VERSION,
51
56
  repository,
@@ -54,6 +59,7 @@ export async function scan(options = {}) {
54
59
  summary,
55
60
  scores: null,
56
61
  scoringAvailable: false,
62
+ agentSecurityAnalysis,
57
63
  timing: {
58
64
  discoveryMs: discovery.elapsedMs,
59
65
  detectionMs,
@@ -3,6 +3,8 @@ export interface FrameworkDetectionInput {
3
3
  relativePaths: string[];
4
4
  packageJsonDependencies?: Record<string, string>;
5
5
  composerJsonRequire?: Record<string, string>;
6
+ /** Lowercase dependency/package names discovered from Python manifests. */
7
+ pythonDependencyNames?: string[];
6
8
  pubspecName?: string;
7
9
  }
8
10
  export interface FrameworkDetectionResult {
@@ -15,3 +17,4 @@ export interface FrameworkDetectionResult {
15
17
  */
16
18
  export declare function detectFrameworks(input: FrameworkDetectionInput): FrameworkDetectionResult;
17
19
  export declare function formatFramework(framework: FrameworkId): string;
20
+ export declare function formatFrameworks(ids: FrameworkId[]): string;
@@ -4,6 +4,9 @@ function hasDep(deps, name) {
4
4
  function hasFile(paths, ...names) {
5
5
  return names.some((name) => paths.has(name.toLowerCase()));
6
6
  }
7
+ function hasPythonDep(names, dep) {
8
+ return Boolean(names?.includes(dep.toLowerCase()));
9
+ }
7
10
  /**
8
11
  * Detect frameworks from config markers and dependency names.
9
12
  * Does not fail when nothing matches — returns unknown.
@@ -18,6 +21,7 @@ export function detectFrameworks(input) {
18
21
  ...(input.packageJsonDependencies ?? {}),
19
22
  };
20
23
  const composer = input.composerJsonRequire ?? {};
24
+ const pythonDeps = input.pythonDependencyNames ?? [];
21
25
  const detected = [];
22
26
  const isNext = hasFile(basenames, "next.config.js", "next.config.mjs", "next.config.ts", "next.config.cjs") ||
23
27
  hasDep(deps, "next");
@@ -63,15 +67,12 @@ export function detectFrameworks(input) {
63
67
  !isVue &&
64
68
  !isSvelte &&
65
69
  !isReact &&
66
- (hasFile(basenames, "package.json") || hasDep(deps, "express") === false) &&
67
70
  hasFile(basenames, "package.json")) {
68
- // Generic Node.js project with package.json but no web framework
69
71
  if (!detected.includes("nodejs")) {
70
72
  detected.push("nodejs");
71
73
  }
72
74
  }
73
75
  if (hasFile(basenames, "pubspec.yaml") || input.pubspecName !== undefined) {
74
- // Flutter: pubspec presence implies Flutter/Dart project context for detection
75
76
  if (hasFile(pathSet, "pubspec.yaml") || basenames.has("pubspec.yaml")) {
76
77
  detected.push("flutter");
77
78
  }
@@ -79,17 +80,19 @@ export function detectFrameworks(input) {
79
80
  if (hasDep(composer, "laravel/framework") || hasFile(basenames, "artisan")) {
80
81
  detected.push("laravel");
81
82
  }
82
- if (hasFile(basenames, "manage.py") ||
83
- [...pathSet].some((p) => p.endsWith("/settings.py") || p === "settings.py")) {
83
+ const isDjango = hasFile(basenames, "manage.py") ||
84
+ hasPythonDep(pythonDeps, "django") ||
85
+ [...pathSet].some((p) => p.endsWith("/requirements-django.txt"));
86
+ if (isDjango) {
84
87
  detected.push("django");
85
88
  }
86
- // FastAPI: path heuristics only (requirements/pyproject often lack a stable marker)
87
- if ([...pathSet].some((p) => p.includes("fastapi") || p.endsWith("requirements-fastapi.txt") || p === "app/main.py")) {
89
+ const hasFastApiDep = hasPythonDep(pythonDeps, "fastapi") ||
90
+ [...pathSet].some((p) => p.includes("fastapi") || p.endsWith("requirements-fastapi.txt"));
91
+ const hasFastApiAppLayout = [...pathSet].some((p) => p === "app/main.py" || p.endsWith("/app/main.py"));
92
+ if (hasFastApiDep && hasFastApiAppLayout) {
88
93
  detected.push("fastapi");
89
94
  }
90
- // Deduplicate while preserving order
91
95
  const unique = [...new Set(detected)];
92
- // Priority for primary framework
93
96
  const priority = [
94
97
  "nextjs",
95
98
  "nuxt",
@@ -146,3 +149,10 @@ export function formatFramework(framework) {
146
149
  return "Unknown";
147
150
  }
148
151
  }
152
+ export function formatFrameworks(ids) {
153
+ const unique = [...new Set(ids)].filter((id) => id !== "unknown");
154
+ if (unique.length === 0) {
155
+ return "Unknown";
156
+ }
157
+ return unique.map((id) => formatFramework(id)).join(", ");
158
+ }
@@ -12,3 +12,4 @@ export interface LanguageDetectionResult {
12
12
  */
13
13
  export declare function detectLanguages(input: LanguageDetectionInput): LanguageDetectionResult;
14
14
  export declare function formatLanguage(language: LanguageId): string;
15
+ export declare function formatLanguages(ids: LanguageId[]): string;
@@ -120,3 +120,10 @@ export function formatLanguage(language) {
120
120
  return "Unknown";
121
121
  }
122
122
  }
123
+ export function formatLanguages(ids) {
124
+ const unique = [...new Set(ids)].filter((id) => id !== "unknown");
125
+ if (unique.length === 0) {
126
+ return "Unknown";
127
+ }
128
+ return unique.map((id) => formatLanguage(id)).join(", ");
129
+ }
@@ -1,6 +1,10 @@
1
1
  import type { PackageManagerId } from "../types/index.js";
2
2
  export interface PackageManagerDetectionInput {
3
3
  relativePaths: string[];
4
+ /** True when a pyproject.toml contains a [tool.poetry] table. */
5
+ poetryToolDetected?: boolean;
6
+ /** True when a non-Poetry pyproject.toml (or similar) indicates a Python project. */
7
+ genericPyprojectDetected?: boolean;
4
8
  }
5
9
  export interface PackageManagerDetectionResult {
6
10
  packageManagers: PackageManagerId[];
@@ -9,6 +13,7 @@ export interface PackageManagerDetectionResult {
9
13
  /**
10
14
  * Detect package managers from lockfiles and manifests.
11
15
  * Primary selection prefers root-level evidence and avoids inventing certainty for mixed ecosystems.
16
+ * Poetry requires poetry.lock and/or [tool.poetry] — not bare pyproject.toml.
12
17
  */
13
18
  export declare function detectPackageManagers(input: PackageManagerDetectionInput): PackageManagerDetectionResult;
14
19
  export declare function formatPackageManager(id: PackageManagerId): string;
@@ -9,7 +9,7 @@ const MARKERS = [
9
9
  },
10
10
  { id: "composer", files: ["composer.lock", "composer.json"], lockfiles: ["composer.lock"] },
11
11
  { id: "pub", files: ["pubspec.yaml", "pubspec.lock"], lockfiles: ["pubspec.lock"] },
12
- { id: "poetry", files: ["poetry.lock", "pyproject.toml"], lockfiles: ["poetry.lock"] },
12
+ { id: "poetry", files: ["poetry.lock"], lockfiles: ["poetry.lock"] },
13
13
  {
14
14
  id: "pip",
15
15
  files: ["requirements.txt", "Pipfile", "Pipfile.lock"],
@@ -33,6 +33,7 @@ function isRootPath(relativePath) {
33
33
  /**
34
34
  * Detect package managers from lockfiles and manifests.
35
35
  * Primary selection prefers root-level evidence and avoids inventing certainty for mixed ecosystems.
36
+ * Poetry requires poetry.lock and/or [tool.poetry] — not bare pyproject.toml.
36
37
  */
37
38
  export function detectPackageManagers(input) {
38
39
  const paths = input.relativePaths;
@@ -64,6 +65,43 @@ export function detectPackageManagers(input) {
64
65
  }
65
66
  continue;
66
67
  }
68
+ if (marker.id === "poetry") {
69
+ const hasLock = basenames.has("poetry.lock");
70
+ const hasTool = input.poetryToolDetected === true;
71
+ if (!hasLock && !hasTool) {
72
+ continue;
73
+ }
74
+ found.push("poetry");
75
+ if (hasLock || (hasTool && rootBasenames.has("pyproject.toml"))) {
76
+ rootFound.push("poetry");
77
+ }
78
+ if (hasLock) {
79
+ lockFound.push("poetry");
80
+ if (rootBasenames.has("poetry.lock")) {
81
+ rootLockFound.push("poetry");
82
+ }
83
+ }
84
+ continue;
85
+ }
86
+ if (marker.id === "pip") {
87
+ const hasMarker = marker.files.some((f) => basenames.has(f.toLowerCase()));
88
+ const hasGenericPyproject = input.genericPyprojectDetected === true;
89
+ if (!hasMarker && !hasGenericPyproject) {
90
+ continue;
91
+ }
92
+ found.push("pip");
93
+ if (marker.files.some((f) => rootBasenames.has(f.toLowerCase())) ||
94
+ (hasGenericPyproject && rootBasenames.has("pyproject.toml"))) {
95
+ rootFound.push("pip");
96
+ }
97
+ if (marker.lockfiles.some((f) => basenames.has(f.toLowerCase()))) {
98
+ lockFound.push("pip");
99
+ }
100
+ if (marker.lockfiles.some((f) => rootBasenames.has(f.toLowerCase()))) {
101
+ rootLockFound.push("pip");
102
+ }
103
+ continue;
104
+ }
67
105
  const hasAny = marker.files.some((f) => basenames.has(f.toLowerCase()));
68
106
  if (!hasAny) {
69
107
  continue;
@@ -5,8 +5,13 @@ import { detectFrameworks } from "./framework.js";
5
5
  import { detectLanguages } from "./language.js";
6
6
  import { detectMonorepo } from "./monorepo.js";
7
7
  import { detectPackageManagers } from "./package-manager.js";
8
- import { isDirectory, pathExists, readJsonFile } from "../utils/fs.js";
8
+ import { extractPythonDependencyNames, pyprojectHasPoetryTool } from "./python-deps.js";
9
+ import { isDirectory, readJsonFile, readTextFile } from "../utils/fs.js";
9
10
  import { resolveRepoRoot } from "../utils/path.js";
11
+ function basename(relativePath) {
12
+ const parts = relativePath.split("/");
13
+ return parts[parts.length - 1] ?? relativePath;
14
+ }
10
15
  /**
11
16
  * Discover files and detect repository characteristics.
12
17
  */
@@ -28,32 +33,77 @@ export async function detectProject(cwd, maxFileSizeBytes = DEFAULT_MAX_FILE_SIZ
28
33
  }
29
34
  diagnostics.push(`Permission/read issue: ${err}`);
30
35
  }
31
- let packageJson;
32
- const packageJsonPath = path.join(root, "package.json");
33
- const rootPackageExists = await pathExists(packageJsonPath);
34
- if (rootPackageExists) {
35
- const packageResult = await readJsonFile(packageJsonPath, maxFileSizeBytes);
36
- if (packageResult.ok) {
37
- packageJson = packageResult.data;
36
+ const mergedPackageDeps = {};
37
+ let rootWorkspaces;
38
+ const packageJsonPaths = relativePaths.filter((p) => basename(p).toLowerCase() === "package.json");
39
+ for (const relative of packageJsonPaths) {
40
+ const absolute = path.join(root, relative);
41
+ const packageResult = await readJsonFile(absolute, maxFileSizeBytes);
42
+ if (!packageResult.ok) {
43
+ if (relative === "package.json") {
44
+ diagnostics.push(`Malformed package.json: ${packageResult.error}`);
45
+ }
46
+ continue;
38
47
  }
39
- else {
40
- diagnostics.push(`Malformed package.json: ${packageResult.error}`);
48
+ Object.assign(mergedPackageDeps, packageResult.data.dependencies ?? {}, packageResult.data.devDependencies ?? {}, packageResult.data.peerDependencies ?? {});
49
+ if (relative === "package.json") {
50
+ rootWorkspaces = packageResult.data.workspaces;
41
51
  }
42
52
  }
43
- const deps = {
44
- ...(packageJson?.dependencies ?? {}),
45
- ...(packageJson?.devDependencies ?? {}),
46
- ...(packageJson?.peerDependencies ?? {}),
47
- };
53
+ const composerRequire = {};
54
+ const composerPaths = relativePaths.filter((p) => basename(p).toLowerCase() === "composer.json");
55
+ for (const relative of composerPaths) {
56
+ const absolute = path.join(root, relative);
57
+ const result = await readJsonFile(absolute, maxFileSizeBytes);
58
+ if (!result.ok) {
59
+ continue;
60
+ }
61
+ Object.assign(composerRequire, result.data.require ?? {}, result.data["require-dev"] ?? {});
62
+ }
63
+ const pythonDependencyNames = new Set();
64
+ let poetryToolDetected = false;
65
+ let genericPyprojectDetected = false;
66
+ for (const relative of relativePaths) {
67
+ const base = basename(relative).toLowerCase();
68
+ if (base !== "pyproject.toml" &&
69
+ base !== "requirements.txt" &&
70
+ !/^requirements(-[a-z0-9_-]+)?\.txt$/i.test(base)) {
71
+ continue;
72
+ }
73
+ const text = await readTextFile(path.join(root, relative), maxFileSizeBytes);
74
+ if (!text) {
75
+ continue;
76
+ }
77
+ for (const name of extractPythonDependencyNames(text)) {
78
+ pythonDependencyNames.add(name);
79
+ }
80
+ if (base === "pyproject.toml") {
81
+ if (pyprojectHasPoetryTool(text)) {
82
+ poetryToolDetected = true;
83
+ }
84
+ else {
85
+ genericPyprojectDetected = true;
86
+ }
87
+ }
88
+ }
89
+ if (relativePaths.some((p) => basename(p).toLowerCase() === "pipfile")) {
90
+ genericPyprojectDetected = true;
91
+ }
48
92
  const languages = detectLanguages({ relativePaths });
49
93
  const frameworks = detectFrameworks({
50
94
  relativePaths,
51
- packageJsonDependencies: deps,
95
+ packageJsonDependencies: mergedPackageDeps,
96
+ composerJsonRequire: composerRequire,
97
+ pythonDependencyNames: [...pythonDependencyNames],
98
+ });
99
+ const packageManagers = detectPackageManagers({
100
+ relativePaths,
101
+ poetryToolDetected,
102
+ genericPyprojectDetected,
52
103
  });
53
- const packageManagers = detectPackageManagers({ relativePaths });
54
104
  const monorepo = detectMonorepo({
55
105
  relativePaths,
56
- packageJsonWorkspaces: packageJson?.workspaces,
106
+ packageJsonWorkspaces: rootWorkspaces,
57
107
  });
58
108
  const repository = {
59
109
  root,
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Lightweight Python dependency name extraction from manifests.
3
+ * Filename/table semantics only — does not evaluate environments.
4
+ */
5
+ export declare function pyprojectHasPoetryTool(text: string): boolean;
6
+ export declare function extractPythonDependencyNames(text: string): string[];
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Lightweight Python dependency name extraction from manifests.
3
+ * Filename/table semantics only — does not evaluate environments.
4
+ */
5
+ const SKIP_KEYS = new Set([
6
+ "python",
7
+ "package-mode",
8
+ "packages",
9
+ "include",
10
+ "exclude",
11
+ "readme",
12
+ "homepage",
13
+ "repository",
14
+ "documentation",
15
+ "keywords",
16
+ "classifiers",
17
+ "license",
18
+ "authors",
19
+ "maintainers",
20
+ "dependencies",
21
+ "dev-dependencies",
22
+ "group",
23
+ ]);
24
+ export function pyprojectHasPoetryTool(text) {
25
+ return /\[tool\.poetry(?:\.|\])/m.test(text);
26
+ }
27
+ export function extractPythonDependencyNames(text) {
28
+ const names = new Set();
29
+ for (const match of text.matchAll(/^\s*([A-Za-z0-9][A-Za-z0-9._-]*)\s*=\s*["'{[]/gm)) {
30
+ const name = (match[1] ?? "").toLowerCase().replace(/_/g, "-");
31
+ if (name && !SKIP_KEYS.has(name) && !name.startsWith("tool.")) {
32
+ names.add(name);
33
+ }
34
+ }
35
+ for (const match of text.matchAll(/["']([A-Za-z0-9][A-Za-z0-9._-]*)(?:\[[^\]]*\])?(?:\s*[><=! ~^,;]|["'])/g)) {
36
+ const name = (match[1] ?? "").toLowerCase().replace(/_/g, "-");
37
+ if (name && !SKIP_KEYS.has(name)) {
38
+ names.add(name);
39
+ }
40
+ }
41
+ for (const rawLine of text.split(/\r?\n/)) {
42
+ const line = rawLine.trim();
43
+ if (!line || line.startsWith("#") || line.startsWith("-")) {
44
+ continue;
45
+ }
46
+ const name = line
47
+ .split(/[><=![;@\s]/)[0]
48
+ ?.toLowerCase()
49
+ .replace(/_/g, "-");
50
+ if (name && /^[a-z0-9][a-z0-9._-]*$/.test(name) && !SKIP_KEYS.has(name)) {
51
+ names.add(name);
52
+ }
53
+ }
54
+ return [...names];
55
+ }
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  export { scan } from "./core/scanner/scan.js";
2
2
  export { PACKAGE_VERSION } from "./constants.js";
3
- export type { ScanOptions, ScanResult, RepositoryInfo, Finding, FindingsSummary, Scores, AgentPresence, AgentDetectionResult, CliOptions, ExitCode, } from "./types/index.js";
3
+ export type { ScanOptions, ScanResult, RepositoryInfo, Finding, FindingsSummary, Scores, AgentPresence, AgentDetectionResult, AgentSecurityAnalysisMode, CliOptions, ExitCode, } from "./types/index.js";
4
4
  export { EXIT_CODES } from "./types/index.js";
5
5
  export { agentRegistry } from "./agents/registry.js";
6
6
  export { detectAgents } from "./agents/detect-agents.js";
@@ -74,6 +74,7 @@ export function renderJsonReport(result) {
74
74
  summary: result.summary,
75
75
  scoringAvailable: result.scoringAvailable,
76
76
  scores: result.scores,
77
+ agentSecurityAnalysis: result.agentSecurityAnalysis,
77
78
  timing: result.timing,
78
79
  diagnostics: {
79
80
  warnings: result.diagnostics.warnings.map(sanitizeForOutput),
@@ -1,12 +1,13 @@
1
1
  import { PACKAGE_VERSION } from "../../constants.js";
2
2
  import { AGENT_DISPLAY_NAMES } from "../../constants.js";
3
- import { formatFramework } from "../../detectors/framework.js";
4
- import { formatLanguage } from "../../detectors/language.js";
3
+ import { formatFramework, formatFrameworks } from "../../detectors/framework.js";
4
+ import { formatLanguage, formatLanguages } from "../../detectors/language.js";
5
5
  import { formatMonorepo } from "../../detectors/monorepo.js";
6
6
  import { formatPackageManager, formatPackageManagers } from "../../detectors/package-manager.js";
7
7
  import { colors, symbolFail, symbolInfo, symbolOk, symbolWarn } from "../../utils/colors.js";
8
8
  import { sanitizeTerminalText } from "../../security/redaction.js";
9
9
  const DEFAULT_FINDING_LIMIT = 12;
10
+ const LIMITED_ANALYSIS_NOTE = "No supported coding-agent configuration detected; agent-specific security exposure checks are limited.";
10
11
  function groupFindings(findings) {
11
12
  return {
12
13
  critical: findings.filter((f) => f.severity === "critical"),
@@ -26,6 +27,14 @@ function agentGlyph(configured, detected) {
26
27
  function formatAgents(agents) {
27
28
  return agents.map((id) => AGENT_DISPLAY_NAMES[id] ?? id).join(", ");
28
29
  }
30
+ function isMultiStack(result) {
31
+ if (result.repository.monorepo === "multi-project") {
32
+ return true;
33
+ }
34
+ const frameworks = result.repository.frameworks.filter((id) => id !== "unknown");
35
+ const managers = result.repository.packageManagers.filter((id) => id !== "unknown");
36
+ return frameworks.length > 1 || managers.length > 1;
37
+ }
29
38
  function renderFindingBlock(finding, verbose) {
30
39
  const lines = [];
31
40
  const icon = finding.severity === "critical"
@@ -62,13 +71,20 @@ export function renderTerminalReport(result, options = {}) {
62
71
  lines.push(colors.dim("Scanning repository..."));
63
72
  lines.push("");
64
73
  lines.push(colors.bold("Repository"));
65
- lines.push(` Framework: ${sanitizeTerminalText(formatFramework(result.repository.primaryFramework))}`);
66
- lines.push(` Language: ${sanitizeTerminalText(formatLanguage(result.repository.primaryLanguage))}`);
67
- const packageManagerLabel = result.repository.packageManagers.length > 1 ||
68
- result.repository.primaryPackageManager === "unknown"
69
- ? formatPackageManagers(result.repository.packageManagers)
70
- : formatPackageManager(result.repository.primaryPackageManager);
71
- lines.push(` Package manager: ${sanitizeTerminalText(packageManagerLabel)}`);
74
+ if (isMultiStack(result)) {
75
+ lines.push(` Languages: ${sanitizeTerminalText(formatLanguages(result.repository.languages))}`);
76
+ lines.push(` Frameworks: ${sanitizeTerminalText(formatFrameworks(result.repository.frameworks))}`);
77
+ lines.push(` Package managers: ${sanitizeTerminalText(formatPackageManagers(result.repository.packageManagers))}`);
78
+ }
79
+ else {
80
+ lines.push(` Framework: ${sanitizeTerminalText(formatFramework(result.repository.primaryFramework))}`);
81
+ lines.push(` Language: ${sanitizeTerminalText(formatLanguage(result.repository.primaryLanguage))}`);
82
+ const packageManagerLabel = result.repository.packageManagers.length > 1 ||
83
+ result.repository.primaryPackageManager === "unknown"
84
+ ? formatPackageManagers(result.repository.packageManagers)
85
+ : formatPackageManager(result.repository.primaryPackageManager);
86
+ lines.push(` Package manager: ${sanitizeTerminalText(packageManagerLabel)}`);
87
+ }
72
88
  if (result.repository.monorepo !== "none") {
73
89
  lines.push(` Monorepo: ${sanitizeTerminalText(formatMonorepo(result.repository.monorepo))}`);
74
90
  }
@@ -90,10 +106,20 @@ export function renderTerminalReport(result, options = {}) {
90
106
  lines.push("");
91
107
  const grouped = groupFindings(result.findings);
92
108
  const total = result.summary.total;
109
+ const limited = result.agentSecurityAnalysis === "limited";
93
110
  lines.push(colors.bold("Findings"));
94
111
  lines.push("");
112
+ if (limited) {
113
+ lines.push(` ${symbolWarn()} ${sanitizeTerminalText(LIMITED_ANALYSIS_NOTE)}`);
114
+ lines.push("");
115
+ }
95
116
  if (total === 0) {
96
- lines.push(` ${symbolOk()} No findings`);
117
+ if (limited) {
118
+ lines.push(` ${symbolOk()} No agent-configuration findings`);
119
+ }
120
+ else {
121
+ lines.push(` ${symbolOk()} No findings`);
122
+ }
97
123
  lines.push("");
98
124
  }
99
125
  else {
@@ -94,6 +94,11 @@ export interface ScanDiagnostics {
94
94
  warnings: string[];
95
95
  errors: string[];
96
96
  }
97
+ /**
98
+ * Whether agent-specific security exposure checks had enough agent configuration
99
+ * to run meaningfully. Additive field — absent consumers may ignore it.
100
+ */
101
+ export type AgentSecurityAnalysisMode = "full" | "limited";
97
102
  export interface ScanResult {
98
103
  version: string;
99
104
  repository: RepositoryInfo;
@@ -107,6 +112,11 @@ export interface ScanResult {
107
112
  scores: Scores | null;
108
113
  /** False until the readiness scoring model ships. */
109
114
  scoringAvailable: boolean;
115
+ /**
116
+ * `limited` when no supported coding agent is detected/configured.
117
+ * Repository-risk checks may still run; agent exposure claims are not asserted.
118
+ */
119
+ agentSecurityAnalysis: AgentSecurityAnalysisMode;
110
120
  timing: TimingInfo;
111
121
  diagnostics: ScanDiagnostics;
112
122
  }