@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.
- package/CHANGELOG.md +52 -1
- package/README.md +10 -8
- package/dist/agent/chat/deterministic.js +22 -4
- package/dist/agent/context/retrieve.d.ts +2 -1
- package/dist/agent/context/retrieve.js +14 -1
- package/dist/agent/tools/execute.js +39 -43
- package/dist/agent/tools/write.d.ts +1 -1
- package/dist/agent/tools/write.js +18 -4
- package/dist/ai/providers/adversarial-local.d.ts +7 -0
- package/dist/ai/providers/adversarial-local.js +146 -0
- package/dist/cli/commands/agent.js +13 -3
- package/dist/cli/commands/architecture.js +7 -2
- package/dist/cli/commands/brain.js +7 -2
- package/dist/cli/commands/chat.js +13 -3
- package/dist/cli/commands/complete.js +13 -3
- package/dist/cli/commands/fix.js +6 -0
- package/dist/cli/commands/learn.js +7 -2
- package/dist/cli/commands/mcp.js +7 -3
- package/dist/cli/commands/platform.js +7 -2
- package/dist/cli/commands/policy-graph-run.js +7 -0
- package/dist/cli/commands/product.js +13 -3
- package/dist/cli/commands/scan.js +6 -0
- package/dist/cli/commands/start.js +22 -7
- package/dist/cli/commands/v2.js +61 -11
- package/dist/cli/commands/verify.js +6 -0
- package/dist/cli/safe-root.d.ts +17 -0
- package/dist/cli/safe-root.js +32 -0
- package/dist/constants.d.ts +1 -1
- package/dist/constants.js +1 -1
- package/dist/core/brain-cli/service.js +5 -2
- package/dist/core/monorepo/detect.js +10 -0
- package/dist/core/secrets/scan.js +9 -0
- package/dist/core/understanding/brain/storage/store.d.ts +4 -0
- package/dist/core/understanding/brain/storage/store.js +22 -2
- package/dist/dashboard/page.d.ts +2 -0
- package/dist/dashboard/page.js +53 -0
- package/dist/dashboard/server.js +20 -244
- package/dist/dashboard/ui/client.d.ts +2 -0
- package/dist/dashboard/ui/client.js +2 -0
- package/dist/dashboard/ui/styles.d.ts +2 -0
- package/dist/dashboard/ui/styles.js +138 -0
- package/dist/discovery/files.js +13 -0
- package/dist/intelligence/graph/build.js +10 -30
- package/dist/intelligence/graph/incremental.d.ts +2 -0
- package/dist/intelligence/graph/incremental.js +9 -0
- package/dist/mcp/intelligence/path-safety.d.ts +6 -1
- package/dist/mcp/intelligence/path-safety.js +55 -2
- package/dist/platform/graph/build.js +9 -0
- package/dist/product/decisions/ledger.d.ts +13 -1
- package/dist/product/decisions/ledger.js +41 -10
- package/dist/product/discovery/roots.d.ts +10 -0
- package/dist/product/discovery/roots.js +137 -15
- package/dist/product/graph/enrich-languages.js +7 -32
- package/dist/product/index.d.ts +4 -1
- package/dist/product/index.js +2 -1
- package/dist/product/map/software-map.js +2 -1
- package/dist/product/memory/institutional.js +5 -0
- package/dist/product/search/software-search.js +5 -0
- package/dist/product/twin/store.js +9 -1
- package/dist/product/whatif/engine.js +22 -1
- package/dist/project/ownership.d.ts +65 -0
- package/dist/project/ownership.js +169 -0
- 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
|
|
97
|
-
"Candidates are VERIFIED only when marker files
|
|
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
|
|
100
|
-
if (
|
|
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:
|
|
113
|
-
|
|
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
|
|
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
|
|
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 (
|
|
181
|
-
|
|
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
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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")
|
package/dist/product/index.d.ts
CHANGED
|
@@ -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";
|
package/dist/product/index.js
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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
|
+
}
|