agenthusk 0.1.0

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/src/scanner.js ADDED
@@ -0,0 +1,768 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import os from "node:os";
5
+ import { AGENT_CATALOG, knownRoots } from "./catalog.js";
6
+
7
+ const SENSITIVE_FILE_PATTERN = /(?:^|[/_.-])(?:\.?env|credentials?|secrets?|tokens?|auth|session|history|logs?|config)(?:$|[/_.-])/i;
8
+ const ENV_COPY_PATTERN = /(?:^|[/_.-])\.?env(?:$|[/_.-])/i;
9
+ const HISTORY_PATTERN = /(?:bash|zsh|fish|shell)[_.-]?history/i;
10
+ const SESSION_PATTERN = /(?:^|[/_.-])(?:sessions?|conversations?|chats?|rollouts?|transcripts?)(?:$|[/_.-])/i;
11
+ const MCP_PATTERN = /["']?mcpServers?["']?\s*[:=]/i;
12
+ const PLACEHOLDER_PATTERNS = [
13
+ /^(?:example|placeholder|replace[_-]?me|changeme|dummy|redacted)$/i,
14
+ /^your[_-][a-z0-9_-]+$/i,
15
+ /^x{4,}$/i,
16
+ /^<[^>]+>$/,
17
+ /^\$\{[^}]+\}$/
18
+ ];
19
+ const SKIP_DIRECTORIES = new Set([".git", "node_modules", "coverage"]);
20
+ const DEFAULT_MAX_FILES = 20_000;
21
+ const DEFAULT_MAX_DEPTH = 14;
22
+ const DEFAULT_MAX_CONTENT_BYTES = 4 * 1024 * 1024;
23
+ const DEFAULT_MAX_TOTAL_BYTES = 64 * 1024 * 1024;
24
+ const DEFAULT_MAX_DIRECTORIES = 5_000;
25
+ const DEFAULT_MAX_ENTRIES = 100_000;
26
+ const DEFAULT_MAX_FINDINGS = 5_000;
27
+ const DEFAULT_MAX_OCCURRENCES = 5_000;
28
+ const DEFAULT_MAX_OCCURRENCES_PER_FILE = 200;
29
+ const FINGERPRINT_LENGTH = 32;
30
+
31
+ export class ScanUsageError extends Error {}
32
+
33
+ const SECRET_PATTERNS = [
34
+ {
35
+ type: "Discord webhook",
36
+ regex: /https:\/\/(?:discord|discordapp)\.com\/api\/webhooks\/[0-9]+\/[A-Za-z0-9_-]+/g
37
+ },
38
+ { type: "GitHub token", regex: /\bgh[pousr]_[A-Za-z0-9_]{30,}\b/g },
39
+ { type: "Anthropic API key", regex: /\bsk-ant-[A-Za-z0-9_-]{20,}\b/g },
40
+ { type: "OpenAI-style API key", regex: /\bsk-(?!ant-)[A-Za-z0-9_-]{20,}\b/g },
41
+ { type: "Google API key", regex: /\bAIza[A-Za-z0-9_-]{30,}\b/g },
42
+ { type: "AWS access key ID", regex: /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g },
43
+ { type: "Slack token", regex: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g },
44
+ {
45
+ type: "Bearer token",
46
+ regex: /\bBearer\s+([A-Za-z0-9._~+/=-]{16,})/gi,
47
+ capture: 1
48
+ },
49
+ {
50
+ type: "Assigned secret",
51
+ regex: /\b(?:api[_-]?key|access[_-]?token|auth[_-]?token|secret|password|passwd|webhook[_-]?url)\b\s*[:=]\s*["']?([A-Za-z0-9_./+:=@-]{8,})/gi,
52
+ capture: 1
53
+ }
54
+ ];
55
+
56
+ function createFingerprinter() {
57
+ const key = crypto.randomBytes(32);
58
+ return value => crypto
59
+ .createHmac("sha256", key)
60
+ .update(value)
61
+ .digest("hex")
62
+ .slice(0, FINGERPRINT_LENGTH);
63
+ }
64
+
65
+ function positiveIntegerOption(value, fallback, label) {
66
+ const selected = value ?? fallback;
67
+ if (!Number.isSafeInteger(selected) || selected <= 0) {
68
+ throw new ScanUsageError(`${label} must be a positive integer.`);
69
+ }
70
+ return selected;
71
+ }
72
+
73
+ function nonNegativeIntegerOption(value, fallback, label) {
74
+ const selected = value ?? fallback;
75
+ if (!Number.isSafeInteger(selected) || selected < 0) {
76
+ throw new ScanUsageError(`${label} must be a non-negative integer.`);
77
+ }
78
+ return selected;
79
+ }
80
+
81
+ function normalizeRelativePath(value) {
82
+ return value.split(path.sep).join("/");
83
+ }
84
+
85
+ function isPlaceholder(value) {
86
+ return PLACEHOLDER_PATTERNS.some(pattern => pattern.test(value));
87
+ }
88
+
89
+ function createNewlineOffsets(content) {
90
+ const offsets = [];
91
+ for (let index = 0; index < content.length; index += 1) {
92
+ if (content.charCodeAt(index) === 10) offsets.push(index);
93
+ }
94
+ return offsets;
95
+ }
96
+
97
+ function lineNumber(newlineOffsets, matchIndex) {
98
+ let left = 0;
99
+ let right = newlineOffsets.length;
100
+ while (left < right) {
101
+ const middle = Math.floor((left + right) / 2);
102
+ if (newlineOffsets[middle] < matchIndex) left = middle + 1;
103
+ else right = middle;
104
+ }
105
+ return left + 1;
106
+ }
107
+
108
+ function redactKnownSecrets(value) {
109
+ let redacted = value;
110
+ for (const pattern of SECRET_PATTERNS) {
111
+ pattern.regex.lastIndex = 0;
112
+ redacted = redacted.replace(pattern.regex, "<redacted>");
113
+ }
114
+ return redacted;
115
+ }
116
+
117
+ function displayPath(filePath, homeDir, rootPath, redactPaths, pathFingerprint) {
118
+ if (redactPaths) return `<redacted-path:${pathFingerprint(filePath)}>`;
119
+ if (filePath === homeDir) return "~";
120
+ if (filePath.startsWith(`${homeDir}${path.sep}`)) {
121
+ return redactKnownSecrets(`~${path.sep}${path.relative(homeDir, filePath)}`);
122
+ }
123
+ const rootLabel = `<external-root:${pathFingerprint(rootPath ?? filePath).slice(0, 16)}>`;
124
+ if (!rootPath || filePath === rootPath) return rootLabel;
125
+ return redactKnownSecrets(path.join(rootLabel, path.relative(rootPath, filePath)));
126
+ }
127
+
128
+ function detectSecrets(content, filePath, homeDir, agent, fingerprint, display, limit) {
129
+ const occurrences = [];
130
+ const dedupe = new Set();
131
+ const newlineOffsets = createNewlineOffsets(content);
132
+ const publishedPath = display(filePath, homeDir, agent.path);
133
+ let dropped = 0;
134
+
135
+ for (const pattern of SECRET_PATTERNS) {
136
+ pattern.regex.lastIndex = 0;
137
+ for (const match of content.matchAll(pattern.regex)) {
138
+ const value = pattern.capture ? match[pattern.capture] : match[0];
139
+ if (!value || isPlaceholder(value)) continue;
140
+ const secretFingerprint = fingerprint(value);
141
+ const line = lineNumber(newlineOffsets, match.index);
142
+ const key = `${secretFingerprint}:${line}`;
143
+ if (dedupe.has(key)) continue;
144
+ dedupe.add(key);
145
+ if (occurrences.length >= limit) {
146
+ dropped += 1;
147
+ continue;
148
+ }
149
+ occurrences.push({
150
+ agent: agent.id,
151
+ agentLabel: agent.label,
152
+ type: pattern.type,
153
+ fingerprint: secretFingerprint,
154
+ path: publishedPath,
155
+ line
156
+ });
157
+ }
158
+ }
159
+
160
+ return { occurrences, dropped };
161
+ }
162
+
163
+ function createFinding({ severity, category, title, detail, filePath, homeDir, agent, display }) {
164
+ return {
165
+ id: crypto.randomUUID(),
166
+ severity,
167
+ category,
168
+ title,
169
+ detail,
170
+ path: display(filePath, homeDir, agent.path),
171
+ agent: agent.id,
172
+ agentLabel: agent.label
173
+ };
174
+ }
175
+
176
+ function severityScore(severity) {
177
+ return { critical: 25, high: 14, medium: 7, low: 3, info: 0 }[severity] ?? 0;
178
+ }
179
+
180
+ function riskScore(findings) {
181
+ const categoryCaps = { secret: 100, permissions: 30, residue: 20, mcp: 10 };
182
+ const categoryScores = new Map();
183
+ for (const finding of findings) {
184
+ const cap = categoryCaps[finding.category] ?? 0;
185
+ if (cap === 0) continue;
186
+ const current = categoryScores.get(finding.category) ?? 0;
187
+ categoryScores.set(finding.category, Math.min(cap, current + severityScore(finding.severity)));
188
+ }
189
+ return Math.min(100, [...categoryScores.values()].reduce((sum, value) => sum + value, 0));
190
+ }
191
+
192
+ function riskLabel(score) {
193
+ if (score >= 75) return "critical";
194
+ if (score >= 45) return "high";
195
+ if (score >= 20) return "guarded";
196
+ return "low";
197
+ }
198
+
199
+ function isProbablyText(buffer) {
200
+ return !buffer.includes(0);
201
+ }
202
+
203
+ function isWithinRoot(candidatePath, rootPath) {
204
+ const relative = path.relative(rootPath, candidatePath);
205
+ return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative));
206
+ }
207
+
208
+ function hasSymbolicLinkComponent(filePath, anchorPath) {
209
+ const absolutePath = path.resolve(filePath);
210
+ const absoluteAnchor = path.resolve(anchorPath);
211
+ if (!isWithinRoot(absolutePath, absoluteAnchor)) return false;
212
+
213
+ const relativeParts = path.relative(absoluteAnchor, absolutePath).split(path.sep).filter(Boolean);
214
+ let currentPath = absoluteAnchor;
215
+
216
+ for (const part of relativeParts) {
217
+ currentPath = path.join(currentPath, part);
218
+ try {
219
+ if (fs.lstatSync(currentPath).isSymbolicLink()) return true;
220
+ } catch {
221
+ return false;
222
+ }
223
+ }
224
+ return false;
225
+ }
226
+
227
+ function descriptorPath(fileDescriptor) {
228
+ for (const basePath of ["/proc/self/fd", "/dev/fd"]) {
229
+ const alias = path.join(basePath, String(fileDescriptor));
230
+ try {
231
+ const realPath = fs.realpathSync.native(alias);
232
+ // macOS may resolve /dev/fd aliases to themselves instead of the opened file.
233
+ if (isWithinRoot(realPath, basePath)) continue;
234
+ return { alias, realPath };
235
+ } catch {
236
+ // Descriptor aliases are platform-specific. The pre-open check remains as a fallback.
237
+ }
238
+ }
239
+ return null;
240
+ }
241
+
242
+ function readTextSample(filePath, rootRealPath, maxContentBytes, remainingBytes) {
243
+ if (remainingBytes <= 0) return { content: null, bytesRead: 0, reason: "total-bytes" };
244
+ const realPath = fs.realpathSync.native(filePath);
245
+ if (!isWithinRoot(realPath, rootRealPath)) {
246
+ return { content: null, bytesRead: 0, reason: "outside-root" };
247
+ }
248
+
249
+ const flags = fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0);
250
+ const fileDescriptor = fs.openSync(filePath, flags);
251
+ try {
252
+ const openedDescriptor = descriptorPath(fileDescriptor);
253
+ if (openedDescriptor && !isWithinRoot(openedDescriptor.realPath, rootRealPath)) {
254
+ return { content: null, bytesRead: 0, reason: "outside-root" };
255
+ }
256
+ const stat = fs.fstatSync(fileDescriptor);
257
+ if (!stat.isFile()) return { content: null, bytesRead: 0, reason: "not-file" };
258
+ if (stat.size > maxContentBytes) return { content: null, bytesRead: 0, reason: "file-size" };
259
+ if (stat.size > remainingBytes) return { content: null, bytesRead: 0, reason: "total-bytes" };
260
+
261
+ const buffer = Buffer.alloc(stat.size);
262
+ let bytesRead = 0;
263
+ while (bytesRead < buffer.length) {
264
+ const count = fs.readSync(fileDescriptor, buffer, bytesRead, buffer.length - bytesRead, bytesRead);
265
+ if (count === 0) break;
266
+ bytesRead += count;
267
+ }
268
+ const sample = buffer.subarray(0, bytesRead);
269
+ return {
270
+ content: isProbablyText(sample) ? sample.toString("utf8") : null,
271
+ bytesRead,
272
+ reason: isProbablyText(sample) ? null : "binary"
273
+ };
274
+ } finally {
275
+ fs.closeSync(fileDescriptor);
276
+ }
277
+ }
278
+
279
+ function mergeRootDescriptors(homeDir, roots) {
280
+ if (!roots?.length) return knownRoots(homeDir);
281
+ return roots.map((rootPath, index) => {
282
+ const absolutePath = path.resolve(rootPath);
283
+ if (absolutePath === path.parse(absolutePath).root) {
284
+ throw new ScanUsageError("Refusing to scan a filesystem root. Select a narrower agent storage directory.");
285
+ }
286
+ const knownAgent = AGENT_CATALOG.find(agent =>
287
+ agent.paths.some(candidate => absolutePath.endsWith(candidate))
288
+ );
289
+ return {
290
+ id: knownAgent?.id ?? `custom-${index + 1}`,
291
+ label: knownAgent?.label ?? `Custom root ${index + 1}`,
292
+ color: knownAgent?.color ?? "#a9b3bd",
293
+ path: absolutePath
294
+ };
295
+ });
296
+ }
297
+
298
+ export function scan(options = {}) {
299
+ const homeDir = path.resolve(options.homeDir ?? os.homedir());
300
+ const maxFiles = positiveIntegerOption(options.maxFiles, DEFAULT_MAX_FILES, "maxFiles");
301
+ const maxDepth = nonNegativeIntegerOption(options.maxDepth, DEFAULT_MAX_DEPTH, "maxDepth");
302
+ const maxContentBytes = positiveIntegerOption(options.maxContentBytes, DEFAULT_MAX_CONTENT_BYTES, "maxContentBytes");
303
+ const maxTotalBytes = positiveIntegerOption(options.maxTotalBytes, DEFAULT_MAX_TOTAL_BYTES, "maxTotalBytes");
304
+ const maxDirectories = positiveIntegerOption(options.maxDirectories, DEFAULT_MAX_DIRECTORIES, "maxDirectories");
305
+ const maxEntries = positiveIntegerOption(options.maxEntries, DEFAULT_MAX_ENTRIES, "maxEntries");
306
+ const maxFindings = positiveIntegerOption(options.maxFindings, DEFAULT_MAX_FINDINGS, "maxFindings");
307
+ const maxOccurrences = positiveIntegerOption(options.maxOccurrences, DEFAULT_MAX_OCCURRENCES, "maxOccurrences");
308
+ const maxOccurrencesPerFile = positiveIntegerOption(
309
+ options.maxOccurrencesPerFile,
310
+ DEFAULT_MAX_OCCURRENCES_PER_FILE,
311
+ "maxOccurrencesPerFile"
312
+ );
313
+ const redactPaths = options.showPaths !== true;
314
+ const explicitRoots = Boolean(options.roots?.length);
315
+ const roots = mergeRootDescriptors(homeDir, options.roots);
316
+ const fingerprint = createFingerprinter();
317
+ const pathFingerprint = createFingerprinter();
318
+ const display = (filePath, baseHomeDir, rootPath) =>
319
+ displayPath(filePath, baseHomeDir, rootPath, redactPaths, pathFingerprint);
320
+ const findings = [];
321
+ const secretOccurrences = [];
322
+ const agents = [];
323
+ const coverageKeys = new Set();
324
+ let stopTraversal = false;
325
+ const stats = {
326
+ filesVisited: 0,
327
+ directoriesVisited: 0,
328
+ entriesVisited: 0,
329
+ bytesVisited: 0,
330
+ bytesRead: 0,
331
+ textFilesInspected: 0,
332
+ binaryFilesSkipped: 0,
333
+ filesSkippedBySize: 0,
334
+ symlinksSkipped: 0,
335
+ rootsMissing: 0,
336
+ rootsSkippedUnsafe: 0,
337
+ depthCapped: 0,
338
+ findingsDropped: 0,
339
+ secretOccurrencesDropped: 0,
340
+ coverageIncomplete: false,
341
+ noFollowSupported: typeof fs.constants.O_NOFOLLOW === "number",
342
+ permissionsSupported: process.platform !== "win32",
343
+ capped: false
344
+ };
345
+
346
+ function addFinding(finding) {
347
+ if (findings.length >= maxFindings) {
348
+ stats.findingsDropped += 1;
349
+ stats.coverageIncomplete = true;
350
+ stats.capped = true;
351
+ return;
352
+ }
353
+ findings.push(finding);
354
+ }
355
+
356
+ function addCoverageFinding(key, finding) {
357
+ stats.coverageIncomplete = true;
358
+ stats.capped = true;
359
+ if (coverageKeys.has(key)) return;
360
+ coverageKeys.add(key);
361
+ addFinding(finding);
362
+ }
363
+
364
+ function finding(details) {
365
+ return createFinding({ ...details, homeDir, display });
366
+ }
367
+
368
+ function addPermissionFinding(filePath, agent, mode, sensitiveName, hasSecrets = false) {
369
+ if (!stats.permissionsSupported || (mode & 0o077) === 0 || (!sensitiveName && !hasSecrets)) return;
370
+ addFinding(finding({
371
+ severity: "high",
372
+ category: "permissions",
373
+ title: "Sensitive residue grants access outside its owner account",
374
+ detail: `Mode ${mode.toString(8).padStart(3, "0")} grants group or other permission bits on an agent-related file.`,
375
+ filePath,
376
+ agent
377
+ }));
378
+ }
379
+
380
+ function inspectFile(filePath, initialStat, agent, rootRealPath) {
381
+ if (stats.filesVisited >= maxFiles) {
382
+ stopTraversal = true;
383
+ addCoverageFinding("max-files", finding({
384
+ severity: "medium",
385
+ category: "coverage",
386
+ title: "File traversal limit reached",
387
+ detail: `AgentHusk stopped after ${maxFiles} files. Raise --max-files or scan a narrower root.`,
388
+ filePath,
389
+ agent
390
+ }));
391
+ return;
392
+ }
393
+ stats.filesVisited += 1;
394
+ stats.bytesVisited += initialStat.size;
395
+
396
+ const relativePath = normalizeRelativePath(path.relative(agent.path, filePath));
397
+ const mode = initialStat.mode & 0o777;
398
+ const sensitiveName = SENSITIVE_FILE_PATTERN.test(relativePath);
399
+ addPermissionFinding(filePath, agent, mode, sensitiveName);
400
+
401
+ if (ENV_COPY_PATTERN.test(relativePath)) {
402
+ addFinding(finding({
403
+ severity: "high",
404
+ category: "residue",
405
+ title: "Environment file residue found inside agent storage",
406
+ detail: "Review whether this environment snapshot is still needed. It may contain credentials copied from a workspace.",
407
+ filePath,
408
+ agent
409
+ }));
410
+ } else if (HISTORY_PATTERN.test(relativePath)) {
411
+ addFinding(finding({
412
+ severity: "high",
413
+ category: "residue",
414
+ title: "Shell history residue found inside agent storage",
415
+ detail: "Shell history often contains commands, URLs, tokens, and operational details.",
416
+ filePath,
417
+ agent
418
+ }));
419
+ } else if (SESSION_PATTERN.test(relativePath) && /\.(?:json|jsonl|log|txt)$/i.test(filePath)) {
420
+ addFinding(finding({
421
+ severity: "low",
422
+ category: "residue",
423
+ title: "Agent session transcript stored locally",
424
+ detail: "Session transcripts are expected but should be retained intentionally and protected with owner-only permissions.",
425
+ filePath,
426
+ agent
427
+ }));
428
+ }
429
+
430
+ let sample;
431
+ try {
432
+ sample = readTextSample(filePath, rootRealPath, maxContentBytes, maxTotalBytes - stats.bytesRead);
433
+ } catch {
434
+ addCoverageFinding(`read:${filePath}`, finding({
435
+ severity: "info",
436
+ category: "scan",
437
+ title: "File could not be inspected",
438
+ detail: "AgentHusk continued without reading this file. The coverage signal is incomplete.",
439
+ filePath,
440
+ agent
441
+ }));
442
+ return;
443
+ }
444
+ stats.bytesRead += sample.bytesRead;
445
+
446
+ if (sample.reason === "file-size") {
447
+ stats.filesSkippedBySize += 1;
448
+ addCoverageFinding(`large:${filePath}`, finding({
449
+ severity: sensitiveName ? "medium" : "info",
450
+ category: "coverage",
451
+ title: "Large file was not content-scanned",
452
+ detail: `File size exceeds the ${(maxContentBytes / 1024 / 1024).toFixed(1)} MiB inspection limit. Increase --max-bytes if this residue needs a deeper scan.`,
453
+ filePath,
454
+ agent
455
+ }));
456
+ return;
457
+ }
458
+ if (sample.reason === "total-bytes") {
459
+ stopTraversal = true;
460
+ addCoverageFinding("max-total-bytes", finding({
461
+ severity: "medium",
462
+ category: "coverage",
463
+ title: "Total content-reading limit reached",
464
+ detail: "AgentHusk stopped content traversal at its bounded total-read limit. Scan a narrower root for deeper coverage.",
465
+ filePath,
466
+ agent
467
+ }));
468
+ return;
469
+ }
470
+ if (sample.reason === "outside-root") {
471
+ addCoverageFinding(`outside-root:${filePath}`, finding({
472
+ severity: "medium",
473
+ category: "coverage",
474
+ title: "Path escaped the selected root and was skipped",
475
+ detail: "AgentHusk refused to follow a path whose resolved target was outside the selected root.",
476
+ filePath,
477
+ agent
478
+ }));
479
+ return;
480
+ }
481
+ if (sample.reason === "binary") {
482
+ stats.binaryFilesSkipped += 1;
483
+ return;
484
+ }
485
+ if (sample.content === null) return;
486
+ stats.textFilesInspected += 1;
487
+
488
+ const availableOccurrences = Math.max(0, maxOccurrences - secretOccurrences.length);
489
+ const limit = Math.min(maxOccurrencesPerFile, availableOccurrences);
490
+ const detected = detectSecrets(sample.content, filePath, homeDir, agent, fingerprint, display, limit);
491
+ secretOccurrences.push(...detected.occurrences);
492
+ stats.secretOccurrencesDropped += detected.dropped;
493
+ if (detected.dropped > 0) {
494
+ addCoverageFinding(`occurrences:${filePath}`, finding({
495
+ severity: "medium",
496
+ category: "coverage",
497
+ title: "Secret occurrence reporting limit reached",
498
+ detail: "Additional secret-shaped matches were omitted to keep the local report bounded. Scan a narrower root for complete triage.",
499
+ filePath,
500
+ agent
501
+ }));
502
+ }
503
+
504
+ if (!sensitiveName) {
505
+ addPermissionFinding(filePath, agent, mode, sensitiveName, detected.occurrences.length > 0);
506
+ }
507
+ for (const occurrence of detected.occurrences) {
508
+ addFinding(finding({
509
+ severity: "critical",
510
+ category: "secret",
511
+ title: `${occurrence.type} fingerprint found in agent storage`,
512
+ detail: `Matched value hidden. Fingerprint ${occurrence.fingerprint} at line ${occurrence.line}. Rotate the credential if this residue was not expected.`,
513
+ filePath,
514
+ agent
515
+ }));
516
+ }
517
+
518
+ if (MCP_PATTERN.test(sample.content)) {
519
+ addFinding(finding({
520
+ severity: "medium",
521
+ category: "mcp",
522
+ title: "MCP server configuration discovered",
523
+ detail: "Review command paths, environment variable references, and trust boundaries before enabling local MCP servers.",
524
+ filePath,
525
+ agent
526
+ }));
527
+ }
528
+ }
529
+
530
+ function inspectDirectory(directoryPath, stat, agent) {
531
+ if (!stats.permissionsSupported) return;
532
+ const relativePath = normalizeRelativePath(path.relative(agent.path, directoryPath));
533
+ const mode = stat.mode & 0o777;
534
+ if ((directoryPath === agent.path || SENSITIVE_FILE_PATTERN.test(relativePath)) && (mode & 0o077) !== 0) {
535
+ addFinding(finding({
536
+ severity: "medium",
537
+ category: "permissions",
538
+ title: "Agent storage directory grants access outside its owner account",
539
+ detail: `Mode ${mode.toString(8).padStart(3, "0")} grants group or other permission bits on a residue directory.`,
540
+ filePath: directoryPath,
541
+ agent
542
+ }));
543
+ }
544
+ }
545
+
546
+ function walk(directoryPath, depth, agent, rootRealPath) {
547
+ if (stopTraversal) return;
548
+ if (depth > maxDepth) {
549
+ stats.depthCapped += 1;
550
+ addCoverageFinding(`depth:${directoryPath}`, finding({
551
+ severity: "medium",
552
+ category: "coverage",
553
+ title: "Directory depth limit reached",
554
+ detail: `AgentHusk did not descend beyond depth ${maxDepth}. Scan this subtree directly for deeper coverage.`,
555
+ filePath: directoryPath,
556
+ agent
557
+ }));
558
+ return;
559
+ }
560
+ if (stats.directoriesVisited >= maxDirectories) {
561
+ stopTraversal = true;
562
+ addCoverageFinding("max-directories", finding({
563
+ severity: "medium",
564
+ category: "coverage",
565
+ title: "Directory traversal limit reached",
566
+ detail: "AgentHusk stopped at its bounded directory limit. Scan a narrower root for deeper coverage.",
567
+ filePath: directoryPath,
568
+ agent
569
+ }));
570
+ return;
571
+ }
572
+
573
+ let directoryDescriptor;
574
+ let directory;
575
+ let directoryFileDescriptor;
576
+ function closeDirectoryHandles() {
577
+ try {
578
+ directory?.closeSync();
579
+ } catch {
580
+ // The directory handle may already be closed after an iteration failure.
581
+ }
582
+ directory = undefined;
583
+ if (directoryFileDescriptor !== undefined) {
584
+ try {
585
+ fs.closeSync(directoryFileDescriptor);
586
+ } catch {
587
+ // The descriptor may already be closed after an open failure.
588
+ }
589
+ }
590
+ directoryFileDescriptor = undefined;
591
+ }
592
+ try {
593
+ const resolvedDirectory = fs.realpathSync.native(directoryPath);
594
+ if (!isWithinRoot(resolvedDirectory, rootRealPath)) {
595
+ addCoverageFinding(`outside-root:${directoryPath}`, finding({
596
+ severity: "medium",
597
+ category: "coverage",
598
+ title: "Directory escaped the selected root and was skipped",
599
+ detail: "AgentHusk refused to traverse a directory whose resolved target was outside the selected root.",
600
+ filePath: directoryPath,
601
+ agent
602
+ }));
603
+ return;
604
+ }
605
+ if (typeof fs.constants.O_DIRECTORY === "number" && typeof fs.constants.O_NOFOLLOW === "number") {
606
+ const flags = fs.constants.O_RDONLY | fs.constants.O_DIRECTORY | fs.constants.O_NOFOLLOW;
607
+ directoryFileDescriptor = fs.openSync(directoryPath, flags);
608
+ directoryDescriptor = descriptorPath(directoryFileDescriptor);
609
+ if (directoryDescriptor && !isWithinRoot(directoryDescriptor.realPath, rootRealPath)) {
610
+ addCoverageFinding(`outside-root:${directoryPath}`, finding({
611
+ severity: "medium",
612
+ category: "coverage",
613
+ title: "Directory escaped the selected root and was skipped",
614
+ detail: "AgentHusk refused to traverse a directory whose opened descriptor resolved outside the selected root.",
615
+ filePath: directoryPath,
616
+ agent
617
+ }));
618
+ closeDirectoryHandles();
619
+ return;
620
+ }
621
+ }
622
+ directory = fs.opendirSync(directoryDescriptor?.alias ?? directoryPath);
623
+ stats.directoriesVisited += 1;
624
+ } catch {
625
+ closeDirectoryHandles();
626
+ addCoverageFinding(`directory:${directoryPath}`, finding({
627
+ severity: "info",
628
+ category: "scan",
629
+ title: "Directory could not be inspected",
630
+ detail: "AgentHusk continued without reading this directory. The coverage signal is incomplete.",
631
+ filePath: directoryPath,
632
+ agent
633
+ }));
634
+ return;
635
+ }
636
+
637
+ try {
638
+ let entry;
639
+ while (!stopTraversal && (entry = directory.readSync()) !== null) {
640
+ stats.entriesVisited += 1;
641
+ if (stats.entriesVisited > maxEntries) {
642
+ stopTraversal = true;
643
+ addCoverageFinding("max-entries", finding({
644
+ severity: "medium",
645
+ category: "coverage",
646
+ title: "Directory entry limit reached",
647
+ detail: "AgentHusk stopped at its bounded directory-entry limit. Scan a narrower root for deeper coverage.",
648
+ filePath: directoryPath,
649
+ agent
650
+ }));
651
+ return;
652
+ }
653
+ const entryPath = path.join(directoryPath, entry.name);
654
+ let stat;
655
+ try {
656
+ stat = fs.lstatSync(entryPath);
657
+ } catch {
658
+ addCoverageFinding(`stat:${entryPath}`, finding({
659
+ severity: "info",
660
+ category: "scan",
661
+ title: "Directory entry metadata could not be inspected",
662
+ detail: "AgentHusk continued without inspecting this entry. The coverage signal is incomplete.",
663
+ filePath: entryPath,
664
+ agent
665
+ }));
666
+ continue;
667
+ }
668
+ if (stat.isSymbolicLink()) {
669
+ stats.symlinksSkipped += 1;
670
+ } else if (stat.isDirectory()) {
671
+ if (SKIP_DIRECTORIES.has(entry.name)) continue;
672
+ inspectDirectory(entryPath, stat, agent);
673
+ walk(entryPath, depth + 1, agent, rootRealPath);
674
+ } else if (stat.isFile()) {
675
+ inspectFile(entryPath, stat, agent, rootRealPath);
676
+ }
677
+ }
678
+ } finally {
679
+ closeDirectoryHandles();
680
+ }
681
+ }
682
+
683
+ for (const agent of roots) {
684
+ if (stopTraversal) break;
685
+ let stat;
686
+ let rootRealPath;
687
+ try {
688
+ stat = fs.lstatSync(agent.path);
689
+ if (!stat.isDirectory()) {
690
+ stats.rootsMissing += 1;
691
+ if (explicitRoots) {
692
+ stats.coverageIncomplete = true;
693
+ stats.capped = true;
694
+ }
695
+ continue;
696
+ }
697
+ if (stat.isSymbolicLink() || hasSymbolicLinkComponent(agent.path, homeDir)) {
698
+ stats.rootsSkippedUnsafe += 1;
699
+ stats.coverageIncomplete = true;
700
+ stats.capped = true;
701
+ continue;
702
+ }
703
+ rootRealPath = fs.realpathSync.native(agent.path);
704
+ } catch (error) {
705
+ stats.rootsMissing += 1;
706
+ if (explicitRoots || error?.code !== "ENOENT") {
707
+ stats.coverageIncomplete = true;
708
+ stats.capped = true;
709
+ }
710
+ continue;
711
+ }
712
+ agents.push({
713
+ id: agent.id,
714
+ label: agent.label,
715
+ color: agent.color,
716
+ path: display(agent.path, homeDir, agent.path)
717
+ });
718
+ inspectDirectory(agent.path, stat, agent);
719
+ walk(agent.path, 0, agent, rootRealPath);
720
+ }
721
+
722
+ if (explicitRoots && agents.length === 0) {
723
+ throw new ScanUsageError("No explicit scan root could be inspected. Check --root paths and permissions.");
724
+ }
725
+
726
+ const duplicateSecrets = [];
727
+ const fingerprintGroups = new Map();
728
+ for (const occurrence of secretOccurrences) {
729
+ const group = fingerprintGroups.get(occurrence.fingerprint) ?? [];
730
+ group.push(occurrence);
731
+ fingerprintGroups.set(occurrence.fingerprint, group);
732
+ }
733
+ for (const [secretFingerprint, occurrences] of fingerprintGroups) {
734
+ const uniqueFiles = [...new Set(occurrences.map(occurrence => occurrence.path))];
735
+ if (uniqueFiles.length < 2) continue;
736
+ duplicateSecrets.push({
737
+ fingerprint: secretFingerprint,
738
+ type: occurrences[0].type,
739
+ files: uniqueFiles,
740
+ agents: [...new Set(occurrences.map(occurrence => occurrence.agentLabel))]
741
+ });
742
+ }
743
+
744
+ findings.sort((left, right) => severityScore(right.severity) - severityScore(left.severity));
745
+ const severityCounts = Object.fromEntries(
746
+ ["critical", "high", "medium", "low", "info"].map(severity => [
747
+ severity,
748
+ findings.filter(finding => finding.severity === severity).length
749
+ ])
750
+ );
751
+ const score = riskScore(findings);
752
+
753
+ return {
754
+ schemaVersion: 1,
755
+ generatedAt: new Date().toISOString(),
756
+ home: "~",
757
+ pathsRedacted: redactPaths,
758
+ guarantee: "Matched content values are excluded from content-derived report fields. Paths are anonymized by default; review metadata before sharing.",
759
+ score,
760
+ risk: riskLabel(score),
761
+ stats,
762
+ severityCounts,
763
+ agents,
764
+ findings,
765
+ secretOccurrences,
766
+ duplicateSecrets
767
+ };
768
+ }