@praneeth_54/agentdoctor 3.0.0 → 3.0.2

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 (63) hide show
  1. package/CHANGELOG.md +52 -1
  2. package/README.md +10 -8
  3. package/dist/agent/chat/deterministic.js +22 -4
  4. package/dist/agent/context/retrieve.d.ts +2 -1
  5. package/dist/agent/context/retrieve.js +14 -1
  6. package/dist/agent/tools/execute.js +39 -43
  7. package/dist/agent/tools/write.d.ts +1 -1
  8. package/dist/agent/tools/write.js +18 -4
  9. package/dist/ai/providers/adversarial-local.d.ts +7 -0
  10. package/dist/ai/providers/adversarial-local.js +146 -0
  11. package/dist/cli/commands/agent.js +13 -3
  12. package/dist/cli/commands/architecture.js +7 -2
  13. package/dist/cli/commands/brain.js +7 -2
  14. package/dist/cli/commands/chat.js +13 -3
  15. package/dist/cli/commands/complete.js +13 -3
  16. package/dist/cli/commands/fix.js +6 -0
  17. package/dist/cli/commands/learn.js +7 -2
  18. package/dist/cli/commands/mcp.js +7 -3
  19. package/dist/cli/commands/platform.js +7 -2
  20. package/dist/cli/commands/policy-graph-run.js +7 -0
  21. package/dist/cli/commands/product.js +13 -3
  22. package/dist/cli/commands/scan.js +6 -0
  23. package/dist/cli/commands/start.js +22 -7
  24. package/dist/cli/commands/v2.js +61 -11
  25. package/dist/cli/commands/verify.js +6 -0
  26. package/dist/cli/safe-root.d.ts +17 -0
  27. package/dist/cli/safe-root.js +32 -0
  28. package/dist/constants.d.ts +1 -1
  29. package/dist/constants.js +1 -1
  30. package/dist/core/brain-cli/service.js +5 -2
  31. package/dist/core/monorepo/detect.js +10 -0
  32. package/dist/core/secrets/scan.js +9 -0
  33. package/dist/core/understanding/brain/storage/store.d.ts +4 -0
  34. package/dist/core/understanding/brain/storage/store.js +22 -2
  35. package/dist/dashboard/page.d.ts +2 -0
  36. package/dist/dashboard/page.js +53 -0
  37. package/dist/dashboard/server.js +20 -244
  38. package/dist/dashboard/ui/client.d.ts +2 -0
  39. package/dist/dashboard/ui/client.js +2 -0
  40. package/dist/dashboard/ui/styles.d.ts +2 -0
  41. package/dist/dashboard/ui/styles.js +138 -0
  42. package/dist/discovery/files.js +13 -0
  43. package/dist/intelligence/graph/build.js +10 -30
  44. package/dist/intelligence/graph/incremental.d.ts +2 -0
  45. package/dist/intelligence/graph/incremental.js +9 -0
  46. package/dist/mcp/intelligence/path-safety.d.ts +6 -1
  47. package/dist/mcp/intelligence/path-safety.js +55 -2
  48. package/dist/platform/graph/build.js +9 -0
  49. package/dist/product/decisions/ledger.d.ts +13 -1
  50. package/dist/product/decisions/ledger.js +41 -10
  51. package/dist/product/discovery/roots.d.ts +10 -0
  52. package/dist/product/discovery/roots.js +137 -15
  53. package/dist/product/graph/enrich-languages.js +7 -32
  54. package/dist/product/index.d.ts +4 -1
  55. package/dist/product/index.js +2 -1
  56. package/dist/product/map/software-map.js +2 -1
  57. package/dist/product/memory/institutional.js +5 -0
  58. package/dist/product/search/software-search.js +5 -0
  59. package/dist/product/twin/store.js +9 -1
  60. package/dist/product/whatif/engine.js +22 -1
  61. package/dist/project/ownership.d.ts +65 -0
  62. package/dist/project/ownership.js +169 -0
  63. package/package.json +1 -1
@@ -2,6 +2,7 @@ import fs from "node:fs/promises";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { DEFAULT_IGNORE_DIRECTORIES } from "../../constants.js";
5
+ import { detectProject } from "../../detectors/project.js";
5
6
  import { isDirectory, pathExists } from "../../utils/fs.js";
6
7
  import { resolveRepoRoot } from "../../utils/path.js";
7
8
  /** Markers that strongly suggest a software project root. */
