@praneeth_54/agentdoctor 3.0.0 → 3.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 (62) hide show
  1. package/CHANGELOG.md +30 -1
  2. package/README.md +9 -8
  3. package/dist/agent/context/retrieve.d.ts +2 -1
  4. package/dist/agent/context/retrieve.js +14 -1
  5. package/dist/agent/tools/execute.js +39 -43
  6. package/dist/agent/tools/write.d.ts +1 -1
  7. package/dist/agent/tools/write.js +18 -4
  8. package/dist/ai/providers/adversarial-local.d.ts +7 -0
  9. package/dist/ai/providers/adversarial-local.js +146 -0
  10. package/dist/cli/commands/agent.js +13 -3
  11. package/dist/cli/commands/architecture.js +7 -2
  12. package/dist/cli/commands/brain.js +7 -2
  13. package/dist/cli/commands/chat.js +13 -3
  14. package/dist/cli/commands/complete.js +13 -3
  15. package/dist/cli/commands/fix.js +6 -0
  16. package/dist/cli/commands/learn.js +7 -2
  17. package/dist/cli/commands/mcp.js +7 -3
  18. package/dist/cli/commands/platform.js +7 -2
  19. package/dist/cli/commands/policy-graph-run.js +7 -0
  20. package/dist/cli/commands/product.js +13 -3
  21. package/dist/cli/commands/scan.js +6 -0
  22. package/dist/cli/commands/start.js +7 -1
  23. package/dist/cli/commands/v2.js +61 -11
  24. package/dist/cli/commands/verify.js +6 -0
  25. package/dist/cli/safe-root.d.ts +17 -0
  26. package/dist/cli/safe-root.js +32 -0
  27. package/dist/constants.d.ts +1 -1
  28. package/dist/constants.js +1 -1
  29. package/dist/core/brain-cli/service.js +5 -2
  30. package/dist/core/monorepo/detect.js +10 -0
  31. package/dist/core/secrets/scan.js +9 -0
  32. package/dist/core/understanding/brain/storage/store.d.ts +4 -0
  33. package/dist/core/understanding/brain/storage/store.js +22 -2
  34. package/dist/dashboard/page.d.ts +2 -0
  35. package/dist/dashboard/page.js +53 -0
  36. package/dist/dashboard/server.js +13 -238
  37. package/dist/dashboard/ui/client.d.ts +2 -0
  38. package/dist/dashboard/ui/client.js +2 -0
  39. package/dist/dashboard/ui/styles.d.ts +2 -0
  40. package/dist/dashboard/ui/styles.js +138 -0
  41. package/dist/discovery/files.js +13 -0
  42. package/dist/intelligence/graph/build.js +10 -30
  43. package/dist/intelligence/graph/incremental.d.ts +2 -0
  44. package/dist/intelligence/graph/incremental.js +9 -0
  45. package/dist/mcp/intelligence/path-safety.d.ts +6 -1
  46. package/dist/mcp/intelligence/path-safety.js +55 -2
  47. package/dist/platform/graph/build.js +9 -0
  48. package/dist/product/decisions/ledger.d.ts +13 -1
  49. package/dist/product/decisions/ledger.js +41 -10
  50. package/dist/product/discovery/roots.d.ts +10 -0
  51. package/dist/product/discovery/roots.js +23 -7
  52. package/dist/product/graph/enrich-languages.js +7 -32
  53. package/dist/product/index.d.ts +4 -1
  54. package/dist/product/index.js +2 -1
  55. package/dist/product/map/software-map.js +2 -1
  56. package/dist/product/memory/institutional.js +5 -0
  57. package/dist/product/search/software-search.js +5 -0
  58. package/dist/product/twin/store.js +9 -1
  59. package/dist/product/whatif/engine.js +22 -1
  60. package/dist/project/ownership.d.ts +65 -0
  61. package/dist/project/ownership.js +169 -0
  62. package/package.json +1 -1
@@ -1,7 +1,7 @@
1
- import fs from "node:fs/promises";
2
1
  import path from "node:path";
3
2
  import { createHash } from "node:crypto";
4
3
  import ts from "typescript";
4
+ import { discoverFiles } from "../../discovery/files.js";
5
5
  import { resolveRepoRoot, toPosixRelative } from "../../utils/path.js";
6
6
  import { buildRepositoryGraph } from "../../platform/graph/build.js";
7
7
  import { CONTRACTS_VERSION } from "../../contracts/index.js";
@@ -45,35 +45,15 @@ async function applyLanguageEnrichment(graph, root) {
45
45
  }
46
46
  }
