@praneeth_54/agentdoctor 2.0.0 → 2.0.1
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 +73 -4
- package/README.md +283 -158
- package/dist/architecture/contract.d.ts +64 -0
- package/dist/architecture/contract.js +373 -0
- package/dist/assurance/change.d.ts +211 -0
- package/dist/assurance/change.js +624 -0
- package/dist/assurance/proof.d.ts +136 -0
- package/dist/assurance/proof.js +338 -0
- package/dist/auth/index.d.ts +81 -0
- package/dist/auth/index.js +130 -0
- package/dist/auth/rbac.d.ts +9 -0
- package/dist/auth/rbac.js +26 -0
- package/dist/cli/commands/architecture.d.ts +6 -0
- package/dist/cli/commands/architecture.js +109 -0
- package/dist/cli/commands/assurance.d.ts +67 -0
- package/dist/cli/commands/assurance.js +210 -0
- package/dist/cli/commands/complete.js +2 -2
- package/dist/cli/commands/platform.d.ts +2 -0
- package/dist/cli/commands/platform.js +5 -1
- package/dist/cli/commands/policy-graph-run.d.ts +40 -0
- package/dist/cli/commands/policy-graph-run.js +200 -0
- package/dist/cli/commands/workspace.d.ts +9 -0
- package/dist/cli/commands/workspace.js +82 -0
- package/dist/cli/program.js +535 -17
- package/dist/constants.d.ts +1 -1
- package/dist/constants.js +1 -1
- package/dist/contracts/index.d.ts +1 -1
- package/dist/contracts/index.js +2 -2
- package/dist/core/brain-product/init.js +5 -2
- package/dist/core/secrets/scan.d.ts +7 -1
- package/dist/core/secrets/scan.js +9 -9
- package/dist/coverage/cobertura.d.ts +6 -0
- package/dist/coverage/cobertura.js +52 -0
- package/dist/coverage/istanbul.d.ts +7 -0
- package/dist/coverage/istanbul.js +134 -0
- package/dist/coverage/lcov.d.ts +7 -0
- package/dist/coverage/lcov.js +59 -0
- package/dist/coverage/load.d.ts +9 -0
- package/dist/coverage/load.js +39 -0
- package/dist/coverage/types.d.ts +23 -0
- package/dist/coverage/types.js +5 -0
- package/dist/dashboard/server.d.ts +1 -1
- package/dist/dashboard/server.js +21 -5
- package/dist/enforcement/runner.d.ts +47 -3
- package/dist/enforcement/runner.js +420 -16
- package/dist/index.d.ts +23 -2
- package/dist/index.js +15 -2
- package/dist/intelligence/graph/build.d.ts +2 -0
- package/dist/intelligence/graph/build.js +49 -5
- package/dist/intelligence/graph/incremental.d.ts +68 -0
- package/dist/intelligence/graph/incremental.js +234 -0
- package/dist/intelligence/resolve/imports.d.ts +36 -0
- package/dist/intelligence/resolve/imports.js +245 -0
- package/dist/languages/go.d.ts +9 -0
- package/dist/languages/go.js +45 -0
- package/dist/languages/index.d.ts +11 -0
- package/dist/languages/index.js +76 -0
- package/dist/languages/php.d.ts +7 -0
- package/dist/languages/php.js +193 -0
- package/dist/languages/python.d.ts +7 -0
- package/dist/languages/python.js +150 -0
- package/dist/languages/types.d.ts +42 -0
- package/dist/languages/types.js +26 -0
- package/dist/languages/typescript.d.ts +3 -0
- package/dist/languages/typescript.js +95 -0
- package/dist/mcp/intelligence/handlers.d.ts +5 -0
- package/dist/mcp/intelligence/handlers.js +229 -40
- package/dist/mcp/intelligence/path-safety.d.ts +2 -4
- package/dist/mcp/intelligence/path-safety.js +25 -44
- package/dist/mcp/intelligence/registry.d.ts +1 -1
- package/dist/mcp/intelligence/registry.js +69 -1
- package/dist/platform/firewall/evaluate.d.ts +6 -3
- package/dist/platform/firewall/evaluate.js +66 -15
- package/dist/platform/graph/build.js +19 -23
- package/dist/platform/health/analyze.js +23 -17
- package/dist/platform/index.js +2 -0
- package/dist/platform/test-impact/analyze.d.ts +21 -3
- package/dist/platform/test-impact/analyze.js +214 -28
- package/dist/platform/tokens/plan.js +39 -20
- package/dist/platform/types.d.ts +9 -0
- package/dist/policy/compose.d.ts +34 -0
- package/dist/policy/compose.js +118 -0
- package/dist/policy/packs.js +33 -1
- package/dist/security/paths.d.ts +21 -0
- package/dist/security/paths.js +105 -0
- package/dist/storage/postgres.d.ts +26 -0
- package/dist/storage/postgres.js +90 -0
- package/dist/storage/provider.d.ts +2 -7
- package/dist/storage/provider.js +22 -16
- package/dist/storage/sqlite.d.ts +33 -0
- package/dist/storage/sqlite.js +77 -0
- package/dist/team/auth.js +1 -1
- package/dist/workspace/index.d.ts +69 -0
- package/dist/workspace/index.js +220 -0
- package/package.json +9 -3
|
@@ -201,8 +201,11 @@ export async function reviewProposal(options) {
|
|
|
201
201
|
else {
|
|
202
202
|
content = `${statusLine}\n\n${content}`;
|
|
203
203
|
}
|
|
204
|
-
// Drop stale "not approved" parenthetical if present after replacement edge cases
|
|
205
|
-
|
|
204
|
+
// Drop stale "not approved" parenthetical if present after replacement edge cases.
|
|
205
|
+
// Avoid /\s*(...)/g — polynomial ReDoS on uncontrolled proposal markdown.
|
|
206
|
+
if (content.includes("(not approved)")) {
|
|
207
|
+
content = content.split("(not approved)").join("");
|
|
208
|
+
}
|
|
206
209
|
await atomicWriteTextFile(abs, content.endsWith("\n") ? content : `${content}\n`);
|
|
207
210
|
}
|
|
208
211
|
}
|
|
@@ -1,10 +1,16 @@
|
|
|
1
|
+
export type SecretSeverity = "critical" | "warning";
|
|
2
|
+
/** Pattern match confidence — independent from severity impact rating. */
|
|
3
|
+
export type SecretConfidence = "low" | "medium" | "high";
|
|
1
4
|
export interface SecretFinding {
|
|
2
5
|
file: string;
|
|
3
6
|
line: number;
|
|
4
7
|
ruleId: string;
|
|
5
8
|
/** Always redacted — never contains the secret value */
|
|
6
9
|
redactedSnippet: string;
|
|
7
|
-
|
|
10
|
+
/** Impact if the match is a real secret — not the same as confidence. */
|
|
11
|
+
severity: SecretSeverity;
|
|
12
|
+
/** How confident the heuristic is that this match is a secret. */
|
|
13
|
+
confidence: SecretConfidence;
|
|
8
14
|
}
|
|
9
15
|
export interface SecretScanReport {
|
|
10
16
|
root: string;
|
|
@@ -1,26 +1,31 @@
|
|
|
1
1
|
import fs from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { readTextFile } from "../../utils/fs.js";
|
|
3
4
|
import { resolveRepoRoot, toPosixRelative } from "../../utils/path.js";
|
|
4
5
|
const SECRET_PATTERNS = [
|
|
5
6
|
{
|
|
6
7
|
id: "aws-access-key",
|
|
7
8
|
re: /\bAKIA[0-9A-Z]{16}\b/g,
|
|
8
9
|
severity: "critical",
|
|
10
|
+
confidence: "high",
|
|
9
11
|
},
|
|
10
12
|
{
|
|
11
13
|
id: "generic-api-key",
|
|
12
14
|
re: /\b(?:api[_-]?key|secret[_-]?key|access[_-]?token)\s*[:=]\s*['"]?[A-Za-z0-9_-]{16,}['"]?/gi,
|
|
13
15
|
severity: "warning",
|
|
16
|
+
confidence: "medium",
|
|
14
17
|
},
|
|
15
18
|
{
|
|
16
19
|
id: "private-key-header",
|
|
17
20
|
re: /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/g,
|
|
18
21
|
severity: "critical",
|
|
22
|
+
confidence: "high",
|
|
19
23
|
},
|
|
20
24
|
{
|
|
21
25
|
id: "github-pat",
|
|
22
26
|
re: /\bghp_[A-Za-z0-9]{36}\b/g,
|
|
23
27
|
severity: "critical",
|
|
28
|
+
confidence: "high",
|
|
24
29
|
},
|
|
25
30
|
];
|
|
26
31
|
const SKIP_DIR = new Set([
|
|
@@ -123,16 +128,10 @@ export async function scanSecrets(options) {
|
|
|
123
128
|
limitations.push(`Scan capped at ${maxFiles} files`);
|
|
124
129
|
}
|
|
125
130
|
for (const absolute of files) {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
if (st.size > 512 * 1024)
|
|
130
|
-
continue;
|
|
131
|
-
content = await fs.readFile(absolute, "utf8");
|
|
132
|
-
}
|
|
133
|
-
catch {
|
|
131
|
+
// open+fstat+read via readTextFile — avoids exists/stat→read TOCTOU
|
|
132
|
+
const content = await readTextFile(absolute, 512 * 1024);
|
|
133
|
+
if (content === null)
|
|
134
134
|
continue;
|
|
135
|
-
}
|
|
136
135
|
const lines = content.split(/\r?\n/);
|
|
137
136
|
const rel = toPosixRelative(root, absolute);
|
|
138
137
|
for (let i = 0; i < lines.length; i += 1) {
|
|
@@ -147,6 +146,7 @@ export async function scanSecrets(options) {
|
|
|
147
146
|
ruleId: pattern.id,
|
|
148
147
|
redactedSnippet: redactMatch(line, match[0] ?? ""),
|
|
149
148
|
severity: pattern.severity,
|
|
149
|
+
confidence: pattern.confidence,
|
|
150
150
|
});
|
|
151
151
|
if (findings.length >= 200) {
|
|
152
152
|
limitations.push("Finding cap reached (200)");
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { type NormalizedCoverage } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* DOM-free Cobertura XML parser focused on class/@filename and line hits.
|
|
4
|
+
*/
|
|
5
|
+
export declare function parseCobertura(xml: string): NormalizedCoverage;
|
|
6
|
+
export declare function isCoberturaXml(text: string): boolean;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { normalizeCoveragePath } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* DOM-free Cobertura XML parser focused on class/@filename and line hits.
|
|
4
|
+
*/
|
|
5
|
+
export function parseCobertura(xml) {
|
|
6
|
+
const files = {};
|
|
7
|
+
const limitations = [
|
|
8
|
+
"Cobertura parser extracts class filename + line hits only (DOM-free regex)",
|
|
9
|
+
"No test→source mapping in standard Cobertura — test attribution is hybrid/heuristic",
|
|
10
|
+
];
|
|
11
|
+
if (!xml.trim()) {
|
|
12
|
+
return { format: "cobertura", files, limitations: ["Empty Cobertura input", ...limitations] };
|
|
13
|
+
}
|
|
14
|
+
const classRe = /<class\b([^>]*)>([\s\S]*?)<\/class>/gi;
|
|
15
|
+
let classMatch;
|
|
16
|
+
while ((classMatch = classRe.exec(xml)) !== null) {
|
|
17
|
+
const attrs = classMatch[1] ?? "";
|
|
18
|
+
const body = classMatch[2] ?? "";
|
|
19
|
+
const filename = attrValue(attrs, "filename") ?? attrValue(attrs, "name") ?? "";
|
|
20
|
+
if (!filename)
|
|
21
|
+
continue;
|
|
22
|
+
const filePath = normalizeCoveragePath(filename);
|
|
23
|
+
const file = files[filePath] ?? {
|
|
24
|
+
path: filePath,
|
|
25
|
+
lines: {},
|
|
26
|
+
functions: [],
|
|
27
|
+
};
|
|
28
|
+
const lineRe = /<line\b([^>]*)\/?>/gi;
|
|
29
|
+
let lineMatch;
|
|
30
|
+
while ((lineMatch = lineRe.exec(body)) !== null) {
|
|
31
|
+
const lineAttrs = lineMatch[1] ?? "";
|
|
32
|
+
const number = Number(attrValue(lineAttrs, "number"));
|
|
33
|
+
const hits = Number(attrValue(lineAttrs, "hits") ?? "0");
|
|
34
|
+
if (!Number.isFinite(number))
|
|
35
|
+
continue;
|
|
36
|
+
file.lines[number] = Math.max(file.lines[number] ?? 0, Number.isFinite(hits) ? hits : 0);
|
|
37
|
+
}
|
|
38
|
+
files[filePath] = file;
|
|
39
|
+
}
|
|
40
|
+
return { format: "cobertura", files, limitations };
|
|
41
|
+
}
|
|
42
|
+
function attrValue(attrs, name) {
|
|
43
|
+
const re = new RegExp(`${name}\\s*=\\s*(["'])([\\s\\S]*?)\\1`, "i");
|
|
44
|
+
const m = attrs.match(re);
|
|
45
|
+
return m?.[2] ?? null;
|
|
46
|
+
}
|
|
47
|
+
export function isCoberturaXml(text) {
|
|
48
|
+
const sample = text.slice(0, 8000);
|
|
49
|
+
return (/<coverage\b/i.test(sample) ||
|
|
50
|
+
/<!DOCTYPE\s+coverage/i.test(sample) ||
|
|
51
|
+
(/<class\b/i.test(sample) && /<line\b/i.test(sample)));
|
|
52
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type NormalizedCoverage } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Istanbul / nyc / vitest coverage JSON (coverage-final.json style).
|
|
4
|
+
* Accepts a JSON string or already-parsed object.
|
|
5
|
+
* Supports optional testMap / coveredByTests when present.
|
|
6
|
+
*/
|
|
7
|
+
export declare function parseIstanbul(input: string | object): NormalizedCoverage;
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { normalizeCoveragePath } from "./types.js";
|
|
2
|
+
function asRecord(value) {
|
|
3
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
4
|
+
return value;
|
|
5
|
+
}
|
|
6
|
+
return null;
|
|
7
|
+
}
|
|
8
|
+
function looksLikeFileRecord(value) {
|
|
9
|
+
const rec = asRecord(value);
|
|
10
|
+
if (!rec)
|
|
11
|
+
return false;
|
|
12
|
+
return ("statementMap" in rec ||
|
|
13
|
+
"s" in rec ||
|
|
14
|
+
"fnMap" in rec ||
|
|
15
|
+
"f" in rec ||
|
|
16
|
+
typeof rec.path === "string");
|
|
17
|
+
}
|
|
18
|
+
function extractTests(rec) {
|
|
19
|
+
const fromMeta = rec.meta?.tests ?? rec.meta?.coveredByTests ?? [];
|
|
20
|
+
const direct = rec.coveredByTests ?? rec.tests ?? [];
|
|
21
|
+
return [...new Set([...fromMeta, ...direct].map(normalizeCoveragePath).filter(Boolean))].sort();
|
|
22
|
+
}
|
|
23
|
+
function collectTestMap(root) {
|
|
24
|
+
const raw = root.testMap ?? root.tests;
|
|
25
|
+
const map = asRecord(raw);
|
|
26
|
+
if (!map)
|
|
27
|
+
return undefined;
|
|
28
|
+
const out = {};
|
|
29
|
+
for (const [testPath, sources] of Object.entries(map)) {
|
|
30
|
+
if (!Array.isArray(sources))
|
|
31
|
+
continue;
|
|
32
|
+
const files = sources
|
|
33
|
+
.filter((s) => typeof s === "string")
|
|
34
|
+
.map(normalizeCoveragePath);
|
|
35
|
+
if (files.length)
|
|
36
|
+
out[normalizeCoveragePath(testPath)] = [...new Set(files)].sort();
|
|
37
|
+
}
|
|
38
|
+
return Object.keys(out).length ? out : undefined;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Istanbul / nyc / vitest coverage JSON (coverage-final.json style).
|
|
42
|
+
* Accepts a JSON string or already-parsed object.
|
|
43
|
+
* Supports optional testMap / coveredByTests when present.
|
|
44
|
+
*/
|
|
45
|
+
export function parseIstanbul(input) {
|
|
46
|
+
let root;
|
|
47
|
+
if (typeof input === "string") {
|
|
48
|
+
try {
|
|
49
|
+
root = JSON.parse(input);
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
throw new Error(`Invalid Istanbul JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
else if (input && typeof input === "object" && !Array.isArray(input)) {
|
|
56
|
+
root = input;
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
throw new Error("Invalid Istanbul JSON: expected object or JSON string");
|
|
60
|
+
}
|
|
61
|
+
const candidate = asRecord(root.coverageMap) ?? asRecord(root.result) ?? asRecord(root.coverage) ?? root;
|
|
62
|
+
const files = {};
|
|
63
|
+
const limitations = [
|
|
64
|
+
"Istanbul statement/branch maps vary by version; line hits taken from s/statementMap when present",
|
|
65
|
+
];
|
|
66
|
+
for (const [key, value] of Object.entries(candidate)) {
|
|
67
|
+
if (key === "testMap" || key === "tests" || key === "meta")
|
|
68
|
+
continue;
|
|
69
|
+
if (!looksLikeFileRecord(value))
|
|
70
|
+
continue;
|
|
71
|
+
const pathKey = normalizeCoveragePath(typeof value.path === "string" ? value.path : key.replace(/^.*:/, ""));
|
|
72
|
+
if (!pathKey)
|
|
73
|
+
continue;
|
|
74
|
+
const prev = files[pathKey];
|
|
75
|
+
const file = {
|
|
76
|
+
path: pathKey,
|
|
77
|
+
lines: { ...(prev?.lines ?? {}) },
|
|
78
|
+
functions: [...(prev?.functions ?? [])],
|
|
79
|
+
...(prev?.coveredByTests ? { coveredByTests: [...prev.coveredByTests] } : {}),
|
|
80
|
+
};
|
|
81
|
+
const statementMap = value.statementMap ?? {};
|
|
82
|
+
const statements = value.s ?? {};
|
|
83
|
+
for (const [id, hits] of Object.entries(statements)) {
|
|
84
|
+
const loc = statementMap[id];
|
|
85
|
+
const line = loc?.start?.line ?? loc?.end?.line;
|
|
86
|
+
if (typeof line !== "number" || !Number.isFinite(hits))
|
|
87
|
+
continue;
|
|
88
|
+
file.lines[line] = Math.max(file.lines[line] ?? 0, Number(hits));
|
|
89
|
+
}
|
|
90
|
+
const fnMap = value.fnMap ?? {};
|
|
91
|
+
const fnHits = value.f ?? {};
|
|
92
|
+
for (const [id, hits] of Object.entries(fnHits)) {
|
|
93
|
+
const meta = fnMap[id];
|
|
94
|
+
const name = meta?.name || `fn_${id}`;
|
|
95
|
+
const line = meta?.decl?.start?.line ?? meta?.line ?? 0;
|
|
96
|
+
const existing = file.functions.find((f) => f.name === name);
|
|
97
|
+
if (existing) {
|
|
98
|
+
existing.hits = Math.max(existing.hits, Number(hits) || 0);
|
|
99
|
+
if (!existing.line && line)
|
|
100
|
+
existing.line = line;
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
file.functions.push({ name, line, hits: Number(hits) || 0 });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
const tests = extractTests(value);
|
|
107
|
+
if (tests.length) {
|
|
108
|
+
file.coveredByTests = [...new Set([...(file.coveredByTests ?? []), ...tests])].sort();
|
|
109
|
+
}
|
|
110
|
+
files[pathKey] = file;
|
|
111
|
+
}
|
|
112
|
+
let testToFiles = collectTestMap(root) ?? collectTestMap(candidate);
|
|
113
|
+
for (const [source, cov] of Object.entries(files)) {
|
|
114
|
+
for (const test of cov.coveredByTests ?? []) {
|
|
115
|
+
testToFiles ??= {};
|
|
116
|
+
const list = testToFiles[test] ?? [];
|
|
117
|
+
if (!list.includes(source))
|
|
118
|
+
list.push(source);
|
|
119
|
+
testToFiles[test] = list.sort();
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (!testToFiles) {
|
|
123
|
+
limitations.push("Istanbul JSON had no test→source map — test attribution falls back to heuristics");
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
limitations.push("Istanbul test→source map used where present");
|
|
127
|
+
}
|
|
128
|
+
return {
|
|
129
|
+
format: "istanbul",
|
|
130
|
+
files,
|
|
131
|
+
...(testToFiles ? { testToFiles } : {}),
|
|
132
|
+
limitations,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type NormalizedCoverage } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Parse LCOV text into NormalizedCoverage.
|
|
4
|
+
* LCOV records source line/function hits only — no test→source map.
|
|
5
|
+
*/
|
|
6
|
+
export declare function parseLcov(text: string): NormalizedCoverage;
|
|
7
|
+
export declare function isLcovText(text: string): boolean;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { normalizeCoveragePath } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Parse LCOV text into NormalizedCoverage.
|
|
4
|
+
* LCOV records source line/function hits only — no test→source map.
|
|
5
|
+
*/
|
|
6
|
+
export function parseLcov(text) {
|
|
7
|
+
const files = {};
|
|
8
|
+
let current = null;
|
|
9
|
+
const limitations = [
|
|
10
|
+
"LCOV provides source line/function hits only — no test→source mapping",
|
|
11
|
+
"Test recommendation with LCOV uses hybrid naming/graph heuristics for test attribution",
|
|
12
|
+
];
|
|
13
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
14
|
+
const line = raw.trimEnd();
|
|
15
|
+
if (line.startsWith("SF:")) {
|
|
16
|
+
const p = normalizeCoveragePath(line.slice(3).trim());
|
|
17
|
+
current = { path: p, lines: {}, functions: [] };
|
|
18
|
+
files[p] = current;
|
|
19
|
+
}
|
|
20
|
+
else if (line.startsWith("DA:") && current) {
|
|
21
|
+
const [ln, hits] = line.slice(3).split(",");
|
|
22
|
+
const n = Number(ln);
|
|
23
|
+
const h = Number(hits);
|
|
24
|
+
if (Number.isFinite(n) && Number.isFinite(h))
|
|
25
|
+
current.lines[n] = h;
|
|
26
|
+
}
|
|
27
|
+
else if (line.startsWith("FN:") && current) {
|
|
28
|
+
const body = line.slice(3);
|
|
29
|
+
const comma = body.indexOf(",");
|
|
30
|
+
if (comma < 0)
|
|
31
|
+
continue;
|
|
32
|
+
const n = Number(body.slice(0, comma));
|
|
33
|
+
const name = body.slice(comma + 1).trim();
|
|
34
|
+
if (Number.isFinite(n) && name) {
|
|
35
|
+
current.functions.push({ name, line: n, hits: 0 });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
else if (line.startsWith("FNDA:") && current) {
|
|
39
|
+
const body = line.slice(5);
|
|
40
|
+
const comma = body.indexOf(",");
|
|
41
|
+
if (comma < 0)
|
|
42
|
+
continue;
|
|
43
|
+
const h = Number(body.slice(0, comma));
|
|
44
|
+
const name = body.slice(comma + 1).trim();
|
|
45
|
+
const fn = current.functions.find((f) => f.name === name);
|
|
46
|
+
if (fn && Number.isFinite(h))
|
|
47
|
+
fn.hits = h;
|
|
48
|
+
}
|
|
49
|
+
else if (line === "end_of_record") {
|
|
50
|
+
current = null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return { format: "lcov", files, limitations };
|
|
54
|
+
}
|
|
55
|
+
export function isLcovText(text) {
|
|
56
|
+
const sample = text.slice(0, 4000);
|
|
57
|
+
return (/(^|\n)SF:/.test(sample) &&
|
|
58
|
+
(/(^|\n)DA:/.test(sample) || /(^|\n)end_of_record/.test(sample) || /(^|\n)TN:/.test(sample)));
|
|
59
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { NormalizedCoverage } from "./types.js";
|
|
2
|
+
export type { NormalizedCoverage, CoverageFormat, CoverageFile } from "./types.js";
|
|
3
|
+
export { parseLcov } from "./lcov.js";
|
|
4
|
+
export { parseIstanbul } from "./istanbul.js";
|
|
5
|
+
export { parseCobertura } from "./cobertura.js";
|
|
6
|
+
/**
|
|
7
|
+
* Load coverage from a file path, auto-detecting format by extension then content.
|
|
8
|
+
*/
|
|
9
|
+
export declare function loadCoverage(coveragePath: string): Promise<NormalizedCoverage>;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { isCoberturaXml, parseCobertura } from "./cobertura.js";
|
|
4
|
+
import { parseIstanbul } from "./istanbul.js";
|
|
5
|
+
import { isLcovText, parseLcov } from "./lcov.js";
|
|
6
|
+
export { parseLcov } from "./lcov.js";
|
|
7
|
+
export { parseIstanbul } from "./istanbul.js";
|
|
8
|
+
export { parseCobertura } from "./cobertura.js";
|
|
9
|
+
/**
|
|
10
|
+
* Load coverage from a file path, auto-detecting format by extension then content.
|
|
11
|
+
*/
|
|
12
|
+
export async function loadCoverage(coveragePath) {
|
|
13
|
+
const abs = path.resolve(coveragePath);
|
|
14
|
+
let text;
|
|
15
|
+
try {
|
|
16
|
+
text = await fs.readFile(abs, "utf8");
|
|
17
|
+
}
|
|
18
|
+
catch (error) {
|
|
19
|
+
throw new Error(`Unable to read coverage file: ${error instanceof Error ? error.message : String(error)}`);
|
|
20
|
+
}
|
|
21
|
+
if (!text.trim()) {
|
|
22
|
+
throw new Error("Coverage file is empty");
|
|
23
|
+
}
|
|
24
|
+
const base = path.basename(abs).toLowerCase();
|
|
25
|
+
const ext = path.extname(abs).toLowerCase();
|
|
26
|
+
if (ext === ".lcov" || base === "lcov.info" || base.endsWith(".info") || isLcovText(text)) {
|
|
27
|
+
if (text.includes("SF:") || isLcovText(text))
|
|
28
|
+
return parseLcov(text);
|
|
29
|
+
}
|
|
30
|
+
if (ext === ".xml" || base.includes("cobertura") || isCoberturaXml(text)) {
|
|
31
|
+
return parseCobertura(text);
|
|
32
|
+
}
|
|
33
|
+
if (ext === ".json" || text.trimStart().startsWith("{")) {
|
|
34
|
+
return parseIstanbul(text);
|
|
35
|
+
}
|
|
36
|
+
if (text.includes("end_of_record"))
|
|
37
|
+
return parseLcov(text);
|
|
38
|
+
return parseIstanbul(text);
|
|
39
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export type CoverageFormat = "lcov" | "istanbul" | "cobertura";
|
|
2
|
+
export interface CoverageFunction {
|
|
3
|
+
name: string;
|
|
4
|
+
line: number;
|
|
5
|
+
hits: number;
|
|
6
|
+
}
|
|
7
|
+
export interface CoverageFile {
|
|
8
|
+
path: string;
|
|
9
|
+
lines: Record<number, number>;
|
|
10
|
+
functions: CoverageFunction[];
|
|
11
|
+
/** Optional test files known to cover this source (when format provides it). */
|
|
12
|
+
coveredByTests?: string[];
|
|
13
|
+
}
|
|
14
|
+
export interface NormalizedCoverage {
|
|
15
|
+
format: CoverageFormat;
|
|
16
|
+
files: Record<string, CoverageFile>;
|
|
17
|
+
/** Optional reverse map test → source files when available. */
|
|
18
|
+
testToFiles?: Record<string, string[]>;
|
|
19
|
+
limitations: string[];
|
|
20
|
+
}
|
|
21
|
+
export declare function normalizeCoveragePath(p: string): string;
|
|
22
|
+
/** @deprecated use normalizeCoveragePath */
|
|
23
|
+
export declare const normalizePath: typeof normalizeCoveragePath;
|
|
@@ -6,7 +6,7 @@ export interface DashboardServerOptions {
|
|
|
6
6
|
readOnly?: boolean;
|
|
7
7
|
/**
|
|
8
8
|
* Unsafe opt-in to bind outside loopback. Not an enterprise security boundary.
|
|
9
|
-
*
|
|
9
|
+
* Query ?user= is a localDevIdentityHint only; role elevation requires AGENTDOCTOR_ALLOW_LOCAL_IDENTITY_HINT=1.
|
|
10
10
|
*/
|
|
11
11
|
allowNonLoopback?: boolean;
|
|
12
12
|
}
|
package/dist/dashboard/server.js
CHANGED
|
@@ -92,7 +92,7 @@ function htmlPage() {
|
|
|
92
92
|
<header>
|
|
93
93
|
<h1>AgentDoctor 2.0</h1>
|
|
94
94
|
<p>Local read-only dashboard — Safety + Brain + intelligence. No cloud writes.</p>
|
|
95
|
-
<p class="notice muted">Action Policy Evaluator is evaluate-only.
|
|
95
|
+
<p class="notice muted">Action Policy Evaluator is evaluate-only. Query ?user= is a localDevIdentityHint only (not authentication). Team local-dev auth is separate and not SSO. OIDC JWT validation is a library path — full browser OAuth redirect is experimental.</p>
|
|
96
96
|
</header>
|
|
97
97
|
<main>
|
|
98
98
|
<nav aria-label="Views" style="display:flex;flex-wrap:wrap;gap:0.75rem;font-size:0.9rem">
|
|
@@ -208,7 +208,7 @@ export async function startDashboardServer(options) {
|
|
|
208
208
|
host,
|
|
209
209
|
loopbackOnly: isLoopbackHost(host),
|
|
210
210
|
contractsVersion: CONTRACTS_VERSION,
|
|
211
|
-
roleNote: "
|
|
211
|
+
roleNote: "Query ?user= is a localDevIdentityHint only — not authentication. Privileged role elevation requires AGENTDOCTOR_ALLOW_LOCAL_IDENTITY_HINT=1",
|
|
212
212
|
actionPolicyNote: EVALUATE_ONLY,
|
|
213
213
|
adapters: agentRegistry.map((a) => a.id),
|
|
214
214
|
policyPacks: listPolicyPacks(),
|
|
@@ -259,9 +259,20 @@ export async function startDashboardServer(options) {
|
|
|
259
259
|
}
|
|
260
260
|
if (url.pathname === "/api/platform") {
|
|
261
261
|
const auth = await loadLocalAuthConfig(root);
|
|
262
|
-
const
|
|
262
|
+
const identityHint = url.searchParams.get("user");
|
|
263
|
+
const allowLocalHint = process.env.AGENTDOCTOR_ALLOW_LOCAL_IDENTITY_HINT === "1";
|
|
264
|
+
// Default: deny privileged elevation. ?user= is a label only unless opt-in env is set.
|
|
265
|
+
let role = auth.defaultRole;
|
|
266
|
+
const localDevIdentityHint = identityHint && identityHint.trim() ? identityHint.trim() : null;
|
|
267
|
+
if (localDevIdentityHint && allowLocalHint) {
|
|
268
|
+
role = resolveRole(auth, localDevIdentityHint);
|
|
269
|
+
}
|
|
263
270
|
if (!canAccess(role, "read-findings")) {
|
|
264
|
-
sendJson(res, 403, {
|
|
271
|
+
sendJson(res, 403, {
|
|
272
|
+
error: "forbidden",
|
|
273
|
+
localDevIdentityHint,
|
|
274
|
+
notice: "?user= is not authentication; privileged ops denied without AGENTDOCTOR_ALLOW_LOCAL_IDENTITY_HINT=1",
|
|
275
|
+
});
|
|
265
276
|
return;
|
|
266
277
|
}
|
|
267
278
|
const snapshot = await readJsonIfExists(path.join(platformDir(root), "snapshots", "latest.json"));
|
|
@@ -271,6 +282,8 @@ export async function startDashboardServer(options) {
|
|
|
271
282
|
: [];
|
|
272
283
|
sendJson(res, 200, {
|
|
273
284
|
role,
|
|
285
|
+
localDevIdentityHint,
|
|
286
|
+
localIdentityHintElevated: Boolean(localDevIdentityHint && allowLocalHint),
|
|
274
287
|
hasSnapshot: snapshot !== null,
|
|
275
288
|
findingCount: snapshot?.findings.length ?? 0,
|
|
276
289
|
graphNodes: snapshot?.graph.nodes.length ?? 0,
|
|
@@ -295,7 +308,10 @@ export async function startDashboardServer(options) {
|
|
|
295
308
|
...(snapshot?.limitations ?? [
|
|
296
309
|
"No platform snapshot yet — run: agentdoctor platform scan",
|
|
297
310
|
]),
|
|
298
|
-
"
|
|
311
|
+
"?user= is localDevIdentityHint only — not authentication",
|
|
312
|
+
allowLocalHint
|
|
313
|
+
? "AGENTDOCTOR_ALLOW_LOCAL_IDENTITY_HINT=1 enabled — hint may elevate local roles (still not SSO)"
|
|
314
|
+
: "Privileged role elevation from ?user= denied unless AGENTDOCTOR_ALLOW_LOCAL_IDENTITY_HINT=1",
|
|
299
315
|
EVALUATE_ONLY,
|
|
300
316
|
],
|
|
301
317
|
sampleFindings: canExport
|
|
@@ -1,19 +1,40 @@
|
|
|
1
1
|
import type { PolicyDecisionContract } from "../contracts/index.js";
|
|
2
|
+
export type ExecutionStatus = "executed" | "blocked-by-enforcement" | "not-executed" | "execution-failed";
|
|
2
3
|
export interface EnforcementResult {
|
|
3
4
|
decision: PolicyDecisionContract;
|
|
4
5
|
enforced: boolean;
|
|
5
6
|
notice: string;
|
|
7
|
+
stdout?: string;
|
|
8
|
+
stderr?: string;
|
|
9
|
+
exitCode?: number | null;
|
|
10
|
+
durationMs?: number;
|
|
6
11
|
}
|
|
12
|
+
/**
|
|
13
|
+
* Parse a command string into argv without invoking a shell.
|
|
14
|
+
* Supports simple double/single quotes; rejects unclosed quotes.
|
|
15
|
+
*/
|
|
16
|
+
export declare function parseArgv(command: string): string[];
|
|
17
|
+
export declare function buildAllowlistedEnv(source?: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
|
|
18
|
+
/**
|
|
19
|
+
* Reject path-like args that resolve outside the repository root.
|
|
20
|
+
*/
|
|
21
|
+
export declare function assertArgsInsideRoot(args: string[], root: string): void;
|
|
7
22
|
/**
|
|
8
23
|
* AgentDoctor-controlled command runner.
|
|
9
|
-
*
|
|
24
|
+
* Executes only when executeIfAllowed===true AND policy decision===allow.
|
|
25
|
+
* Default path uses shell:false with argv parsing (no shell metacharacters).
|
|
10
26
|
*/
|
|
11
27
|
export declare function runControlledCommand(options: {
|
|
12
28
|
root: string;
|
|
13
|
-
command
|
|
29
|
+
command?: string;
|
|
30
|
+
argv?: string[];
|
|
14
31
|
failClosed?: boolean;
|
|
15
|
-
/** When true,
|
|
32
|
+
/** When true, execute only if policy allows. */
|
|
16
33
|
executeIfAllowed?: boolean;
|
|
34
|
+
/** Explicit high-risk shell mode — requires policy allow as well. */
|
|
35
|
+
useShell?: boolean;
|
|
36
|
+
timeoutMs?: number;
|
|
37
|
+
maxBuffer?: number;
|
|
17
38
|
}): Promise<EnforcementResult>;
|
|
18
39
|
/** Append-only tamper-evident audit chain (HMAC over previous hash). */
|
|
19
40
|
export declare function appendAuditChain(previousHash: string | null, record: unknown, secret?: string): {
|
|
@@ -21,3 +42,26 @@ export declare function appendAuditChain(previousHash: string | null, record: un
|
|
|
21
42
|
payload: string;
|
|
22
43
|
};
|
|
23
44
|
export declare function auditRecordId(parts: string): string;
|
|
45
|
+
/**
|
|
46
|
+
* Explain what the controlled runner would do for a command (evaluate-only).
|
|
47
|
+
* Does not execute.
|
|
48
|
+
*/
|
|
49
|
+
export declare function explainControlledRun(options: {
|
|
50
|
+
root: string;
|
|
51
|
+
command?: string;
|
|
52
|
+
argv?: string[];
|
|
53
|
+
failClosed?: boolean;
|
|
54
|
+
useShell?: boolean;
|
|
55
|
+
}): Promise<{
|
|
56
|
+
command: string;
|
|
57
|
+
argv: string[];
|
|
58
|
+
useShell: boolean;
|
|
59
|
+
decision: string;
|
|
60
|
+
reason: string;
|
|
61
|
+
policyId: string | null;
|
|
62
|
+
riskLevel: string;
|
|
63
|
+
wouldExecute: boolean;
|
|
64
|
+
executionResult: "not-executed";
|
|
65
|
+
notice: string;
|
|
66
|
+
meaning: string;
|
|
67
|
+
}>;
|