@@ -19,6 +20,8 @@ const PROJECT_MARKERS = [
19
20
  "pubspec.yaml",
20
21
  ".git",
21
22
  ];
23
+ /** Directory basenames that must never be treated as a project root candidate. */
24
+ const FORBIDDEN_ROOT_BASENAMES = new Set([".private", "agentdoctoros"]);
22
25
  const SUSPICIOUS_DIR_NAMES = new Set([
23
26
  "desktop",
24
27
  "downloads",
@@ -32,9 +35,19 @@ const SUSPICIOUS_DIR_NAMES = new Set([
32
35
  function normalizeHome(home) {
33
36
  return path.resolve(home);
34
37
  }
38
+ function pathsEqual(a, b) {
39
+ const left = path.resolve(a);
40
+ const right = path.resolve(b);
41
+ return process.platform === "win32" ? left.toLowerCase() === right.toLowerCase() : left === right;
42
+ }
35
43
  function isUnderHomeSubtree(abs, home, leaf) {
36
44
  const target = path.resolve(home, leaf);
37
45
  const resolved = path.resolve(abs);
46
+ if (process.platform === "win32") {
47
+ const t = target.toLowerCase();
48
+ const r = resolved.toLowerCase();
49
+ return r === t || r.startsWith(t + "\\");
50
+ }
38
51
  return resolved === target || resolved.startsWith(target + path.sep);
39
52
  }
40
53
  async function countEntriesShallow(dir, budget) {
@@ -84,6 +97,26 @@ function scoreMarkers(markers) {
84
97
  }
85
98
  return score;
86
99
  }
100
+ /**
101
+ * True when `cwd` is the user home directory or a similarly unsafe broad folder
102
+ * (Desktop / Downloads / Documents). Used to refuse silent whole-home scans.
103
+ */
104
+ export function classifyBroadUserScanRoot(cwdInput, homeInput) {
105
+ const cwd = path.resolve(cwdInput);
106
+ const home = normalizeHome(homeInput ?? os.homedir());
107
+ const baselower = path.basename(cwd).toLowerCase();
108
+ if (pathsEqual(cwd, home) ||
109
+ SUSPICIOUS_DIR_NAMES.has(baselower) ||
110
+ isUnderHomeSubtree(cwd, home, "Desktop") ||
111
+ isUnderHomeSubtree(cwd, home, "Downloads") ||
112
+ isUnderHomeSubtree(cwd, home, "Documents")) {
113
+ return {
114
+ blocked: true,
115
+ 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.",
116
+ };
117
+ }
118
+ return { blocked: false };
119
+ }
87
120
  /**
88
121
  * Discover likely project roots from cwd without silently scanning home/Desktop/Downloads
89
122
  * or unbounded parent trees.
@@ -93,24 +126,20 @@ export async function discoverProjectRoots(options = {}) {
93
126
  const home = normalizeHome(os.homedir());
94
127
  const maxEntries = options.maxEntries ?? 25_000;
95
128
  const limitations = [
96
- "Discovery uses shallow marker probes and bounded entry counts — not a full repository index.",
97
- "Candidates are VERIFIED only when marker files/directories exist on disk.",
129
+ "Discovery uses shallow marker probes, bounded entry counts, and detectProject for cwd/prefer when classic markers are absent.",
130
+ "Candidates are VERIFIED only when marker files exist or detectProject finds languages/source under that root.",
98
131
  ];
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")) {
105
- const estimatedEntries = await countEntriesShallow(cwd, Math.min(maxEntries, 5_000));
132
+ const broad = classifyBroadUserScanRoot(cwd, home);
133
+ if (broad.blocked) {
106
134
  return {
107
135
  cwd,
108
136
  home,
109
137
  candidates: [],
110
138
  selected: null,
111
139
  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.",
113
- estimatedEntries,
140
+ blockReason: broad.reason,
141
+ // Do not walk the tree — counting home/Desktop can hang CI (esp. Windows).
142
+ estimatedEntries: 0,
114
143
  limitations,
115
144
  };
116
145
  }
@@ -129,13 +158,19 @@ export async function discoverProjectRoots(options = {}) {
129
158
  }
130
159
  const candidates = [];
131
160
  const seen = new Set();
161
+ function rejectForbiddenRoot(resolved) {
162
+ const base = path.basename(resolved).toLowerCase();
163
+ return FORBIDDEN_ROOT_BASENAMES.has(base);
164
+ }
132
165
  async function consider(dir, reason) {
133
166
  const resolved = path.resolve(dir);
134
167
  if (seen.has(resolved))
135
168
  return;
136
169
  if (!(await isDirectory(resolved)))
137
170
  return;
138
- if (resolved === home)
171
+ if (pathsEqual(resolved, home))
172
+ return;
173
+ if (rejectForbiddenRoot(resolved))
139
174
  return;
140
175
  const markers = await markersAt(resolved);
141
176
  if (markers.length === 0)
@@ -149,6 +184,46 @@ export async function discoverProjectRoots(options = {}) {
149
184
  reason,
150
185
  });
151
186
  }
187
+ /**
188
+ * When classic manifests/.git are absent, reuse the same detectProject path as DNA
189
+ * so `start` agrees with `dna` for valid software trees (e.g. language-only roots).
190
+ * Only applied to prefer/cwd — never to unbounded parent/child walks.
191
+ */
192
+ async function considerDetectProject(dir, reason) {
193
+ const resolved = path.resolve(dir);
194
+ if (seen.has(resolved))
195
+ return;
196
+ if (!(await isDirectory(resolved)))
197
+ return;
198
+ if (pathsEqual(resolved, home))
199
+ return;
200
+ if (rejectForbiddenRoot(resolved))
201
+ return;
202
+ if (classifyBroadUserScanRoot(resolved, home).blocked)
203
+ return;
204
+ let detection;
205
+ try {
206
+ detection = await detectProject(resolved);
207
+ }
208
+ catch {
209
+ return;
210
+ }
211
+ // detectProject returns ["unknown"] for empty trees — that is not a project signal.
212
+ const languages = detection.repository.languages.filter((l) => l !== "unknown");
213
+ const sourceFiles = detection.discovery.files.filter((f) => /\.(ts|tsx|js|jsx|mjs|cjs|py|php|go|rs|java|kt|kts|dart|rb|cs)$/i.test(f.relativePath)).length;
214
+ if (languages.length === 0 && sourceFiles === 0)
215
+ return;
216
+ seen.add(resolved);
217
+ const markers = languages.length > 0 ? languages.map((l) => `lang:${l}`) : [`source-files:${sourceFiles}`];
218
+ candidates.push({
219
+ root: resolved,
220
+ // Match strong marker score so a lone detectProject hit auto-selects like package.json.
221
+ score: languages.length > 0 ? 5 : 3,
222
+ markers,
223
+ truth: "VERIFIED",
224
+ reason,
225
+ });
226
+ }
152
227
  if (options.prefer) {
153
228
  await consider(options.prefer, "explicit prefer path");
154
229
  }
@@ -156,7 +231,7 @@ export async function discoverProjectRoots(options = {}) {
156
231
  // Walk up a few parents looking for markers (bounded).
157
232
  let parent = path.dirname(cwd);
158
233
  for (let i = 0; i < 4; i++) {
159
- if (parent === home || parent === path.dirname(parent))
234
+ if (pathsEqual(parent, home) || parent === path.dirname(parent))
160
235
  break;
161
236
  await consider(parent, "parent directory with project markers");
162
237
  parent = path.dirname(parent);
@@ -169,16 +244,63 @@ export async function discoverProjectRoots(options = {}) {
169
244
  continue;
170
245
  if (DEFAULT_IGNORE_DIRECTORIES.has(entry.name) || entry.name.startsWith("."))
171
246
  continue;
247
+ if (FORBIDDEN_ROOT_BASENAMES.has(entry.name.toLowerCase()))
248
+ continue;
172
249
  await consider(path.join(cwd, entry.name), "child directory with project markers");
173
250
  }
174
251
  }
175
252
  catch {
176
253
  limitations.push("Could not list child directories for candidate discovery.");
177
254
  }
255
+ // detectProject fallback (same engine as DNA) for prefer/cwd when no classic markers
256
+ // apply to that path AND no ancestor marker candidate already covers it (nested Case B).
257
+ // Also skip when child classic-marker projects already exist — otherwise detectProject
258
+ // would absorb sibling packages into a multi-project parent (start always passes prefer=cwd).
259
+ const preferAbs = options.prefer ? path.resolve(options.prefer) : null;
260
+ function ancestorMarkerCovers(target) {
261
+ return candidates.find((c) => {
262
+ if (pathsEqual(c.root, target))
263
+ return true;
264
+ const rootWithSep = c.root.endsWith(path.sep) ? c.root : c.root + path.sep;
265
+ if (process.platform === "win32") {
266
+ return target.toLowerCase().startsWith(rootWithSep.toLowerCase());
267
+ }
268
+ return target.startsWith(rootWithSep);
269
+ });
270
+ }
271
+ function descendantMarkerCandidates(target) {
272
+ const targetWithSep = target.endsWith(path.sep) ? target : target + path.sep;
273
+ return candidates.filter((c) => {
274
+ if (pathsEqual(c.root, target))
275
+ return false;
276
+ if (process.platform === "win32") {
277
+ return c.root.toLowerCase().startsWith(targetWithSep.toLowerCase());
278
+ }
279
+ return c.root.startsWith(targetWithSep);
280
+ });
281
+ }
282
+ if (preferAbs) {
283
+ // Skip detectProject absorption when 2+ classic child projects exist (multi-project
284
+ // parent). A single nested foreign repo must not block prefer's own language tree.
285
+ if (!ancestorMarkerCovers(preferAbs) && descendantMarkerCandidates(preferAbs).length < 2) {
286
+ await considerDetectProject(preferAbs, "explicit prefer path (detectProject)");
287
+ }
288
+ }
289
+ else if (candidates.length === 0) {
290
+ await considerDetectProject(cwd, "current working directory (detectProject)");
291
+ }
178
292
  candidates.sort((a, b) => b.score - a.score || a.root.localeCompare(b.root));
179
293
  let selected = null;
180
- if (options.prefer) {
181
- selected = candidates.find((c) => c.root === path.resolve(options.prefer)) ?? null;
294
+ if (preferAbs) {
295
+ const exact = candidates.find((c) => pathsEqual(c.root, preferAbs));
296
+ const covered = ancestorMarkerCovers(preferAbs);
297
+ // Nested dir under a marker project → select the marker root, not the nested leaf.
298
+ if (covered && !pathsEqual(covered.root, preferAbs)) {
299
+ selected = covered;
300
+ }
301
+ else {
302
+ selected = exact ?? covered ?? null;
303
+ }
182
304
  }
183
305
  else if (options.autoSelect !== false) {
184
306
  const strong = candidates.filter((c) => c.score >= 5);
@@ -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) {
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Semantic project ownership — filesystem containment alone is not ownership.
3
+ *
4
+ * VERIFIED file evidence still means "we read this path under the scan root";
5
+ * ownership classification decides whether that path may feed current-project
6
+ * intelligence (DNA, decisions, search corpus, etc.).
7
+ */
8
+ export type ProjectOwnershipClass = "project_owned" | "nested_repository" | "private_workspace" | "internal_docs" | "fixture_tree" | "validation_checkout" | "generated" | "unknown";
9
+ /** Top-level directory names that are never current-project source by default. */
10
+ export declare const NON_OWNED_TOP_LEVEL_DIRECTORIES: Set<string>;
11
+ /** Relative path prefixes (posix) treated as non-owned validation/fixture trees. */
12
+ export declare const NON_OWNED_PATH_PREFIXES: readonly ["validation/real-world/repositories/checkouts/", "fixtures/nested-repos/"];
13
+ /** Bump when ownership/traversal rules change so cached twins/brain consumers invalidate. */
14
+ export declare const OWNERSHIP_BOUNDARY_VERSION = 4;
15
+ export declare class ProjectOwnershipError extends Error {
16
+ readonly code = "PROJECT_OWNERSHIP";
17
+ readonly ownership: ProjectOwnershipClass;
18
+ constructor(ownership: ProjectOwnershipClass, message?: string);
19
+ }
20
+ export interface DirectoryTraversalDecision {
21
+ traverse: boolean;
22
+ ownership: ProjectOwnershipClass;
23
+ reason: string;
24
+ }
25
+ /**
26
+ * Classify a relative path for current-project ownership (no I/O).
27
+ * Nested git detection is handled separately during traversal.
28
+ */
29
+ export declare function classifyRelativePathOwnership(relativePath: string): ProjectOwnershipClass;
30
+ export declare function isProjectOwnedRelativePath(relativePath: string): boolean;
31
+ /**
32
+ * True when `dir` is a nested VCS root (has `.git` file or directory) and is
33
+ * not the canonical project root itself.
34
+ */
35
+ export declare function isNestedRepositoryRoot(projectRootInput: string, dirAbsolute: string): Promise<boolean>;
36
+ /**
37
+ * Decide whether discovery should descend into a directory under the project root.
38
+ */
39
+ export declare function decideDirectoryTraversal(options: {
40
+ projectRoot: string;
41
+ absoluteDir: string;
42
+ relativeDir: string;
43
+ }): Promise<DirectoryTraversalDecision>;
44
+ /**
45
+ * True when any ancestor directory under `projectRoot` (excluding the root) is a nested VCS root.
46
+ * Used for path-targeted access (agent/MCP) where discovery traversal never ran.
47
+ */
48
+ export declare function isUnderNestedRepository(projectRootInput: string, absolutePath: string): Promise<boolean>;
49
+ /**
50
+ * Assert a user-supplied path is both inside the repo root AND project-owned.
51
+ * Containment alone is not ownership (.private, AgentDoctorOS, nested repos, fixtures).
52
+ *
53
+ * Ownership is classified from the realpath-relative path under the real project root
54
+ * so symlink aliases (e.g. link-to-private → .private) cannot launder ownership.
55
+ * Lexical hints are also rejected when they themselves name a non-owned tree.
56
+ */
57
+ export declare function assertProjectOwnedRepoPath(projectRootInput: string, absolutePath: string, relativeHint?: string): Promise<{
58
+ absolutePath: string;
59
+ relativePath: string;
60
+ ownership: ProjectOwnershipClass;
61
+ }>;
62
+ /**
63
+ * Human-readable meaning for decision truth labels in product UI.
64
+ */
65
+ export declare function verifiedDecisionTruthMeaning(): string;
@@ -0,0 +1,169 @@
1
+ import path from "node:path";
2
+ import fs from "node:fs";
3
+ import { pathExists } from "../utils/fs.js";
4
+ import { resolveRepoRoot, toPosixRelative } from "../utils/path.js";
5
+ /** Top-level directory names that are never current-project source by default. */
6
+ export const NON_OWNED_TOP_LEVEL_DIRECTORIES = new Set([".private", "AgentDoctorOS"]);
7
+ /** Relative path prefixes (posix) treated as non-owned validation/fixture trees. */
8
+ export const NON_OWNED_PATH_PREFIXES = [
9
+ "validation/real-world/repositories/checkouts/",
10
+ "fixtures/nested-repos/",
11
+ ];
12
+ /** Bump when ownership/traversal rules change so cached twins/brain consumers invalidate. */
13
+ export const OWNERSHIP_BOUNDARY_VERSION = 4;
14
+ export class ProjectOwnershipError extends Error {
15
+ code = "PROJECT_OWNERSHIP";
16
+ ownership;
17
+ constructor(ownership, message) {
18
+ super(message ?? `path is outside project ownership (${ownership})`);
19
+ this.name = "ProjectOwnershipError";
20
+ this.ownership = ownership;
21
+ }
22
+ }
23
+ function firstSegment(relativePosix) {
24
+ const norm = relativePosix.replace(/\\/g, "/").replace(/^\.\//, "");
25
+ if (!norm || norm === ".")
26
+ return "";
27
+ return norm.split("/")[0] ?? "";
28
+ }
29
+ /**
30
+ * Classify a relative path for current-project ownership (no I/O).
31
+ * Nested git detection is handled separately during traversal.
32
+ */
33
+ export function classifyRelativePathOwnership(relativePath) {
34
+ const norm = relativePath.replace(/\\/g, "/").replace(/^\.\//, "");
35
+ if (!norm || norm === ".")
36
+ return "project_owned";
37
+ const top = firstSegment(norm);
38
+ if (top === ".private")
39
+ return "private_workspace";
40
+ if (top === "AgentDoctorOS")
41
+ return "internal_docs";
42
+ if (top === "fixtures")
43
+ return "fixture_tree";
44
+ for (const prefix of NON_OWNED_PATH_PREFIXES) {
45
+ if (norm === prefix.slice(0, -1) || norm.startsWith(prefix)) {
46
+ return "validation_checkout";
47
+ }
48
+ }
49
+ if (norm.startsWith("validation/") && norm.includes("/checkouts/")) {
50
+ return "validation_checkout";
51
+ }
52
+ return "project_owned";
53
+ }
54
+ export function isProjectOwnedRelativePath(relativePath) {
55
+ return classifyRelativePathOwnership(relativePath) === "project_owned";
56
+ }
57
+ /**
58
+ * True when `dir` is a nested VCS root (has `.git` file or directory) and is
59
+ * not the canonical project root itself.
60
+ */
61
+ export async function isNestedRepositoryRoot(projectRootInput, dirAbsolute) {
62
+ const root = resolveRepoRoot(projectRootInput);
63
+ const abs = path.resolve(dirAbsolute);
64
+ if (abs === root)
65
+ return false;
66
+ return pathExists(path.join(abs, ".git"));
67
+ }
68
+ /**
69
+ * Decide whether discovery should descend into a directory under the project root.
70
+ */
71
+ export async function decideDirectoryTraversal(options) {
72
+ const root = resolveRepoRoot(options.projectRoot);
73
+ const abs = path.resolve(options.absoluteDir);
74
+ const rel = toPosixRelative(root, abs) || options.relativeDir.replace(/\\/g, "/");
75
+ if (abs === root) {
76
+ return {
77
+ traverse: true,
78
+ ownership: "project_owned",
79
+ reason: "canonical project root",
80
+ };
81
+ }
82
+ const pathClass = classifyRelativePathOwnership(rel);
83
+ if (pathClass !== "project_owned") {
84
+ return {
85
+ traverse: false,
86
+ ownership: pathClass,
87
+ reason: `non-owned tree (${pathClass})`,
88
+ };
89
+ }
90
+ if (await isNestedRepositoryRoot(root, abs)) {
91
+ return {
92
+ traverse: false,
93
+ ownership: "nested_repository",
94
+ reason: "nested .git repository boundary",
95
+ };
96
+ }
97
+ return {
98
+ traverse: true,
99
+ ownership: "project_owned",
100
+ reason: "inside project-owned tree",
101
+ };
102
+ }
103
+ /**
104
+ * True when any ancestor directory under `projectRoot` (excluding the root) is a nested VCS root.
105
+ * Used for path-targeted access (agent/MCP) where discovery traversal never ran.
106
+ */
107
+ export async function isUnderNestedRepository(projectRootInput, absolutePath) {
108
+ const root = resolveRepoRoot(projectRootInput);
109
+ let current = path.resolve(absolutePath);
110
+ if (current === root)
111
+ return false;
112
+ // Walk ancestors including the path itself when it is a directory.
113
+ for (;;) {
114
+ if (current !== root && (await pathExists(path.join(current, ".git")))) {
115
+ return true;
116
+ }
117
+ if (current === root)
118
+ return false;
119
+ const parent = path.dirname(current);
120
+ if (parent === current)
121
+ return false;
122
+ current = parent;
123
+ if (current === root)
124
+ return false;
125
+ }
126
+ }
127
+ function realpathOrResolve(candidate) {
128
+ try {
129
+ return fs.realpathSync(candidate);
130
+ }
131
+ catch {
132
+ return path.resolve(candidate);
133
+ }
134
+ }
135
+ /**
136
+ * Assert a user-supplied path is both inside the repo root AND project-owned.
137
+ * Containment alone is not ownership (.private, AgentDoctorOS, nested repos, fixtures).
138
+ *
139
+ * Ownership is classified from the realpath-relative path under the real project root
140
+ * so symlink aliases (e.g. link-to-private → .private) cannot launder ownership.
141
+ * Lexical hints are also rejected when they themselves name a non-owned tree.
142
+ */
143
+ export async function assertProjectOwnedRepoPath(projectRootInput, absolutePath, relativeHint) {
144
+ const root = resolveRepoRoot(projectRootInput);
145
+ const abs = path.resolve(absolutePath);
146
+ const realRoot = realpathOrResolve(root);
147
+ const realAbs = realpathOrResolve(abs);
148
+ const resolvedRelative = toPosixRelative(realRoot, realAbs) || ".";
149
+ if (relativeHint) {
150
+ const hintClass = classifyRelativePathOwnership(relativeHint.replace(/\\/g, "/"));
151
+ if (hintClass !== "project_owned") {
152
+ throw new ProjectOwnershipError(hintClass);
153
+ }
154
+ }
155
+ const pathClass = classifyRelativePathOwnership(resolvedRelative);
156
+ if (pathClass !== "project_owned") {
157
+ throw new ProjectOwnershipError(pathClass);
158
+ }
159
+ if (await isUnderNestedRepository(root, realAbs)) {
160
+ throw new ProjectOwnershipError("nested_repository");
161
+ }
162
+ return { absolutePath: realAbs, relativePath: resolvedRelative, ownership: "project_owned" };
163
+ }
164
+ /**
165
+ * Human-readable meaning for decision truth labels in product UI.
166
+ */
167
+ export function verifiedDecisionTruthMeaning() {
168
+ return "File evidence was found and parsed for the current project. This does not prove the architectural decision is correct.";
169
+ }