47
47
  export async function listTsFiles(root, limit = 400) {
48
- const out = [];
49
- const skip = new Set(["node_modules", ".git", "dist", "coverage", ".agentdoctor", "vendor"]);
50
- async function walk(dir) {
51
- if (out.length >= limit)
52
- return;
53
- let entries;
54
- try {
55
- entries = await fs.readdir(dir, { withFileTypes: true });
56
- }
57
- catch {
58
- return;
59
- }
60
- for (const e of entries) {
61
- if (skip.has(e.name))
62
- continue;
63
- const abs = path.join(dir, e.name);
64
- if (e.isSymbolicLink())
65
- continue;
66
- if (e.isDirectory()) {
67
- await walk(abs);
68
- continue;
69
- }
70
- if (/\.(ts|tsx|mts|cts)$/i.test(e.name) && !e.name.endsWith(".d.ts")) {
71
- out.push(abs);
72
- }
73
- }
74
- }
75
- await walk(root);
76
- return out.sort();
48
+ const discovered = await discoverFiles({ root });
49
+ return discovered.files
50
+ .filter((f) => {
51
+ const name = path.basename(f.relativePath);
52
+ return /\.(ts|tsx|mts|cts)$/i.test(name) && !name.endsWith(".d.ts");
53
+ })
54
+ .map((f) => f.absolutePath)
55
+ .sort()
56
+ .slice(0, limit);
77
57
  }
78
58
  /**
79
59
  * TypeScript compiler API based graph enrichment.
@@ -12,6 +12,8 @@ export interface GraphSnapshot {
12
12
  nodes: GraphNode[];
13
13
  edges: GraphEdge[];
14
14
  limitations: string[];
15
+ /** Reject cached graphs built under a prior ownership boundary. */
16
+ ownershipBoundaryVersion?: number;
15
17
  }
