@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.
Files changed (95) hide show
  1. package/CHANGELOG.md +73 -4
  2. package/README.md +283 -158
  3. package/dist/architecture/contract.d.ts +64 -0
  4. package/dist/architecture/contract.js +373 -0
  5. package/dist/assurance/change.d.ts +211 -0
  6. package/dist/assurance/change.js +624 -0
  7. package/dist/assurance/proof.d.ts +136 -0
  8. package/dist/assurance/proof.js +338 -0
  9. package/dist/auth/index.d.ts +81 -0
  10. package/dist/auth/index.js +130 -0
  11. package/dist/auth/rbac.d.ts +9 -0
  12. package/dist/auth/rbac.js +26 -0
  13. package/dist/cli/commands/architecture.d.ts +6 -0
  14. package/dist/cli/commands/architecture.js +109 -0
  15. package/dist/cli/commands/assurance.d.ts +67 -0
  16. package/dist/cli/commands/assurance.js +210 -0
  17. package/dist/cli/commands/complete.js +2 -2
  18. package/dist/cli/commands/platform.d.ts +2 -0
  19. package/dist/cli/commands/platform.js +5 -1
  20. package/dist/cli/commands/policy-graph-run.d.ts +40 -0
  21. package/dist/cli/commands/policy-graph-run.js +200 -0
  22. package/dist/cli/commands/workspace.d.ts +9 -0
  23. package/dist/cli/commands/workspace.js +82 -0
  24. package/dist/cli/program.js +535 -17
  25. package/dist/constants.d.ts +1 -1
  26. package/dist/constants.js +1 -1
  27. package/dist/contracts/index.d.ts +1 -1
  28. package/dist/contracts/index.js +2 -2
  29. package/dist/core/brain-product/init.js +5 -2
  30. package/dist/core/secrets/scan.d.ts +7 -1
  31. package/dist/core/secrets/scan.js +9 -9
  32. package/dist/coverage/cobertura.d.ts +6 -0
  33. package/dist/coverage/cobertura.js +52 -0
  34. package/dist/coverage/istanbul.d.ts +7 -0
  35. package/dist/coverage/istanbul.js +134 -0
  36. package/dist/coverage/lcov.d.ts +7 -0
  37. package/dist/coverage/lcov.js +59 -0
  38. package/dist/coverage/load.d.ts +9 -0
  39. package/dist/coverage/load.js +39 -0
  40. package/dist/coverage/types.d.ts +23 -0
  41. package/dist/coverage/types.js +5 -0
  42. package/dist/dashboard/server.d.ts +1 -1
  43. package/dist/dashboard/server.js +21 -5
  44. package/dist/enforcement/runner.d.ts +47 -3
  45. package/dist/enforcement/runner.js +420 -16
  46. package/dist/index.d.ts +23 -2
  47. package/dist/index.js +15 -2
  48. package/dist/intelligence/graph/build.d.ts +2 -0
  49. package/dist/intelligence/graph/build.js +49 -5
  50. package/dist/intelligence/graph/incremental.d.ts +68 -0
  51. package/dist/intelligence/graph/incremental.js +234 -0
  52. package/dist/intelligence/resolve/imports.d.ts +36 -0
  53. package/dist/intelligence/resolve/imports.js +245 -0
  54. package/dist/languages/go.d.ts +9 -0
  55. package/dist/languages/go.js +45 -0
  56. package/dist/languages/index.d.ts +11 -0
  57. package/dist/languages/index.js +76 -0
  58. package/dist/languages/php.d.ts +7 -0
  59. package/dist/languages/php.js +193 -0
  60. package/dist/languages/python.d.ts +7 -0
  61. package/dist/languages/python.js +150 -0
  62. package/dist/languages/types.d.ts +42 -0
  63. package/dist/languages/types.js +26 -0
  64. package/dist/languages/typescript.d.ts +3 -0
  65. package/dist/languages/typescript.js +95 -0
  66. package/dist/mcp/intelligence/handlers.d.ts +5 -0
  67. package/dist/mcp/intelligence/handlers.js +229 -40
  68. package/dist/mcp/intelligence/path-safety.d.ts +2 -4
  69. package/dist/mcp/intelligence/path-safety.js +25 -44
  70. package/dist/mcp/intelligence/registry.d.ts +1 -1
  71. package/dist/mcp/intelligence/registry.js +69 -1
  72. package/dist/platform/firewall/evaluate.d.ts +6 -3
  73. package/dist/platform/firewall/evaluate.js +66 -15
  74. package/dist/platform/graph/build.js +19 -23
  75. package/dist/platform/health/analyze.js +23 -17
  76. package/dist/platform/index.js +2 -0
  77. package/dist/platform/test-impact/analyze.d.ts +21 -3
  78. package/dist/platform/test-impact/analyze.js +214 -28
  79. package/dist/platform/tokens/plan.js +39 -20
  80. package/dist/platform/types.d.ts +9 -0
  81. package/dist/policy/compose.d.ts +34 -0
  82. package/dist/policy/compose.js +118 -0
  83. package/dist/policy/packs.js +33 -1
  84. package/dist/security/paths.d.ts +21 -0
  85. package/dist/security/paths.js +105 -0
  86. package/dist/storage/postgres.d.ts +26 -0
  87. package/dist/storage/postgres.js +90 -0
  88. package/dist/storage/provider.d.ts +2 -7
  89. package/dist/storage/provider.js +22 -16
  90. package/dist/storage/sqlite.d.ts +33 -0
  91. package/dist/storage/sqlite.js +77 -0
  92. package/dist/team/auth.js +1 -1
  93. package/dist/workspace/index.d.ts +69 -0
  94. package/dist/workspace/index.js +220 -0
  95. 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
- content = content.replace(/\s*\(not approved\)/g, "");
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
- severity: "critical" | "warning";
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
- let content;
127
- try {
128
- const st = await fs.stat(absolute);
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;
@@ -0,0 +1,5 @@
1
+ export function normalizeCoveragePath(p) {
2
+ return p.replace(/\\/g, "/").replace(/^\.\//, "");
3
+ }
4
+ /** @deprecated use normalizeCoveragePath */
5
+ export const normalizePath = 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
- * Local ?user= role selection remains spoofable.
9
+ * Query ?user= is a localDevIdentityHint only; role elevation requires AGENTDOCTOR_ALLOW_LOCAL_IDENTITY_HINT=1.
10
10
  */
11
11
  allowNonLoopback?: boolean;
12
12
  }
@@ -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. Local ?user= is not authentication. Team local-dev auth is separate and not SSO.</p>
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: "Local ?user= role selection is not authentication",
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 role = resolveRole(auth, url.searchParams.get("user") ?? undefined);
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, { error: "forbidden" });
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
- "Local ?user= role selection is not authentication — spoofable on shared hosts",
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
- * Only this boundary can mark execution as blocked-by-enforcement when it refuses to run.
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: string;
29
+ command?: string;
30
+ argv?: string[];
14
31
  failClosed?: boolean;
15
- /** When true, actually attempt execution only if policy allows — still default false. */
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
+ }>;