@praneeth_54/agentdoctor 0.1.0-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.
- package/CHANGELOG.md +58 -0
- package/LICENSE +21 -0
- package/README.md +348 -0
- package/dist/agents/claude/adapter.d.ts +1 -0
- package/dist/agents/claude/adapter.js +1 -0
- package/dist/agents/claude/detector.d.ts +16 -0
- package/dist/agents/claude/detector.js +225 -0
- package/dist/agents/codex/adapter.d.ts +1 -0
- package/dist/agents/codex/adapter.js +1 -0
- package/dist/agents/codex/detector.d.ts +17 -0
- package/dist/agents/codex/detector.js +151 -0
- package/dist/agents/cursor/adapter.d.ts +1 -0
- package/dist/agents/cursor/adapter.js +1 -0
- package/dist/agents/cursor/detector.d.ts +17 -0
- package/dist/agents/cursor/detector.js +198 -0
- package/dist/agents/detect-agents.d.ts +17 -0
- package/dist/agents/detect-agents.js +23 -0
- package/dist/agents/inspect.d.ts +36 -0
- package/dist/agents/inspect.js +165 -0
- package/dist/agents/registry.d.ts +7 -0
- package/dist/agents/registry.js +11 -0
- package/dist/agents/types.d.ts +52 -0
- package/dist/agents/types.js +15 -0
- package/dist/cli/commands/doctor.d.ts +5 -0
- package/dist/cli/commands/doctor.js +22 -0
- package/dist/cli/commands/explain.d.ts +5 -0
- package/dist/cli/commands/explain.js +51 -0
- package/dist/cli/commands/fix.d.ts +8 -0
- package/dist/cli/commands/fix.js +19 -0
- package/dist/cli/commands/scan.d.ts +10 -0
- package/dist/cli/commands/scan.js +59 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +3 -0
- package/dist/cli/program.d.ts +3 -0
- package/dist/cli/program.js +124 -0
- package/dist/constants.d.ts +5 -0
- package/dist/constants.js +38 -0
- package/dist/core/mcp/parse.d.ts +7 -0
- package/dist/core/mcp/parse.js +303 -0
- package/dist/core/mcp/types.d.ts +33 -0
- package/dist/core/mcp/types.js +1 -0
- package/dist/core/rules/build-context.d.ts +10 -0
- package/dist/core/rules/build-context.js +31 -0
- package/dist/core/rules/context/generated-directory.d.ts +2 -0
- package/dist/core/rules/context/generated-directory.js +89 -0
- package/dist/core/rules/context/large-instruction-file.d.ts +2 -0
- package/dist/core/rules/context/large-instruction-file.js +56 -0
- package/dist/core/rules/context/large-log-file.d.ts +2 -0
- package/dist/core/rules/context/large-log-file.js +45 -0
- package/dist/core/rules/dedupe.d.ts +13 -0
- package/dist/core/rules/dedupe.js +53 -0
- package/dist/core/rules/ignore.d.ts +23 -0
- package/dist/core/rules/ignore.js +97 -0
- package/dist/core/rules/instructions/duplicate-content.d.ts +2 -0
- package/dist/core/rules/instructions/duplicate-content.js +99 -0
- package/dist/core/rules/instructions/empty-instructions.d.ts +2 -0
- package/dist/core/rules/instructions/empty-instructions.js +53 -0
- package/dist/core/rules/instructions/missing-path-reference.d.ts +2 -0
- package/dist/core/rules/instructions/missing-path-reference.js +131 -0
- package/dist/core/rules/mcp/mcp-rules.d.ts +3 -0
- package/dist/core/rules/mcp/mcp-rules.js +83 -0
- package/dist/core/rules/registry.d.ts +6 -0
- package/dist/core/rules/registry.js +31 -0
- package/dist/core/rules/run-rules.d.ts +16 -0
- package/dist/core/rules/run-rules.js +36 -0
- package/dist/core/rules/security/claude-bypass-permissions.d.ts +7 -0
- package/dist/core/rules/security/claude-bypass-permissions.js +52 -0
- package/dist/core/rules/security/env-file-exposure.d.ts +2 -0
- package/dist/core/rules/security/env-file-exposure.js +87 -0
- package/dist/core/rules/security/mcp-broad-filesystem.d.ts +2 -0
- package/dist/core/rules/security/mcp-broad-filesystem.js +42 -0
- package/dist/core/rules/security/private-key-file.d.ts +2 -0
- package/dist/core/rules/security/private-key-file.js +60 -0
- package/dist/core/rules/text-cache.d.ts +19 -0
- package/dist/core/rules/text-cache.js +112 -0
- package/dist/core/rules/thresholds.d.ts +13 -0
- package/dist/core/rules/thresholds.js +13 -0
- package/dist/core/rules/types.d.ts +57 -0
- package/dist/core/rules/types.js +23 -0
- package/dist/core/scanner/scan.d.ts +7 -0
- package/dist/core/scanner/scan.js +70 -0
- package/dist/core/scoring/placeholder.d.ts +7 -0
- package/dist/core/scoring/placeholder.js +29 -0
- package/dist/detectors/framework.d.ts +17 -0
- package/dist/detectors/framework.js +148 -0
- package/dist/detectors/language.d.ts +14 -0
- package/dist/detectors/language.js +122 -0
- package/dist/detectors/monorepo.d.ts +13 -0
- package/dist/detectors/monorepo.js +44 -0
- package/dist/detectors/package-manager.d.ts +14 -0
- package/dist/detectors/package-manager.js +78 -0
- package/dist/detectors/project.d.ts +10 -0
- package/dist/detectors/project.js +60 -0
- package/dist/discovery/files.d.ts +15 -0
- package/dist/discovery/files.js +118 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +7 -0
- package/dist/reporters/json/report.d.ts +5 -0
- package/dist/reporters/json/report.js +84 -0
- package/dist/reporters/terminal/report.d.ts +8 -0
- package/dist/reporters/terminal/report.js +147 -0
- package/dist/security/redaction.d.ts +9 -0
- package/dist/security/redaction.js +26 -0
- package/dist/types/index.d.ts +137 -0
- package/dist/types/index.js +6 -0
- package/dist/utils/colors.d.ts +14 -0
- package/dist/utils/colors.js +26 -0
- package/dist/utils/fs.d.ts +17 -0
- package/dist/utils/fs.js +63 -0
- package/dist/utils/path.d.ts +17 -0
- package/dist/utils/path.js +37 -0
- package/package.json +80 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
const ENV_BASENAME = /^\.env(\..+)?$/i;
|
|
2
|
+
function isEnvFile(relativePath) {
|
|
3
|
+
const base = relativePath.split("/").pop() ?? relativePath;
|
|
4
|
+
return ENV_BASENAME.test(base);
|
|
5
|
+
}
|
|
6
|
+
function agentsWithoutClearExclusion(context, relativePath) {
|
|
7
|
+
const affected = [];
|
|
8
|
+
for (const agent of context.agents) {
|
|
9
|
+
if (!agent.detected && !agent.configured) {
|
|
10
|
+
continue;
|
|
11
|
+
}
|
|
12
|
+
if (agent.id === "cursor") {
|
|
13
|
+
if (!context.ignore.isExcludedForCursor(relativePath)) {
|
|
14
|
+
affected.push("cursor");
|
|
15
|
+
}
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
if (agent.id === "claude-code") {
|
|
19
|
+
affected.push("claude-code");
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
if (agent.id === "codex") {
|
|
23
|
+
affected.push("codex");
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return affected;
|
|
27
|
+
}
|
|
28
|
+
async function claudeDeniesRead(context, relativePath) {
|
|
29
|
+
const settingsFiles = context.agents
|
|
30
|
+
.find((a) => a.id === "claude-code")
|
|
31
|
+
?.configFiles.filter((f) => f.kind === "claude-settings" || f.kind === "claude-settings-local") ?? [];
|
|
32
|
+
for (const file of settingsFiles) {
|
|
33
|
+
const cached = await context.textCache.read(file.relativePath);
|
|
34
|
+
if (!cached.text)
|
|
35
|
+
continue;
|
|
36
|
+
const patterns = [
|
|
37
|
+
`Read(./${relativePath})`,
|
|
38
|
+
`Read(${relativePath})`,
|
|
39
|
+
`Read(/**/${relativePath.split("/").pop()})`,
|
|
40
|
+
];
|
|
41
|
+
if (patterns.some((p) => cached.text.includes(p))) {
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
if (/"deny"\s*:\s*\[[^\]]*"Read"\s*[,\]]/.test(cached.text)) {
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
export const envFileExposureRule = {
|
|
51
|
+
id: "security/env-file-exposure",
|
|
52
|
+
title: "Sensitive environment file may enter agent context",
|
|
53
|
+
description: "Detects repository .env files when configured AI agents may be able to access them.",
|
|
54
|
+
category: "security",
|
|
55
|
+
severity: "critical",
|
|
56
|
+
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.",
|
|
59
|
+
async check(context) {
|
|
60
|
+
const findings = [];
|
|
61
|
+
const candidates = context.discovery.files.filter((f) => isEnvFile(f.relativePath));
|
|
62
|
+
for (const file of candidates) {
|
|
63
|
+
let affected = agentsWithoutClearExclusion(context, file.relativePath);
|
|
64
|
+
if (affected.includes("claude-code")) {
|
|
65
|
+
if (await claudeDeniesRead(context, file.relativePath)) {
|
|
66
|
+
affected = affected.filter((a) => a !== "claude-code");
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (affected.length === 0) {
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
findings.push({
|
|
73
|
+
ruleId: "security/env-file-exposure",
|
|
74
|
+
category: "security",
|
|
75
|
+
severity: "critical",
|
|
76
|
+
title: "Sensitive environment file may enter agent context",
|
|
77
|
+
message: `Sensitive environment file detected and no relevant exclusion was found for: ${file.relativePath}`,
|
|
78
|
+
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
|
+
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
|
+
affectedAgents: affected,
|
|
81
|
+
evidence: { path: file.relativePath },
|
|
82
|
+
fixability: "review",
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
return findings;
|
|
86
|
+
},
|
|
87
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { classifyFilesystemScope } from "../../mcp/parse.js";
|
|
2
|
+
export const mcpBroadFilesystemRule = {
|
|
3
|
+
id: "security/mcp-broad-filesystem",
|
|
4
|
+
title: "MCP filesystem server has broad or out-of-repo scope",
|
|
5
|
+
description: "Detects MCP filesystem-related path arguments that point at / , home, or paths outside the repository.",
|
|
6
|
+
category: "security",
|
|
7
|
+
severity: "critical",
|
|
8
|
+
fixability: "review",
|
|
9
|
+
rationale: "An MCP filesystem server scoped to the entire machine can expose secrets far beyond the project.",
|
|
10
|
+
recommendation: "Limit filesystem MCP servers to the repository root (or a specific subdirectory) and avoid / , ~, and $HOME scopes.",
|
|
11
|
+
async check(context) {
|
|
12
|
+
const findings = [];
|
|
13
|
+
for (const server of context.mcpServers) {
|
|
14
|
+
if (server.parseError) {
|
|
15
|
+
continue;
|
|
16
|
+
}
|
|
17
|
+
const scopes = server.filesystemScopes ?? [];
|
|
18
|
+
for (const scope of scopes) {
|
|
19
|
+
const classification = classifyFilesystemScope(scope, context.root);
|
|
20
|
+
if (classification !== "broad" && classification !== "outside") {
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
findings.push({
|
|
24
|
+
ruleId: "security/mcp-broad-filesystem",
|
|
25
|
+
category: "security",
|
|
26
|
+
severity: "critical",
|
|
27
|
+
title: "MCP filesystem server has broad or out-of-repo scope",
|
|
28
|
+
message: `MCP server "${server.name}" configures filesystem scope "${scope}" (${classification})`,
|
|
29
|
+
whyItMatters: "Broad filesystem MCP scopes can let an agent read files outside the project, including credentials and unrelated personal data.",
|
|
30
|
+
recommendation: "Point the filesystem server at the repository directory only. Do not use /, ~, or $HOME.",
|
|
31
|
+
affectedAgents: [server.sourceAgent],
|
|
32
|
+
evidence: {
|
|
33
|
+
path: server.sourcePath,
|
|
34
|
+
detail: `server=${server.name}; scope=${scope}`,
|
|
35
|
+
},
|
|
36
|
+
fixability: "review",
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return findings;
|
|
41
|
+
},
|
|
42
|
+
};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
const PRIVATE_KEY_PATTERNS = [
|
|
2
|
+
{ test: (b) => /^id_rsa$/i.test(b), label: "SSH private key" },
|
|
3
|
+
{ test: (b) => /^id_ed25519$/i.test(b), label: "SSH private key" },
|
|
4
|
+
{ test: (b) => /^id_ecdsa$/i.test(b), label: "SSH private key" },
|
|
5
|
+
{
|
|
6
|
+
test: (b) => /\.pem$/i.test(b) && !/cert|certificate|public|ca-bundle/i.test(b),
|
|
7
|
+
label: "PEM private key",
|
|
8
|
+
},
|
|
9
|
+
{ test: (b) => /\.p12$/i.test(b), label: "PKCS#12 credential bundle" },
|
|
10
|
+
{ test: (b) => /\.pfx$/i.test(b), label: "PKCS#12 credential bundle" },
|
|
11
|
+
{ test: (b) => /^credentials\.json$/i.test(b), label: "credentials file" },
|
|
12
|
+
{ test: (b) => /service-account.*\.json$/i.test(b), label: "service account key" },
|
|
13
|
+
{
|
|
14
|
+
test: (b) => /\.key$/i.test(b) && !/public|pub/i.test(b),
|
|
15
|
+
label: "private key file",
|
|
16
|
+
},
|
|
17
|
+
];
|
|
18
|
+
function classify(base) {
|
|
19
|
+
for (const pattern of PRIVATE_KEY_PATTERNS) {
|
|
20
|
+
if (pattern.test(base)) {
|
|
21
|
+
return pattern.label;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
export const privateKeyFileRule = {
|
|
27
|
+
id: "security/private-key-file",
|
|
28
|
+
title: "Private key or credential file present in repository",
|
|
29
|
+
description: "Detects high-confidence private key / credential filenames inside the repository tree.",
|
|
30
|
+
category: "security",
|
|
31
|
+
severity: "critical",
|
|
32
|
+
fixability: "manual",
|
|
33
|
+
rationale: "Private keys and service-account credentials in a repository are high-risk if accessible to agents or collaborators.",
|
|
34
|
+
recommendation: "Remove the credential from the repository, rotate it, and store secrets in a secret manager or local-only location excluded from agents.",
|
|
35
|
+
async check(context) {
|
|
36
|
+
const findings = [];
|
|
37
|
+
const affected = context.agents.filter((a) => a.detected || a.configured).map((a) => a.id);
|
|
38
|
+
const agents = affected.length > 0 ? affected : ["cursor", "claude-code", "codex"];
|
|
39
|
+
for (const file of context.discovery.files) {
|
|
40
|
+
const base = file.relativePath.split("/").pop() ?? file.relativePath;
|
|
41
|
+
const label = classify(base);
|
|
42
|
+
if (!label) {
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
findings.push({
|
|
46
|
+
ruleId: "security/private-key-file",
|
|
47
|
+
category: "security",
|
|
48
|
+
severity: "critical",
|
|
49
|
+
title: "Private key or credential file present in repository",
|
|
50
|
+
message: `Possible ${label} detected at ${file.relativePath}`,
|
|
51
|
+
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.",
|
|
52
|
+
recommendation: "Remove the file from the repository, rotate the credential, add ignore rules, and never commit replacements.",
|
|
53
|
+
affectedAgents: [...agents],
|
|
54
|
+
evidence: { path: file.relativePath, detail: label },
|
|
55
|
+
fixability: "manual",
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
return findings;
|
|
59
|
+
},
|
|
60
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export interface CachedText {
|
|
2
|
+
relativePath: string;
|
|
3
|
+
text: string | null;
|
|
4
|
+
sizeBytes: number;
|
|
5
|
+
empty: boolean;
|
|
6
|
+
binary: boolean;
|
|
7
|
+
error?: string;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Bounded UTF-8 text reader with per-path caching.
|
|
11
|
+
* Skips binaries and oversized files without throwing.
|
|
12
|
+
*/
|
|
13
|
+
export declare class TextCache {
|
|
14
|
+
private readonly root;
|
|
15
|
+
private readonly maxBytes;
|
|
16
|
+
private readonly cache;
|
|
17
|
+
constructor(root: string, maxBytes: number);
|
|
18
|
+
read(relativePath: string): Promise<CachedText>;
|
|
19
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { isPathInsideRoot, toPosixRelative } from "../../utils/path.js";
|
|
4
|
+
/**
|
|
5
|
+
* Bounded UTF-8 text reader with per-path caching.
|
|
6
|
+
* Skips binaries and oversized files without throwing.
|
|
7
|
+
*/
|
|
8
|
+
export class TextCache {
|
|
9
|
+
root;
|
|
10
|
+
maxBytes;
|
|
11
|
+
cache = new Map();
|
|
12
|
+
constructor(root, maxBytes) {
|
|
13
|
+
this.root = root;
|
|
14
|
+
this.maxBytes = maxBytes;
|
|
15
|
+
}
|
|
16
|
+
async read(relativePath) {
|
|
17
|
+
const existing = this.cache.get(relativePath);
|
|
18
|
+
if (existing) {
|
|
19
|
+
return existing;
|
|
20
|
+
}
|
|
21
|
+
const absolute = path.resolve(this.root, relativePath);
|
|
22
|
+
if (!isPathInsideRoot(this.root, absolute)) {
|
|
23
|
+
const blocked = {
|
|
24
|
+
relativePath,
|
|
25
|
+
text: null,
|
|
26
|
+
sizeBytes: 0,
|
|
27
|
+
empty: true,
|
|
28
|
+
binary: false,
|
|
29
|
+
error: "Path escapes repository root",
|
|
30
|
+
};
|
|
31
|
+
this.cache.set(relativePath, blocked);
|
|
32
|
+
return blocked;
|
|
33
|
+
}
|
|
34
|
+
try {
|
|
35
|
+
const stat = await fs.stat(absolute);
|
|
36
|
+
if (!stat.isFile()) {
|
|
37
|
+
const result = {
|
|
38
|
+
relativePath: toPosixRelative(this.root, absolute),
|
|
39
|
+
text: null,
|
|
40
|
+
sizeBytes: 0,
|
|
41
|
+
empty: true,
|
|
42
|
+
binary: false,
|
|
43
|
+
error: "Not a regular file",
|
|
44
|
+
};
|
|
45
|
+
this.cache.set(relativePath, result);
|
|
46
|
+
return result;
|
|
47
|
+
}
|
|
48
|
+
const sizeBytes = Number(stat.size);
|
|
49
|
+
if (sizeBytes > this.maxBytes) {
|
|
50
|
+
const result = {
|
|
51
|
+
relativePath,
|
|
52
|
+
text: null,
|
|
53
|
+
sizeBytes,
|
|
54
|
+
empty: false,
|
|
55
|
+
binary: false,
|
|
56
|
+
error: "File exceeds max read size",
|
|
57
|
+
};
|
|
58
|
+
this.cache.set(relativePath, result);
|
|
59
|
+
return result;
|
|
60
|
+
}
|
|
61
|
+
const buffer = await fs.readFile(absolute);
|
|
62
|
+
if (looksBinary(buffer)) {
|
|
63
|
+
const result = {
|
|
64
|
+
relativePath,
|
|
65
|
+
text: null,
|
|
66
|
+
sizeBytes,
|
|
67
|
+
empty: sizeBytes === 0,
|
|
68
|
+
binary: true,
|
|
69
|
+
error: "Binary file skipped",
|
|
70
|
+
};
|
|
71
|
+
this.cache.set(relativePath, result);
|
|
72
|
+
return result;
|
|
73
|
+
}
|
|
74
|
+
const text = buffer.toString("utf8");
|
|
75
|
+
const result = {
|
|
76
|
+
relativePath,
|
|
77
|
+
text,
|
|
78
|
+
sizeBytes,
|
|
79
|
+
empty: text.trim().length === 0,
|
|
80
|
+
binary: false,
|
|
81
|
+
};
|
|
82
|
+
this.cache.set(relativePath, result);
|
|
83
|
+
return result;
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
const message = error instanceof Error ? error.message : "Unable to read file";
|
|
87
|
+
const result = {
|
|
88
|
+
relativePath,
|
|
89
|
+
text: null,
|
|
90
|
+
sizeBytes: 0,
|
|
91
|
+
empty: true,
|
|
92
|
+
binary: false,
|
|
93
|
+
error: message,
|
|
94
|
+
};
|
|
95
|
+
this.cache.set(relativePath, result);
|
|
96
|
+
return result;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
function looksBinary(buffer) {
|
|
101
|
+
const sample = buffer.subarray(0, Math.min(buffer.length, 8000));
|
|
102
|
+
if (sample.includes(0)) {
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
let suspicious = 0;
|
|
106
|
+
for (const byte of sample) {
|
|
107
|
+
if (byte < 7 || (byte > 13 && byte < 32)) {
|
|
108
|
+
suspicious += 1;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return suspicious / Math.max(sample.length, 1) > 0.3;
|
|
112
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Shared deterministic thresholds for context/instruction rules. */
|
|
2
|
+
export declare const THRESHOLDS: {
|
|
3
|
+
/** Instruction file size (bytes) — info */
|
|
4
|
+
readonly instructionInfoBytes: number;
|
|
5
|
+
/** Instruction file size (bytes) — warning */
|
|
6
|
+
readonly instructionWarningBytes: number;
|
|
7
|
+
/** Log / dump file size that may pollute context */
|
|
8
|
+
readonly largeLogBytes: number;
|
|
9
|
+
/** Unusually large lockfile (info only) */
|
|
10
|
+
readonly largeLockfileBytes: number;
|
|
11
|
+
/** Minimum content length before duplicate detection applies */
|
|
12
|
+
readonly duplicateMinChars: 80;
|
|
13
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Shared deterministic thresholds for context/instruction rules. */
|
|
2
|
+
export const THRESHOLDS = {
|
|
3
|
+
/** Instruction file size (bytes) — info */
|
|
4
|
+
instructionInfoBytes: 16 * 1024,
|
|
5
|
+
/** Instruction file size (bytes) — warning */
|
|
6
|
+
instructionWarningBytes: 32 * 1024,
|
|
7
|
+
/** Log / dump file size that may pollute context */
|
|
8
|
+
largeLogBytes: 256 * 1024,
|
|
9
|
+
/** Unusually large lockfile (info only) */
|
|
10
|
+
largeLockfileBytes: 2 * 1024 * 1024,
|
|
11
|
+
/** Minimum content length before duplicate detection applies */
|
|
12
|
+
duplicateMinChars: 80,
|
|
13
|
+
};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { AgentDetectionResult } from "../../agents/types.js";
|
|
2
|
+
import type { AgentId, DiscoveryResult, DiscoveredFile, Finding, RepositoryInfo, RuleCategory, Severity } from "../../types/index.js";
|
|
3
|
+
import type { McpServerConfig } from "../mcp/types.js";
|
|
4
|
+
import type { IgnoreIndex } from "./ignore.js";
|
|
5
|
+
import type { TextCache } from "./text-cache.js";
|
|
6
|
+
export type Fixability = "safe" | "review" | "manual" | "none";
|
|
7
|
+
export interface RuleDefinition {
|
|
8
|
+
id: string;
|
|
9
|
+
title: string;
|
|
10
|
+
description: string;
|
|
11
|
+
category: RuleCategory;
|
|
12
|
+
severity: Severity;
|
|
13
|
+
/** Agents this rule typically concerns; findings may narrow further. */
|
|
14
|
+
affectedAgents?: AgentId[];
|
|
15
|
+
fixability: Fixability;
|
|
16
|
+
rationale: string;
|
|
17
|
+
recommendation: string;
|
|
18
|
+
check(context: RuleContext): Promise<FindingDraft[]>;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Draft finding produced by a rule before ID assignment / deduplication.
|
|
22
|
+
*/
|
|
23
|
+
export interface FindingDraft {
|
|
24
|
+
ruleId: string;
|
|
25
|
+
category: RuleCategory;
|
|
26
|
+
severity: Severity;
|
|
27
|
+
title: string;
|
|
28
|
+
message: string;
|
|
29
|
+
whyItMatters: string;
|
|
30
|
+
recommendation?: string;
|
|
31
|
+
affectedAgents: AgentId[];
|
|
32
|
+
evidence?: {
|
|
33
|
+
path?: string;
|
|
34
|
+
line?: number;
|
|
35
|
+
detail?: string;
|
|
36
|
+
};
|
|
37
|
+
fixability: Fixability;
|
|
38
|
+
}
|
|
39
|
+
export interface RuleContext {
|
|
40
|
+
root: string;
|
|
41
|
+
repository: RepositoryInfo;
|
|
42
|
+
discovery: DiscoveryResult;
|
|
43
|
+
agents: AgentDetectionResult[];
|
|
44
|
+
ignore: IgnoreIndex;
|
|
45
|
+
mcpServers: McpServerConfig[];
|
|
46
|
+
textCache: TextCache;
|
|
47
|
+
maxFileSizeBytes: number;
|
|
48
|
+
/** Instruction/config files discovered by agent adapters (unique relative paths). */
|
|
49
|
+
instructionFiles: DiscoveredFile[];
|
|
50
|
+
}
|
|
51
|
+
export interface FindingsSummary {
|
|
52
|
+
critical: number;
|
|
53
|
+
warning: number;
|
|
54
|
+
info: number;
|
|
55
|
+
total: number;
|
|
56
|
+
}
|
|
57
|
+
export declare function finalizeFinding(draft: FindingDraft): Finding;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export function finalizeFinding(draft) {
|
|
2
|
+
const pathKey = draft.evidence?.path?.replace(/\\/g, "/") ?? "repo";
|
|
3
|
+
const detailKey = draft.evidence?.detail?.replace(/\\/g, "/") ?? "";
|
|
4
|
+
const id = detailKey.length > 0 ? `${draft.ruleId}:${pathKey}:${detailKey}` : `${draft.ruleId}:${pathKey}`;
|
|
5
|
+
const finding = {
|
|
6
|
+
id,
|
|
7
|
+
ruleId: draft.ruleId,
|
|
8
|
+
category: draft.category,
|
|
9
|
+
severity: draft.severity,
|
|
10
|
+
title: draft.title,
|
|
11
|
+
message: draft.message,
|
|
12
|
+
whyItMatters: draft.whyItMatters,
|
|
13
|
+
affectedAgents: [...new Set(draft.affectedAgents)].sort(),
|
|
14
|
+
fixability: draft.fixability,
|
|
15
|
+
};
|
|
16
|
+
if (draft.recommendation !== undefined) {
|
|
17
|
+
finding.recommendation = draft.recommendation;
|
|
18
|
+
}
|
|
19
|
+
if (draft.evidence !== undefined) {
|
|
20
|
+
finding.evidence = { ...draft.evidence };
|
|
21
|
+
}
|
|
22
|
+
return finding;
|
|
23
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { ScanOptions, ScanResult } from "../../types/index.js";
|
|
2
|
+
/**
|
|
3
|
+
* Public scan entry point.
|
|
4
|
+
* Pipeline: discovery → project detection → agent adapters → rule engine → findings.
|
|
5
|
+
* Readiness scoring is reserved for a later release (`scores` remains null for now).
|
|
6
|
+
*/
|
|
7
|
+
export declare function scan(options?: ScanOptions): Promise<ScanResult>;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { detectAgents } from "../../agents/detect-agents.js";
|
|
2
|
+
import { PACKAGE_VERSION, DEFAULT_MAX_FILE_SIZE_BYTES } from "../../constants.js";
|
|
3
|
+
import { detectProject } from "../../detectors/project.js";
|
|
4
|
+
import { sanitizeTerminalText } from "../../security/redaction.js";
|
|
5
|
+
import { buildRuleContext } from "../rules/build-context.js";
|
|
6
|
+
import { runRules } from "../rules/run-rules.js";
|
|
7
|
+
/**
|
|
8
|
+
* Public scan entry point.
|
|
9
|
+
* Pipeline: discovery → project detection → agent adapters → rule engine → findings.
|
|
10
|
+
* Readiness scoring is reserved for a later release (`scores` remains null for now).
|
|
11
|
+
*/
|
|
12
|
+
export async function scan(options = {}) {
|
|
13
|
+
const totalStarted = performance.now();
|
|
14
|
+
const cwd = options.cwd ?? process.cwd();
|
|
15
|
+
const maxFileSizeBytes = options.maxFileSizeBytes ?? DEFAULT_MAX_FILE_SIZE_BYTES;
|
|
16
|
+
const detectionStarted = performance.now();
|
|
17
|
+
const { repository, discovery, diagnostics } = await detectProject(cwd, maxFileSizeBytes);
|
|
18
|
+
const detectionMs = Math.round(performance.now() - detectionStarted);
|
|
19
|
+
const { agents, elapsedMs: agentsMs } = await detectAgents({
|
|
20
|
+
root: repository.root,
|
|
21
|
+
discovery,
|
|
22
|
+
maxFileSizeBytes,
|
|
23
|
+
});
|
|
24
|
+
const ruleContext = await buildRuleContext({
|
|
25
|
+
root: repository.root,
|
|
26
|
+
repository,
|
|
27
|
+
discovery,
|
|
28
|
+
agents,
|
|
29
|
+
maxFileSizeBytes,
|
|
30
|
+
});
|
|
31
|
+
const { findings, summary, elapsedMs: rulesMs, ruleErrors, } = await runRules({
|
|
32
|
+
context: ruleContext,
|
|
33
|
+
...(options.includeRules !== undefined ? { includeRules: options.includeRules } : {}),
|
|
34
|
+
...(options.excludeRules !== undefined ? { excludeRules: options.excludeRules } : {}),
|
|
35
|
+
});
|
|
36
|
+
const warnings = [...diagnostics];
|
|
37
|
+
for (const agent of agents) {
|
|
38
|
+
for (const diagnostic of agent.diagnostics) {
|
|
39
|
+
if (diagnostic.severity === "info") {
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
const location = diagnostic.file ? ` (${sanitizeTerminalText(diagnostic.file)})` : "";
|
|
43
|
+
warnings.push(`${sanitizeTerminalText(diagnostic.message)}${location}`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
for (const error of ruleErrors) {
|
|
47
|
+
warnings.push(sanitizeTerminalText(error));
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
version: PACKAGE_VERSION,
|
|
51
|
+
repository,
|
|
52
|
+
agents,
|
|
53
|
+
findings,
|
|
54
|
+
summary,
|
|
55
|
+
scores: null,
|
|
56
|
+
scoringAvailable: false,
|
|
57
|
+
timing: {
|
|
58
|
+
discoveryMs: discovery.elapsedMs,
|
|
59
|
+
detectionMs,
|
|
60
|
+
agentsMs,
|
|
61
|
+
rulesMs,
|
|
62
|
+
scoringMs: 0,
|
|
63
|
+
totalMs: Math.round(performance.now() - totalStarted),
|
|
64
|
+
},
|
|
65
|
+
diagnostics: {
|
|
66
|
+
warnings,
|
|
67
|
+
errors: [],
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Scores } from "../../types/index.js";
|
|
2
|
+
/**
|
|
3
|
+
* Internal scoring stub retained for upcoming readiness scores.
|
|
4
|
+
* Production scans currently return `scores: null`.
|
|
5
|
+
* A clean undetected-agent repo stays below 100 by design.
|
|
6
|
+
*/
|
|
7
|
+
export declare function computePlaceholderScores(filesScanned: number): Scores;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Internal scoring stub retained for upcoming readiness scores.
|
|
3
|
+
* Production scans currently return `scores: null`.
|
|
4
|
+
* A clean undetected-agent repo stays below 100 by design.
|
|
5
|
+
*/
|
|
6
|
+
export function computePlaceholderScores(filesScanned) {
|
|
7
|
+
const base = 72;
|
|
8
|
+
const fileBonus = Math.min(8, Math.floor(filesScanned / 25));
|
|
9
|
+
const overall = clamp(base + fileBonus);
|
|
10
|
+
return {
|
|
11
|
+
overall,
|
|
12
|
+
categories: {
|
|
13
|
+
security: clamp(overall + 4),
|
|
14
|
+
context: clamp(overall - 6),
|
|
15
|
+
instructions: clamp(overall - 10),
|
|
16
|
+
mcp: clamp(overall - 8),
|
|
17
|
+
compatibility: clamp(overall - 4),
|
|
18
|
+
performance: clamp(overall),
|
|
19
|
+
},
|
|
20
|
+
agents: {
|
|
21
|
+
cursor: clamp(overall - 5),
|
|
22
|
+
"claude-code": clamp(overall - 8),
|
|
23
|
+
codex: clamp(overall - 6),
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
function clamp(value) {
|
|
28
|
+
return Math.max(0, Math.min(100, Math.round(value)));
|
|
29
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { FrameworkId } from "../types/index.js";
|
|
2
|
+
export interface FrameworkDetectionInput {
|
|
3
|
+
relativePaths: string[];
|
|
4
|
+
packageJsonDependencies?: Record<string, string>;
|
|
5
|
+
composerJsonRequire?: Record<string, string>;
|
|
6
|
+
pubspecName?: string;
|
|
7
|
+
}
|
|
8
|
+
export interface FrameworkDetectionResult {
|
|
9
|
+
frameworks: FrameworkId[];
|
|
10
|
+
primaryFramework: FrameworkId;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Detect frameworks from config markers and dependency names.
|
|
14
|
+
* Does not fail when nothing matches — returns unknown.
|
|
15
|
+
*/
|
|
16
|
+
export declare function detectFrameworks(input: FrameworkDetectionInput): FrameworkDetectionResult;
|
|
17
|
+
export declare function formatFramework(framework: FrameworkId): string;
|