16
18
  export interface GraphStatus {
17
19
  present: boolean;
@@ -3,6 +3,7 @@ import { spawnSync } from "node:child_process";
3
3
  import fs from "node:fs/promises";
4
4
  import path from "node:path";
5
5
  import { PACKAGE_VERSION } from "../../constants.js";
6
+ import { OWNERSHIP_BOUNDARY_VERSION } from "../../project/ownership.js";
6
7
  import { resolveRepoRoot, toPosixRelative } from "../../utils/path.js";
7
8
  import { atomicWriteTextFile } from "../../utils/fs.js";
8
9
  import { buildIntelligenceGraph, listTsFiles } from "./build.js";
@@ -75,6 +76,13 @@ async function loadSnapshot(root) {
75
76
  if (!isValidSnapshot(parsed)) {
76
77
  return { ok: false, corrupt: true, error: "index.json failed schema checks" };
77
78
  }
79
+ if (parsed.ownershipBoundaryVersion !== OWNERSHIP_BOUNDARY_VERSION) {
80
+ return {
81
+ ok: false,
82
+ corrupt: false,
83
+ error: `graph index ownership boundary is stale (have ${String(parsed.ownershipBoundaryVersion ?? "missing")}, need ${OWNERSHIP_BOUNDARY_VERSION})`,
84
+ };
85
+ }
78
86
  return { ok: true, snapshot: parsed };
79
87
  }
80
88
  catch (error) {
@@ -109,6 +117,7 @@ async function buildAndPersist(root, mode = "auto") {
109
117
  ...graph.limitations,
110
118
  "Incremental index persists file hashes for change detection",
111
119
  ],
120
+ ownershipBoundaryVersion: OWNERSHIP_BOUNDARY_VERSION,
112
121
  };
113
122
  const saved = await persistSnapshot(root, snapshot);
114
123
  return { snapshot, path: saved };
@@ -4,6 +4,11 @@ export { tryDecodeUriComponent };
4
4
  * Validate a user-supplied repository-relative or in-repo target.
5
5
  * Returns a normalized path relative to root (POSIX) when the target is path-like and safe,
6
6
  * or null when the target is treated as a non-path identifier (graph id / label).
7
- * Throws a safe Error (no outside path leakage) when a path-like target escapes the root.
7
+ * Throws a safe Error (no outside path leakage) when a path-like target escapes the root
8
+ * or is outside project ownership (.private, AgentDoctorOS, fixtures, validation).
8
9
  */
9
10
  export declare function assertSafeRepoTarget(root: string, rawTarget: string): string | null;
11
+ /**
12
+ * Async ownership gate including nested-repository ancestry (for MCP file-path tools).
13
+ */
14
+ export declare function assertSafeOwnedRepoTarget(root: string, rawTarget: string): Promise<string | null>;
@@ -1,4 +1,6 @@
1
1
  import path from "node:path";
2
+ import fs from "node:fs";
3
+ import { assertProjectOwnedRepoPath, classifyRelativePathOwnership, ProjectOwnershipError, } from "../../project/ownership.js";
2
4
  import { PathEscapeError, rejectHostilePathInput, resolveSafeRepoPath, tryDecodeUriComponent, } from "../../security/paths.js";
3
5
  import { sanitizeForOutput } from "../../utils/path.js";
4
6
  export { tryDecodeUriComponent };
@@ -6,7 +8,8 @@ export { tryDecodeUriComponent };
6
8
  * Validate a user-supplied repository-relative or in-repo target.
7
9
  * Returns a normalized path relative to root (POSIX) when the target is path-like and safe,
8
10
  * or null when the target is treated as a non-path identifier (graph id / label).
9
- * Throws a safe Error (no outside path leakage) when a path-like target escapes the root.
11
+ * Throws a safe Error (no outside path leakage) when a path-like target escapes the root
12
+ * or is outside project ownership (.private, AgentDoctorOS, fixtures, validation).
10
13
  */
11
14
  export function assertSafeRepoTarget(root, rawTarget) {
12
15
  if (typeof rawTarget !== "string" || rawTarget.includes("\0")) {
@@ -52,12 +55,62 @@ export function assertSafeRepoTarget(root, rawTarget) {
52
55
  const primary = candidates[candidates.length - 1];
53
56
  try {
54
57
  const abs = resolveSafeRepoPath(root, primary);
55
- return path.relative(root, abs).split(path.sep).join("/");
58
+ const lexicalRel = path.isAbsolute(primary)
59
+ ? path.relative(root, abs).split(path.sep).join("/")
60
+ : primary.replace(/\\/g, "/").replace(/^\.\//, "");
61
+ // Lexical non-owned prefixes always deny.
62
+ if (classifyRelativePathOwnership(lexicalRel) !== "project_owned") {
63
+ throw new Error("path outside project ownership");
64
+ }
65
+ // Realpath-relative classification catches symlink aliases into non-owned trees.
66
+ let realRoot = root;
67
+ let realAbs = abs;
68
+ try {
69
+ realRoot = fs.realpathSync(root);
70
+ }
71
+ catch {
72
+ /* keep */
73
+ }
74
+ try {
75
+ realAbs = fs.realpathSync(abs);
76
+ }
77
+ catch {
78
+ /* keep */
79
+ }
80
+ const realRel = path.relative(realRoot, realAbs).split(path.sep).join("/") || ".";
81
+ if (realRel.startsWith("..") ||
82
+ path.isAbsolute(realRel) ||
83
+ classifyRelativePathOwnership(realRel) !== "project_owned") {
84
+ throw new Error("path outside project ownership");
85
+ }
86
+ return realRel || lexicalRel;
56
87
  }
57
88
  catch (error) {
58
89
  if (error instanceof PathEscapeError) {
59
90
  throw new Error(error.message);
60
91
  }
92
+ if (error instanceof Error && error.message === "path outside project ownership") {
93
+ throw error;
94
+ }
61
95
  throw new Error("invalid target");
62
96
  }
63
97
  }
98
+ /**
99
+ * Async ownership gate including nested-repository ancestry (for MCP file-path tools).
100
+ */
101
+ export async function assertSafeOwnedRepoTarget(root, rawTarget) {
102
+ const rel = assertSafeRepoTarget(root, rawTarget);
103
+ if (rel === null)
104
+ return null;
105
+ try {
106
+ const abs = resolveSafeRepoPath(root, rel);
107
+ await assertProjectOwnedRepoPath(root, abs, rel);
108
+ }
109
+ catch (error) {
110
+ if (error instanceof PathEscapeError || error instanceof ProjectOwnershipError) {
111
+ throw new Error(error.message);
112
+ }
113
+ throw error;
114
+ }
115
+ return rel;
116
+ }
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { createHash } from "node:crypto";
4
+ import { decideDirectoryTraversal } from "../../project/ownership.js";
4
5
  import { readTextFile } from "../../utils/fs.js";
5
6
  import { resolveRepoRoot, toPosixRelative } from "../../utils/path.js";
6
7
  const SKIP = new Set([
@@ -48,6 +49,14 @@ async function walk(root, dir, files, dirs, limit) {
48
49
  if (entry.isSymbolicLink())
49
50
  continue;
50
51
  if (entry.isDirectory()) {
52
+ const relativeDir = toPosixRelative(root, absolute);
53
+ const traversal = await decideDirectoryTraversal({
54
+ projectRoot: root,
55
+ absoluteDir: absolute,
56
+ relativeDir,
57
+ });
58
+ if (!traversal.traverse)
59
+ continue;
51
60
  dirs.push(absolute);
52
61
  await walk(root, absolute, files, dirs, limit);
53
62
  continue;
@@ -1,10 +1,19 @@
1
+ import { type ProjectOwnershipClass } from "../../project/ownership.js";
1
2
  import type { ProductEvidence, TruthLabel } from "../truth.js";
3
+ /** Why a decision document is attributed to the current project. */
4
+ export type DecisionSourceKind = "adr_file" | "decision_ledger" | "architecture_document" | "internal_note" | "unknown";
2
5
  export interface DecisionRecord {
3
6
  id: string;
4
7
  title: string;
5
8
  status?: string;
9
+ /** Wire-compat provenance channel. */
6
10
  source: "adr-file" | "ledger";
11
+ sourceKind: DecisionSourceKind;
7
12
  truth: TruthLabel;
13
+ /** Explicit meaning for UI — never “architecture proven correct”. */
14
+ truthMeaning: string;
15
+ projectRoot: string;
16
+ ownership: ProjectOwnershipClass;
8
17
  evidence: ProductEvidence[];
9
18
  recordedAt?: string;
10
19
  bodyExcerpt?: string;
@@ -16,9 +25,12 @@ export interface DecisionLedgerResult {
16
25
  limitations: string[];
17
26
  }
18
27
  export declare function parseAdrDecisions(rootInput: string, maxFileSizeBytes?: number): Promise<DecisionRecord[]>;
19
- export declare function appendDecisionLedgerEntry(rootInput: string, record: Omit<DecisionRecord, "source" | "truth"> & {
28
+ export declare function appendDecisionLedgerEntry(rootInput: string, record: Omit<DecisionRecord, "source" | "truth" | "sourceKind" | "truthMeaning" | "projectRoot" | "ownership"> & {
20
29
  source?: DecisionRecord["source"];
30
+ sourceKind?: DecisionSourceKind;
21
31
  truth?: TruthLabel;
32
+ truthMeaning?: string;
33
+ ownership?: ProjectOwnershipClass;
22
34
  }): Promise<DecisionRecord>;
23
35
  export declare function loadDecisionLedger(rootInput: string): Promise<DecisionLedgerResult>;
24
36
  export declare function resolveDecisionPath(rootInput: string, candidate: string): string;
@@ -2,21 +2,26 @@ import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { DEFAULT_MAX_FILE_SIZE_BYTES } from "../../constants.js";
4
4
  import { detectProject } from "../../detectors/project.js";
5
- import { readTextFile } from "../../utils/fs.js";
5
+ import { classifyRelativePathOwnership, isProjectOwnedRelativePath, verifiedDecisionTruthMeaning, } from "../../project/ownership.js";
6
6
  import { resolveSafeRepoPath } from "../../security/paths.js";
7
+ import { pathExists, readTextFile } from "../../utils/fs.js";
7
8
  import { resolveRepoRoot } from "../../utils/path.js";
8
- import { pathExists } from "../../utils/fs.js";
9
9
  function ledgerFile(root) {
10
10
  return path.join(resolveRepoRoot(root), ".agentdoctor", "decisions", "entries.jsonl");
11
11
  }
12
+ /**
13
+ * ADR-shaped paths under the current project-owned tree only.
14
+ * Nested foreign trees with an adr directory segment are excluded by ownership
15
+ * before this runs; this still refuses internal_docs / private paths as defense in depth.
16
+ */
12
17
  function isAdrPath(relativePath) {
18
+ if (!isProjectOwnedRelativePath(relativePath))
19
+ return false;
13
20
  const norm = relativePath.replace(/\\/g, "/").toLowerCase();
14
- return (norm.startsWith("docs/adr/") ||
15
- norm.startsWith("adr/") ||
16
- norm.includes("/adr/") ||
17
- /^adr-\d+/i.test(path.basename(relativePath)));
21
+ const base = path.basename(relativePath);
22
+ return norm.startsWith("docs/adr/") || norm.startsWith("adr/") || /^adr[-_]\d+/i.test(base);
18
23
  }
19
- function parseAdr(relativePath, content) {
24
+ function parseAdr(projectRoot, relativePath, content) {
20
25
  const lines = content.split(/\r?\n/);
21
26
  let title = "";
22
27
  let status;
@@ -31,12 +36,17 @@ function parseAdr(relativePath, content) {
31
36
  if (!title)
32
37
  return null;
33
38
  const id = relativePath.replace(/[^\w./-]/g, "_");
39
+ const ownership = classifyRelativePathOwnership(relativePath);
34
40
  return {
35
41
  id,
36
42
  title,
37
43
  ...(status !== undefined ? { status } : {}),
38
44
  source: "adr-file",
45
+ sourceKind: "adr_file",
39
46
  truth: "VERIFIED",
47
+ truthMeaning: verifiedDecisionTruthMeaning(),
48
+ projectRoot,
49
+ ownership,
40
50
  evidence: [{ path: relativePath, line: 1, excerpt: title.slice(0, 120) }],
41
51
  bodyExcerpt: content.slice(0, 400),
42
52
  };
@@ -52,7 +62,7 @@ export async function parseAdrDecisions(rootInput, maxFileSizeBytes = DEFAULT_MA
52
62
  const text = await readTextFile(path.join(root, rel), maxFileSizeBytes);
53
63
  if (!text)
54
64
  continue;
55
- const parsed = parseAdr(rel, text);
65
+ const parsed = parseAdr(root, rel, text);
56
66
  if (parsed)
57
67
  decisions.push(parsed);
58
68
  }
@@ -66,7 +76,11 @@ export async function appendDecisionLedgerEntry(rootInput, record) {
66
76
  const full = {
67
77
  ...record,
68
78
  source: record.source ?? "ledger",
79
+ sourceKind: record.sourceKind ?? "decision_ledger",
69
80
  truth: record.truth ?? "VERIFIED",
81
+ truthMeaning: record.truthMeaning ?? verifiedDecisionTruthMeaning(),
82
+ projectRoot: root,
83
+ ownership: record.ownership ?? "project_owned",
70
84
  recordedAt: record.recordedAt ?? new Date().toISOString(),
71
85
  };
72
86
  await fs.appendFile(ledgerFile(root), `${JSON.stringify(full)}\n`, "utf8");
@@ -76,7 +90,9 @@ export async function loadDecisionLedger(rootInput) {
76
90
  const root = resolveRepoRoot(rootInput);
77
91
  const limitations = [
78
92
  "ADR parsing reads markdown titles and Status lines only.",
79
- "Ledger JSONL entries are merged with discovered ADR files (deduped by id).",
93
+ "Only project-owned trees are scanned; nested repositories and private/validation checkouts are excluded.",
94
+ "VERIFIED means the ADR file was found and parsed — not that the architecture decision is independently proven correct.",
95
+ "Ledger JSONL entries are merged with discovered ADR files (deduped by id) when present and project-owned.",
80
96
  ];
81
97
  const fromAdr = await parseAdrDecisions(root);
82
98
  const fromLedger = [];
@@ -87,7 +103,22 @@ export async function loadDecisionLedger(rootInput) {
87
103
  if (!line.trim())
88
104
  continue;
89
105
  try {
90
- fromLedger.push(JSON.parse(line));
106
+ const raw = JSON.parse(line);
107
+ const evidencePath = raw.evidence?.[0]?.path ?? raw.id;
108
+ if (!isProjectOwnedRelativePath(String(evidencePath)) &&
109
+ !isProjectOwnedRelativePath(String(raw.id))) {
110
+ limitations.push(`Skipped non-owned ledger entry: ${raw.id}`);
111
+ continue;
112
+ }
113
+ fromLedger.push({
114
+ ...raw,
115
+ source: raw.source ?? "ledger",
116
+ sourceKind: raw.sourceKind ?? "decision_ledger",
117
+ truth: raw.truth ?? "VERIFIED",
118
+ truthMeaning: raw.truthMeaning ?? verifiedDecisionTruthMeaning(),
119
+ projectRoot: raw.projectRoot ?? root,
120
+ ownership: raw.ownership ?? classifyRelativePathOwnership(String(evidencePath)),
121
+ });
91
122
  }
92
123
  catch {
93
124
  limitations.push("Skipped malformed decision ledger line.");
@@ -25,6 +25,16 @@ export interface DiscoverProjectOptions {
25
25
  /** Auto-select highest score when exactly one strong candidate. */
26
26
  autoSelect?: boolean;
27
27
  }
28
+ /**
29
+ * True when `cwd` is the user home directory or a similarly unsafe broad folder
30
+ * (Desktop / Downloads / Documents). Used to refuse silent whole-home scans.
31
+ */
32
+ export declare function classifyBroadUserScanRoot(cwdInput: string, homeInput?: string): {
33
+ blocked: true;
34
+ reason: string;
35
+ } | {
36
+ blocked: false;
37
+ };
28
38
  /**
29
39
  * Discover likely project roots from cwd without silently scanning home/Desktop/Downloads
30
40
  * or unbounded parent trees.
@@ -84,6 +84,26 @@ function scoreMarkers(markers) {
84
84
  }
85
85
  return score;
86
86
  }
87
+ /**
88
+ * True when `cwd` is the user home directory or a similarly unsafe broad folder
89
+ * (Desktop / Downloads / Documents). Used to refuse silent whole-home scans.
90
+ */
91
+ export function classifyBroadUserScanRoot(cwdInput, homeInput) {
92
+ const cwd = path.resolve(cwdInput);
93
+ const home = normalizeHome(homeInput ?? os.homedir());
94
+ const baselower = path.basename(cwd).toLowerCase();
95
+ if (cwd === home ||
96
+ SUSPICIOUS_DIR_NAMES.has(baselower) ||
97
+ isUnderHomeSubtree(cwd, home, "Desktop") ||
98
+ isUnderHomeSubtree(cwd, home, "Downloads") ||
99
+ isUnderHomeSubtree(cwd, home, "Documents")) {
100
+ return {
101
+ blocked: true,
102
+ reason: "Refusing to scan home / Desktop / Downloads / Documents (or similarly broad user folders) as a project root. Navigate into a project directory or pass an explicit project path.",
103
+ };
104
+ }
105
+ return { blocked: false };
106
+ }
87
107
  /**
88
108
  * Discover likely project roots from cwd without silently scanning home/Desktop/Downloads
89
109
  * or unbounded parent trees.
@@ -96,12 +116,8 @@ export async function discoverProjectRoots(options = {}) {
96
116
  "Discovery uses shallow marker probes and bounded entry counts — not a full repository index.",
97
117
  "Candidates are VERIFIED only when marker files/directories exist on disk.",
98
118
  ];
99
- const baselower = path.basename(cwd).toLowerCase();
100
- if (cwd === home ||
101
- SUSPICIOUS_DIR_NAMES.has(baselower) ||
102
- isUnderHomeSubtree(cwd, home, "Desktop") ||
103
- isUnderHomeSubtree(cwd, home, "Downloads") ||
104
- isUnderHomeSubtree(cwd, home, "Documents")) {
119
+ const broad = classifyBroadUserScanRoot(cwd, home);
120
+ if (broad.blocked) {
105
121
  const estimatedEntries = await countEntriesShallow(cwd, Math.min(maxEntries, 5_000));
106
122
  return {
107
123
  cwd,
@@ -109,7 +125,7 @@ export async function discoverProjectRoots(options = {}) {
109
125
  candidates: [],
110
126
  selected: null,
111
127
  blocked: true,
112
- blockReason: "Refusing to scan home / Desktop / Downloads / Documents (or similarly broad user folders) as a project root. Navigate into a project directory or pass an explicit project path.",
128
+ blockReason: broad.reason,
113
129
  estimatedEntries,
114
130
  limitations,
115
131
  };
@@ -1,7 +1,7 @@
1
- import fs from "node:fs/promises";
2
1
  import path from "node:path";
3
2
  import { createHash } from "node:crypto";
4
3
  import { getAdapterForFile, parseSourceFile } from "../../languages/index.js";
4
+ import { discoverFiles } from "../../discovery/files.js";
5
5
  import { readTextFile } from "../../utils/fs.js";
6
6
  import { resolveRepoRoot, toPosixRelative } from "../../utils/path.js";
7
7
  function nodeId(kind, key) {
@@ -9,37 +9,12 @@ function nodeId(kind, key) {
9
9
  }
10
10
  const LANG_FILE_RE = /\.(py|php|go|java|kt|kts|rs|dart)$/i;
11
11
  async function listLanguageSourceFiles(root, limit = 120) {
12
- const out = [];
13
- const skip = new Set(["node_modules", ".git", "dist", "coverage", ".agentdoctor", "vendor"]);
14
- async function walk(dir) {
15
- if (out.length >= limit)
16
- return;
17
- let entries;
18
- try {
19
- entries = await fs.readdir(dir, { withFileTypes: true });
20
- }
21
- catch {
22
- return;
23
- }
24
- for (const e of entries) {
25
- if (out.length >= limit)
26
- return;
27
- if (skip.has(e.name))
28
- continue;
29
- const abs = path.join(dir, e.name);
30
- if (e.isSymbolicLink())
31
- continue;
32
- if (e.isDirectory()) {
33
- await walk(abs);
34
- continue;
35
- }
36
- if (LANG_FILE_RE.test(e.name)) {
37
- out.push(abs);
38
- }
39
- }
40
- }
41
- await walk(root);
42
- return out.sort();
12
+ const discovered = await discoverFiles({ root });
13
+ return discovered.files
14
+ .filter((f) => LANG_FILE_RE.test(f.relativePath))
15
+ .map((f) => f.absolutePath)
16
+ .sort()
17
+ .slice(0, limit);
43
18
  }
44
19
  function evidenceFromAdapter(kind) {
45
20
  if (kind === "ast")
@@ -39,7 +39,7 @@ export { analyzeInfra } from "./ops/infra.js";
39
39
  export type { InfraReport, InfraArtifact } from "./ops/infra.js";
40
40
  export { buildIncidentHypotheses } from "./ops/incident.js";
41
41
  export type { IncidentReport, IncidentTimelineItem } from "./ops/incident.js";
42
- export { discoverProjectRoots, resolveStartedProjectRoot } from "./discovery/roots.js";
42
+ export { discoverProjectRoots, resolveStartedProjectRoot, classifyBroadUserScanRoot, } from "./discovery/roots.js";
43
43
  export type { ProjectDiscoveryReport, ProjectCandidate, DiscoverProjectOptions, } from "./discovery/roots.js";
44
44
  export { buildProjectDna, persistProjectDna } from "./dna/build.js";
45
45
  export type { ProjectDna } from "./dna/build.js";
@@ -67,3 +67,6 @@ export { evaluateApprovalRecord, formatApprovalRecordSummary } from "./approval/
67
67
  export type { ApprovalRecord, ApprovalState, ApprovalRisk, ApprovalEvaluationInput, ApprovalEvaluationResult, } from "./approval/model.js";
68
68
  export { issueApprovalGrant, consumeApprovalGrant, hashPlanPayload, hashFileWritePlan, } from "./approval/session.js";
69
69
  export type { ApprovalGrant, ConsumeApprovalResult } from "./approval/session.js";
70
+ export { classifyRelativePathOwnership, decideDirectoryTraversal, isNestedRepositoryRoot, isProjectOwnedRelativePath, isUnderNestedRepository, assertProjectOwnedRepoPath, ProjectOwnershipError, OWNERSHIP_BOUNDARY_VERSION, verifiedDecisionTruthMeaning, } from "../project/ownership.js";
71
+ export type { ProjectOwnershipClass, DirectoryTraversalDecision } from "../project/ownership.js";
72
+ export type { DecisionSourceKind } from "./decisions/ledger.js";
@@ -19,7 +19,7 @@ export { runEvalLab } from "./eval/lab.js";
19
19
  export { diagnoseAgentDoctorSelf } from "./self/diagnose.js";
20
20
  export { analyzeInfra } from "./ops/infra.js";
21
21
  export { buildIncidentHypotheses } from "./ops/incident.js";
22
- export { discoverProjectRoots, resolveStartedProjectRoot } from "./discovery/roots.js";
22
+ export { discoverProjectRoots, resolveStartedProjectRoot, classifyBroadUserScanRoot, } from "./discovery/roots.js";
23
23
  export { buildProjectDna, persistProjectDna } from "./dna/build.js";
24
24
  export { enrichGraphWithLanguageAdapters } from "./graph/enrich-languages.js";
25
25
  export { analyzeSecuritySurface } from "./security/doctor.js";
@@ -33,3 +33,4 @@ export { buildSoftwareEvolutionTimeline } from "./evolution/timeline.js";
33
33
  export { queryMemory } from "./memory/institutional.js";
34
34
  export { evaluateApprovalRecord, formatApprovalRecordSummary } from "./approval/model.js";
35
35
  export { issueApprovalGrant, consumeApprovalGrant, hashPlanPayload, hashFileWritePlan, } from "./approval/session.js";
36
+ export { classifyRelativePathOwnership, decideDirectoryTraversal, isNestedRepositoryRoot, isProjectOwnedRelativePath, isUnderNestedRepository, assertProjectOwnedRepoPath, ProjectOwnershipError, OWNERSHIP_BOUNDARY_VERSION, verifiedDecisionTruthMeaning, } from "../project/ownership.js";
@@ -3,6 +3,7 @@ import path from "node:path";
3
3
  import { DEFAULT_MAX_FILE_SIZE_BYTES } from "../../constants.js";
4
4
  import { detectProject } from "../../detectors/project.js";
5
5
  import { detectMonorepo } from "../../detectors/monorepo.js";
6
+ import { isProjectOwnedRelativePath } from "../../project/ownership.js";
6
7
  import { resolveRepoRoot } from "../../utils/path.js";
7
8
  function nodeId(prefix, p) {
8
9
  return `${prefix}:${p.replace(/[^\w./-]/g, "_")}`;
@@ -11,7 +12,7 @@ async function topLevelEntries(root) {
11
12
  try {
12
13
  const entries = await fs.readdir(root, { withFileTypes: true });
13
14
  return entries
14
- .filter((e) => e.isDirectory() && !e.name.startsWith("."))
15
+ .filter((e) => e.isDirectory() && !e.name.startsWith(".") && isProjectOwnedRelativePath(e.name))
15
16
  .map((e) => e.name)
16
17
  .sort();
17
18
  }
@@ -19,6 +19,11 @@ export async function queryMemory(rootInput, query) {
19
19
  const brain = await loadLatestBrain(root);
20
20
  if (brain) {
21
21
  for (const bh of searchBrain(brain, q).slice(0, 15)) {
22
+ if (bh.text.includes(".private/") ||
23
+ bh.text.includes("AgentDoctorOS/") ||
24
+ bh.text.includes("oss-validation/")) {
25
+ continue;
26
+ }
22
27
  hits.push({
23
28
  source: "brain",
24
29
  id: bh.id,
@@ -22,6 +22,11 @@ export async function searchSymbolsAndConcepts(rootInput, query, options) {
22
22
  const brain = await loadLatestBrain(root);
23
23
  if (brain) {
24
24
  for (const bh of searchBrain(brain, q)) {
25
+ if (bh.text.includes(".private/") ||
26
+ bh.text.includes("AgentDoctorOS/") ||
27
+ bh.text.includes("oss-validation/")) {
28
+ continue;
29
+ }
25
30
  hits.push({
26
31
  kind: "brain",
27
32
  path: ".agentdoctor/project-brain",
@@ -5,6 +5,7 @@ import { DEFAULT_MAX_FILE_SIZE_BYTES } from "../../constants.js";
5
5
  import { atomicWriteTextFile, pathExists, readJsonFile } from "../../utils/fs.js";
6
6
  import { resolveRepoRoot } from "../../utils/path.js";
7
7
  import { buildSoftwareDigitalTwinSnapshot } from "./digital-twin.js";
8
+ import { OWNERSHIP_BOUNDARY_VERSION } from "../../project/ownership.js";
8
9
  function twinDir(root) {
9
10
  return path.join(resolveRepoRoot(root), ".agentdoctor", "twin");
10
11
  }
@@ -12,7 +13,10 @@ function snapshotPath(root) {
12
13
  return path.join(twinDir(root), "snapshot.json");
13
14
  }
14
15
  function hashInputs(changedFiles) {
15
- const payload = (changedFiles ?? []).slice().sort().join("\n");
16
+ const payload = [
17
+ `ownership-boundary:${OWNERSHIP_BOUNDARY_VERSION}`,
18
+ ...(changedFiles ?? []).slice().sort(),
19
+ ].join("\n");
16
20
  return createHash("sha256").update(payload).digest("hex").slice(0, 16);
17
21
  }
18
22
  export async function loadTwinSnapshot(rootInput) {
@@ -22,6 +26,10 @@ export async function loadTwinSnapshot(rootInput) {
22
26
  return null;
23
27
  if (parsed.data.meta.root !== root)
24
28
  return null;
29
+ // Reject twins built under a prior ownership boundary (or any mismatched hash).
30
+ const expected = hashInputs(parsed.data.meta.changedFiles);
31
+ if (parsed.data.meta.invalidationHash !== expected)
32
+ return null;
25
33
  return parsed.data;
26
34
  }
27
35
  export async function saveTwinSnapshot(rootInput, twin, meta) {
@@ -1,16 +1,37 @@
1
1
  import { deriveGraphChangeImpact } from "../../assurance/change.js";
2
2
  import { buildIntelligenceGraph } from "../../intelligence/graph/build.js";
3
+ import { assertProjectOwnedRepoPath, classifyRelativePathOwnership, ProjectOwnershipError, } from "../../project/ownership.js";
4
+ import { PathEscapeError, resolveSafeRepoPath } from "../../security/paths.js";
3
5
  import { resolveRepoRoot } from "../../utils/path.js";
4
6
  import { minProductTruth } from "../truth.js";
5
7
  function normalizeTarget(target) {
6
8
  return target.replace(/\\/g, "/").replace(/^\.\//, "");
7
9
  }
10
+ async function assertOwnedWhatIfTarget(root, target) {
11
+ const normalized = normalizeTarget(target);
12
+ const lexical = classifyRelativePathOwnership(normalized);
13
+ if (lexical !== "project_owned") {
14
+ throw new ProjectOwnershipError(lexical, `what-if target outside project ownership (${lexical})`);
15
+ }
16
+ try {
17
+ const abs = resolveSafeRepoPath(root, normalized);
18
+ await assertProjectOwnedRepoPath(root, abs, normalized);
19
+ }
20
+ catch (error) {
21
+ if (error instanceof PathEscapeError || error instanceof ProjectOwnershipError) {
22
+ throw error;
23
+ }
24
+ // Non-existent owned path remains a valid hypothetical change target.
25
+ }
26
+ return normalized;
27
+ }
8
28
  export async function analyzeWhatIf(rootInput, target, options) {
9
29
  const root = resolveRepoRoot(rootInput);
10
- const normalized = normalizeTarget(target);
30
+ const normalized = await assertOwnedWhatIfTarget(root, target);
11
31
  const limitations = [
12
32
  "Impact analysis uses intelligence graph import/call edges when available.",
13
33
  "Symbol-level precision depends on graph node coverage.",
34
+ "What-if targets must be project-owned paths (containment alone is not ownership).",
14
35
  ];
15
36
  let graph = options?.graph;
16
37
  if (!graph && options?.buildGraph !== false) {