monomind 2.7.7 → 2.7.9
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/package.json +3 -2
- package/packages/@monomind/cli/.claude/agents/core/coder.md +9 -0
- package/packages/@monomind/cli/.claude/agents/core/coordinator.md +62 -0
- package/packages/@monomind/cli/.claude/agents/core/planner.md +9 -0
- package/packages/@monomind/cli/.claude/agents/core/reviewer.md +9 -0
- package/packages/@monomind/cli/.claude/agents/core/tester.md +8 -0
- package/packages/@monomind/cli/.claude/helpers/handlers/gates-handler.cjs +180 -47
- package/packages/@monomind/cli/.claude/helpers/handlers/route-handler.cjs +32 -3
- package/packages/@monomind/cli/.claude/helpers/hook-handler.cjs +55 -3
- package/packages/@monomind/cli/.claude/helpers/intelligence.cjs +3 -1
- package/packages/@monomind/cli/.claude/helpers/statusline.cjs +27 -3
- package/packages/@monomind/cli/.claude/helpers/utils/monograph.cjs +104 -18
- package/packages/@monomind/cli/.claude/settings.json +1 -1
- package/packages/@monomind/cli/dist/src/browser/dashboard/server.js +6 -1
- package/packages/@monomind/cli/dist/src/capabilities/index.d.ts +0 -1
- package/packages/@monomind/cli/dist/src/capabilities/index.js +8 -1
- package/packages/@monomind/cli/dist/src/commands/agent-lifecycle.js +5 -1
- package/packages/@monomind/cli/dist/src/commands/doctor-project-checks.js +8 -1
- package/packages/@monomind/cli/dist/src/commands/guidance.js +8 -2
- package/packages/@monomind/cli/dist/src/commands/init.js +18 -4
- package/packages/@monomind/cli/dist/src/commands/memory-crud.js +7 -1
- package/packages/@monomind/cli/dist/src/commands/org-observe.js +17 -7
- package/packages/@monomind/cli/dist/src/commands/org.d.ts +11 -0
- package/packages/@monomind/cli/dist/src/commands/org.js +162 -15
- package/packages/@monomind/cli/dist/src/commands/security-scan.d.ts +30 -1
- package/packages/@monomind/cli/dist/src/commands/security-scan.js +182 -69
- package/packages/@monomind/cli/dist/src/commands/swarm.js +41 -14
- package/packages/@monomind/cli/dist/src/consensus/audit-writer.js +11 -10
- package/packages/@monomind/cli/dist/src/init/executor.js +138 -22
- package/packages/@monomind/cli/dist/src/init/settings-generator.js +4 -1
- package/packages/@monomind/cli/dist/src/knowledge/document-pipeline.d.ts +17 -0
- package/packages/@monomind/cli/dist/src/knowledge/document-pipeline.js +62 -3
- package/packages/@monomind/cli/dist/src/mcp-tools/embeddings-tools.js +16 -8
- package/packages/@monomind/cli/dist/src/mcp-tools/knowledge-tools.js +27 -13
- package/packages/@monomind/cli/dist/src/memory/memory-bridge.d.ts +1 -1
- package/packages/@monomind/cli/dist/src/memory/memory-bridge.js +5 -1
- package/packages/@monomind/cli/dist/src/memory/memory-read.d.ts +7 -2
- package/packages/@monomind/cli/dist/src/memory/memory-read.js +10 -2
- package/packages/@monomind/cli/dist/src/monovector/diff-classifier.js +25 -6
- package/packages/@monomind/cli/dist/src/orgrt/daemon.d.ts +21 -1
- package/packages/@monomind/cli/dist/src/orgrt/daemon.js +82 -11
- package/packages/@monomind/cli/dist/src/orgrt/inbox.js +53 -20
- package/packages/@monomind/cli/dist/src/parser.d.ts +4 -2
- package/packages/@monomind/cli/dist/src/parser.js +61 -25
- package/packages/@monomind/cli/dist/src/services/config-file-manager.d.ts +10 -2
- package/packages/@monomind/cli/dist/src/services/config-file-manager.js +10 -2
- package/packages/@monomind/cli/dist/src/ui/collector.mjs +43 -3
- package/packages/@monomind/cli/dist/src/ui/dashboard.html +27 -8
- package/packages/@monomind/cli/dist/src/ui/server.mjs +144 -14
- package/packages/@monomind/cli/package.json +4 -4
- package/packages/@monomind/cli/dist/src/capabilities/watcher.d.ts +0 -18
- package/packages/@monomind/cli/dist/src/capabilities/watcher.js +0 -107
- package/packages/@monomind/cli/dist/src/config-adapter.d.ts +0 -16
- package/packages/@monomind/cli/dist/src/config-adapter.js +0 -220
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// packages/@monomind/cli/src/commands/org.ts
|
|
2
|
-
import { readFileSync, existsSync, unlinkSync, rmSync, readdirSync } from 'node:fs';
|
|
2
|
+
import { readFileSync, writeFileSync, existsSync, unlinkSync, rmSync, readdirSync, mkdirSync } from 'node:fs';
|
|
3
3
|
import { join, resolve } from 'node:path';
|
|
4
4
|
import { output } from '../output.js';
|
|
5
5
|
import { OrgDaemon } from '../orgrt/daemon.js';
|
|
@@ -162,9 +162,35 @@ const stopAction = async (ctx) => {
|
|
|
162
162
|
log(output.error(`Org not found: ${name}`));
|
|
163
163
|
return { success: false, message: 'org not found' };
|
|
164
164
|
}
|
|
165
|
-
|
|
165
|
+
// The stopfile is only meaningful to a process that polls it (`org run` and, since
|
|
166
|
+
// this fix, `org serve`). Writing it for an org that nothing is running was a silent
|
|
167
|
+
// no-op that still reported "daemon exits within 2s" — say what's actually true.
|
|
168
|
+
let rt;
|
|
169
|
+
try {
|
|
170
|
+
rt = JSON.parse(readFileSync(join(ctx.cwd, ORG_DIR, name, 'runtime.json'), 'utf8'));
|
|
171
|
+
}
|
|
172
|
+
catch { /* never run */ }
|
|
173
|
+
if (rt?.status !== 'running') {
|
|
174
|
+
log(output.warning(`Org "${name}" is not running (runtime state: ${rt?.status ?? 'never run'}) — nothing to stop.`));
|
|
175
|
+
return { success: false, message: 'org not running' };
|
|
176
|
+
}
|
|
177
|
+
if (rt.pid) {
|
|
178
|
+
let alive = true;
|
|
179
|
+
try {
|
|
180
|
+
process.kill(rt.pid, 0);
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
alive = false;
|
|
184
|
+
}
|
|
185
|
+
if (!alive) {
|
|
186
|
+
log(output.warning(`Org "${name}" is not running — runtime.json says running but pid ${rt.pid} is gone (crashed daemon).`));
|
|
187
|
+
log(output.info(`Clear the stale record with: monomind org mark-complete ${name}`));
|
|
188
|
+
return { success: false, message: 'org crashed — use mark-complete' };
|
|
189
|
+
}
|
|
190
|
+
}
|
|
166
191
|
mkdirSync(join(ctx.cwd, ORG_DIR, name), { recursive: true });
|
|
167
192
|
writeFileSync(join(ctx.cwd, ORG_DIR, name, 'stop'), new Date().toISOString());
|
|
193
|
+
log(output.info(`Stop requested for "${name}" (pid ${rt.pid}) — the daemon picks it up within ~2s.`));
|
|
168
194
|
return { success: true, message: `stop requested for ${name} (daemon exits within 2s)` };
|
|
169
195
|
};
|
|
170
196
|
const statusAction = async (ctx) => {
|
|
@@ -198,12 +224,26 @@ const statusAction = async (ctx) => {
|
|
|
198
224
|
}
|
|
199
225
|
// A "running" record whose pid is gone means the daemon died without its
|
|
200
226
|
// stopOrg cleanup — surface that instead of reporting it as still running.
|
|
201
|
-
if (state.status === 'running' && state.pid) {
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
227
|
+
if ((state.status === 'running' || state.status === 'crashed') && state.pid) {
|
|
228
|
+
const pidGone = state.status === 'crashed' || (() => {
|
|
229
|
+
try {
|
|
230
|
+
process.kill(state.pid, 0);
|
|
231
|
+
return false;
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
return true;
|
|
235
|
+
}
|
|
236
|
+
})();
|
|
237
|
+
if (pidGone) {
|
|
238
|
+
let heartbeatHint = '';
|
|
239
|
+
try {
|
|
240
|
+
const hb = JSON.parse(readFileSync(join(ctx.cwd, '.monomind', 'serve-heartbeat.json'), 'utf8'));
|
|
241
|
+
heartbeatHint = ` (last heartbeat: ${hb.updatedAt})`;
|
|
242
|
+
}
|
|
243
|
+
catch { /* no heartbeat file — daemon predates this change or was already cleaned up */ }
|
|
244
|
+
const closedBy = state.closedBy;
|
|
245
|
+
const label = closedBy === 'crash-handler' ? 'crashed (caught by crash handler)' : `crashed (runtime.json says ${state.status} but pid ${state.pid} is gone)`;
|
|
246
|
+
log(output.warning(`${t}: ${label}${heartbeatHint}${state.run ? ` — run ${state.run}` : ''} — close it out with "monomind org mark-complete ${t}"`));
|
|
207
247
|
continue;
|
|
208
248
|
}
|
|
209
249
|
}
|
|
@@ -211,6 +251,34 @@ const statusAction = async (ctx) => {
|
|
|
211
251
|
}
|
|
212
252
|
return { success: true };
|
|
213
253
|
};
|
|
254
|
+
/** One pass of the `org serve` stopfile poll.
|
|
255
|
+
*
|
|
256
|
+
* `monomind org stop <name>` writes `.monomind/orgs/<name>/stop`. `org run` has always
|
|
257
|
+
* polled that file; `org serve` did not — so against a serve daemon `org stop` was a
|
|
258
|
+
* silent no-op that still printed "daemon exits within 2s" and exited 0 while the org
|
|
259
|
+
* kept running. Stops every running org whose stopfile is present, then clears the
|
|
260
|
+
* stopfile so the next scheduled iteration isn't killed on sight.
|
|
261
|
+
*
|
|
262
|
+
* Returns the names it stopped (awaited), so callers/tests don't have to guess. */
|
|
263
|
+
export const pollStopfiles = async (cwd, daemon) => {
|
|
264
|
+
const stopped = [];
|
|
265
|
+
for (const name of daemon.listRunning()) {
|
|
266
|
+
if (!existsSync(join(cwd, ORG_DIR, name, 'stop')))
|
|
267
|
+
continue;
|
|
268
|
+
log(output.info(`org ${name}: stop requested — shutting it down`));
|
|
269
|
+
try {
|
|
270
|
+
await daemon.stopOrg(name);
|
|
271
|
+
stopped.push(name);
|
|
272
|
+
}
|
|
273
|
+
catch (err) {
|
|
274
|
+
console.error(`org ${name}: stop failed:`, err);
|
|
275
|
+
}
|
|
276
|
+
finally {
|
|
277
|
+
clearStopfile(cwd, name);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
return stopped;
|
|
281
|
+
};
|
|
214
282
|
const serveAction = async (ctx) => {
|
|
215
283
|
const crossProcess = ctx.flags['crossProcess'] !== false;
|
|
216
284
|
const daemon = new OrgDaemon(ctx.cwd, { crossProcess });
|
|
@@ -219,6 +287,24 @@ const serveAction = async (ctx) => {
|
|
|
219
287
|
srv = await startOrgServer(daemon, 0);
|
|
220
288
|
daemon.setInboxUrl(`http://127.0.0.1:${srv.port}`);
|
|
221
289
|
}
|
|
290
|
+
// Crash handlers: log the reason and persist crashed state so `org status`
|
|
291
|
+
// shows what happened instead of a silent "pid is gone".
|
|
292
|
+
const crashExit = (label, err) => {
|
|
293
|
+
try {
|
|
294
|
+
console.error(`[org serve] ${label}:`, err);
|
|
295
|
+
}
|
|
296
|
+
catch { /* stderr gone */ }
|
|
297
|
+
daemon.persistCrashStateAll();
|
|
298
|
+
daemon.clearHeartbeat();
|
|
299
|
+
process.exitCode = 1;
|
|
300
|
+
};
|
|
301
|
+
process.on('uncaughtException', (err) => { crashExit('uncaughtException', err); process.exit(1); });
|
|
302
|
+
process.on('unhandledRejection', (err) => { crashExit('unhandledRejection', err); process.exit(1); });
|
|
303
|
+
// Heartbeat: write every 30s so `org status` can tell "alive but busy" from
|
|
304
|
+
// "daemon gone" without relying on pid liveness alone.
|
|
305
|
+
daemon.writeHeartbeat();
|
|
306
|
+
const heartbeatInterval = setInterval(() => { daemon.writeHeartbeat(); }, 30_000);
|
|
307
|
+
heartbeatInterval.unref?.();
|
|
222
308
|
log(output.info('org daemon serving — Ctrl-C to stop'));
|
|
223
309
|
// schedule orgs whose definition declares an interval (e.g. "15m", "2h")
|
|
224
310
|
const { OrgScheduler, parseSchedule } = await import('../orgrt/scheduler.js');
|
|
@@ -270,9 +356,14 @@ const serveAction = async (ctx) => {
|
|
|
270
356
|
}
|
|
271
357
|
}
|
|
272
358
|
}
|
|
359
|
+
const stopPoll = setInterval(() => { void pollStopfiles(ctx.cwd, daemon); }, 2000);
|
|
360
|
+
stopPoll.unref?.();
|
|
273
361
|
await new Promise(r => { process.once('SIGINT', () => r()); process.once('SIGTERM', () => r()); });
|
|
362
|
+
clearInterval(stopPoll);
|
|
363
|
+
clearInterval(heartbeatInterval);
|
|
274
364
|
sched.stop();
|
|
275
365
|
await daemon.stopAll();
|
|
366
|
+
daemon.clearHeartbeat();
|
|
276
367
|
srv?.close();
|
|
277
368
|
return { success: true };
|
|
278
369
|
};
|
|
@@ -393,6 +484,36 @@ const deleteAction = async (ctx) => {
|
|
|
393
484
|
log(output.success(`Org "${orgName}" deleted (${removed} file(s) removed).`));
|
|
394
485
|
return { success: true };
|
|
395
486
|
};
|
|
487
|
+
/** Clear a stale `running` record from runtime.json. This is the state `org status`
|
|
488
|
+
* reads, so mark-complete MUST touch it — the dashboard's run:complete event alone
|
|
489
|
+
* left `org status` reporting the same "crashed" line it had just told the user to
|
|
490
|
+
* fix with this exact command. Refuses when the recorded pid is still alive: a live
|
|
491
|
+
* daemon would just rewrite the file, and `org stop` is the right command there. */
|
|
492
|
+
const clearStaleRuntime = (cwd, name) => {
|
|
493
|
+
const rtPath = join(cwd, ORG_DIR, name, 'runtime.json');
|
|
494
|
+
if (!existsSync(rtPath))
|
|
495
|
+
return { cleared: false, reason: 'absent' };
|
|
496
|
+
let rt;
|
|
497
|
+
try {
|
|
498
|
+
rt = JSON.parse(readFileSync(rtPath, 'utf8'));
|
|
499
|
+
}
|
|
500
|
+
catch (err) {
|
|
501
|
+
return { cleared: false, reason: 'unreadable', detail: err instanceof Error ? err.message : String(err) };
|
|
502
|
+
}
|
|
503
|
+
if (rt.status !== 'running' && rt.status !== 'crashed')
|
|
504
|
+
return { cleared: false, reason: 'not-running' };
|
|
505
|
+
if (rt.status === 'running' && rt.pid) {
|
|
506
|
+
try {
|
|
507
|
+
process.kill(rt.pid, 0);
|
|
508
|
+
return { cleared: false, reason: 'alive', detail: String(rt.pid) };
|
|
509
|
+
}
|
|
510
|
+
catch { /* pid is gone — this is exactly the stale case mark-complete exists for */ }
|
|
511
|
+
}
|
|
512
|
+
// Same shape stopOrg's persistState() writes, so every reader (org status,
|
|
513
|
+
// isOrgRunning, the mastermind-org* skills' jq checks) sees a stopped org.
|
|
514
|
+
writeFileSync(rtPath, JSON.stringify({ status: 'stopped', run: rt.run, pid: rt.pid, updated: new Date().toISOString(), closedBy: 'mark-complete' }, null, 2));
|
|
515
|
+
return { cleared: true, run: rt.run };
|
|
516
|
+
};
|
|
396
517
|
const markCompleteAction = async (ctx) => {
|
|
397
518
|
const orgName = ctx.args[0];
|
|
398
519
|
if (!orgName || !ORG_NAME_RE.test(orgName)) {
|
|
@@ -400,6 +521,30 @@ const markCompleteAction = async (ctx) => {
|
|
|
400
521
|
return { success: false, message: 'valid org name required' };
|
|
401
522
|
}
|
|
402
523
|
const cwd = resolve(ctx.cwd || process.cwd());
|
|
524
|
+
// Reject an org that does not exist, using the same check as runAction. Without
|
|
525
|
+
// it `org mark-complete nosuchorg` printed "local state was cleared" and exited
|
|
526
|
+
// 0 — a typo looked like a successful cleanup.
|
|
527
|
+
const orgsDir = join(cwd, ORG_DIR);
|
|
528
|
+
if (!existsSync(join(orgsDir, `${orgName}.json`))) {
|
|
529
|
+
const known = existsSync(orgsDir) ? listOrgConfigFiles(orgsDir).map(f => f.replace(/\.json$/, '')) : [];
|
|
530
|
+
log(output.error(`Org not found: ${orgName}${known.length ? ` — available: ${known.join(', ')}` : ''}`));
|
|
531
|
+
return { success: false, message: 'org not found' };
|
|
532
|
+
}
|
|
533
|
+
// 1) Local runtime.json — the state `org status` actually reads. Done first and
|
|
534
|
+
// independently of the dashboard so the recommended remedy works with no server.
|
|
535
|
+
const local = clearStaleRuntime(cwd, orgName);
|
|
536
|
+
if (!local.cleared && local.reason === 'alive') {
|
|
537
|
+
log(output.error(`Org "${orgName}" is still running (pid ${local.detail}) — stop it with "monomind org stop ${orgName}" instead.`));
|
|
538
|
+
return { success: false, message: 'org is running' };
|
|
539
|
+
}
|
|
540
|
+
if (local.cleared)
|
|
541
|
+
log(output.success(`Cleared stale runtime state for "${orgName}"${local.run ? ` (run ${local.run})` : ''}.`));
|
|
542
|
+
else if (local.reason === 'unreadable')
|
|
543
|
+
log(output.warning(`runtime.json for "${orgName}" is unreadable (${local.detail}) — left untouched.`));
|
|
544
|
+
else
|
|
545
|
+
log(output.info(`No stale runtime state for "${orgName}" (runtime.json ${local.reason === 'absent' ? 'absent' : 'already not running'}).`));
|
|
546
|
+
// 2) Dashboard run:complete event — best effort. A missing/unauthorized dashboard
|
|
547
|
+
// must not make the command fail after the local state was already cleared.
|
|
403
548
|
let ctrlUrl = 'http://localhost:4242';
|
|
404
549
|
try {
|
|
405
550
|
const ctl = JSON.parse(readFileSync(join(cwd, '.monomind', 'control.json'), 'utf8'));
|
|
@@ -420,17 +565,19 @@ const markCompleteAction = async (ctx) => {
|
|
|
420
565
|
});
|
|
421
566
|
const body = await res.json().catch(() => ({}));
|
|
422
567
|
if (!res.ok) {
|
|
423
|
-
log(output.
|
|
424
|
-
|
|
568
|
+
log(output.warning(`Dashboard not updated (${res.status}: ${body.error || 'unknown error'}) — ${local.cleared ? 'local state was cleared' : 'there was no local state to clear'}.`));
|
|
569
|
+
}
|
|
570
|
+
else {
|
|
571
|
+
const runId = body.runId;
|
|
572
|
+
log(output.success(`Dashboard run marked complete for "${orgName}"${runId ? ` (run ${runId})` : ''}.`));
|
|
425
573
|
}
|
|
426
|
-
const runId = body.runId;
|
|
427
|
-
log(output.success(`Run marked complete for org "${orgName}"${runId ? ` (run ${runId})` : ''}.`));
|
|
428
|
-
return { success: true };
|
|
429
574
|
}
|
|
430
575
|
catch (err) {
|
|
431
|
-
log(output.
|
|
432
|
-
return { success: false, message: 'server unreachable' };
|
|
576
|
+
log(output.warning(`Dashboard unreachable at ${ctrlUrl} (${err instanceof Error ? err.message : 'error'}) — ${local.cleared ? 'local state was cleared' : 'there was no local state to clear'}.`));
|
|
433
577
|
}
|
|
578
|
+
return local.cleared
|
|
579
|
+
? { success: true, message: `run marked complete for ${orgName}` }
|
|
580
|
+
: { success: true, message: `nothing to clear for ${orgName}` };
|
|
434
581
|
};
|
|
435
582
|
const migrateAction = async (ctx) => {
|
|
436
583
|
const validated = validateOrgName(ctx.args[0]);
|
|
@@ -12,7 +12,36 @@ export type SecretFinding = {
|
|
|
12
12
|
location: string;
|
|
13
13
|
description: string;
|
|
14
14
|
};
|
|
15
|
-
|
|
15
|
+
/**
|
|
16
|
+
* Records what the scanner could NOT look at.
|
|
17
|
+
*
|
|
18
|
+
* Without this the scanner swallowed unreadable directories and stopped at its
|
|
19
|
+
* depth limit, then printed "No secrets found." — an error presented as a clean
|
|
20
|
+
* result. Callers must consult `scanWasIncomplete()` before reporting a clean
|
|
21
|
+
* bill of health.
|
|
22
|
+
*/
|
|
23
|
+
export interface ScanCoverage {
|
|
24
|
+
/** Directories that could not be listed (permissions, I/O). Real failures. */
|
|
25
|
+
unreadableDirs: string[];
|
|
26
|
+
/** Files that could not be read or stat'd. Real failures. */
|
|
27
|
+
unreadableFiles: string[];
|
|
28
|
+
/** Directories not descended into because the depth limit was reached. */
|
|
29
|
+
depthTruncatedDirs: string[];
|
|
30
|
+
/** Files skipped because they exceed the 1MB per-file cap. */
|
|
31
|
+
oversizedFiles: string[];
|
|
32
|
+
/** Files actually opened and pattern-matched. */
|
|
33
|
+
filesScanned: number;
|
|
34
|
+
/** Directories actually listed. */
|
|
35
|
+
dirsScanned: number;
|
|
36
|
+
}
|
|
37
|
+
export declare function createScanCoverage(): ScanCoverage;
|
|
38
|
+
/** True when some part of the tree was not examined, for any reason. */
|
|
39
|
+
export declare function scanWasIncomplete(c: ScanCoverage): boolean;
|
|
40
|
+
/** True when the scanner hit a hard failure (not merely a configured limit). */
|
|
41
|
+
export declare function scanHadErrors(c: ScanCoverage): boolean;
|
|
42
|
+
/** Human-readable lines describing every gap in coverage. Empty when complete. */
|
|
43
|
+
export declare function describeScanGaps(c: ScanCoverage): string[];
|
|
44
|
+
export declare function findSecretsInDir(dir: string, depthLimit: number, baseDir: string, findings: SecretFinding[], coverage?: ScanCoverage): void;
|
|
16
45
|
export declare const scanCommand: Command;
|
|
17
46
|
export declare const secretsCommand: Command;
|
|
18
47
|
//# sourceMappingURL=security-scan.d.ts.map
|
|
@@ -12,45 +12,97 @@ export const SECRET_PATTERNS = [
|
|
|
12
12
|
{ pattern: /['"]xox[baprs]-[a-zA-Z0-9-]+['"]/g, type: 'Slack Token' },
|
|
13
13
|
{ pattern: /password\s*[:=]\s*['"][^'"]{8,}['"]/gi, type: 'Hardcoded Password' },
|
|
14
14
|
];
|
|
15
|
-
export function
|
|
16
|
-
|
|
15
|
+
export function createScanCoverage() {
|
|
16
|
+
return {
|
|
17
|
+
unreadableDirs: [],
|
|
18
|
+
unreadableFiles: [],
|
|
19
|
+
depthTruncatedDirs: [],
|
|
20
|
+
oversizedFiles: [],
|
|
21
|
+
filesScanned: 0,
|
|
22
|
+
dirsScanned: 0,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
/** True when some part of the tree was not examined, for any reason. */
|
|
26
|
+
export function scanWasIncomplete(c) {
|
|
27
|
+
return (c.unreadableDirs.length > 0 ||
|
|
28
|
+
c.unreadableFiles.length > 0 ||
|
|
29
|
+
c.depthTruncatedDirs.length > 0 ||
|
|
30
|
+
c.oversizedFiles.length > 0);
|
|
31
|
+
}
|
|
32
|
+
/** True when the scanner hit a hard failure (not merely a configured limit). */
|
|
33
|
+
export function scanHadErrors(c) {
|
|
34
|
+
return c.unreadableDirs.length > 0 || c.unreadableFiles.length > 0;
|
|
35
|
+
}
|
|
36
|
+
/** Human-readable lines describing every gap in coverage. Empty when complete. */
|
|
37
|
+
export function describeScanGaps(c) {
|
|
38
|
+
const lines = [];
|
|
39
|
+
if (c.unreadableDirs.length > 0) {
|
|
40
|
+
lines.push(`${c.unreadableDirs.length} directory(ies) could not be read (e.g. ${c.unreadableDirs[0]})`);
|
|
41
|
+
}
|
|
42
|
+
if (c.unreadableFiles.length > 0) {
|
|
43
|
+
lines.push(`${c.unreadableFiles.length} file(s) could not be read (e.g. ${c.unreadableFiles[0]})`);
|
|
44
|
+
}
|
|
45
|
+
if (c.depthTruncatedDirs.length > 0) {
|
|
46
|
+
lines.push(`${c.depthTruncatedDirs.length} directory(ies) not scanned — depth limit reached (use --depth deep)`);
|
|
47
|
+
}
|
|
48
|
+
if (c.oversizedFiles.length > 0) {
|
|
49
|
+
lines.push(`${c.oversizedFiles.length} file(s) skipped — larger than 1MB`);
|
|
50
|
+
}
|
|
51
|
+
return lines;
|
|
52
|
+
}
|
|
53
|
+
export function findSecretsInDir(dir, depthLimit, baseDir, findings, coverage = createScanCoverage()) {
|
|
54
|
+
if (depthLimit <= 0) {
|
|
55
|
+
coverage.depthTruncatedDirs.push(relative(baseDir, dir) || dir);
|
|
17
56
|
return;
|
|
57
|
+
}
|
|
58
|
+
let entries;
|
|
18
59
|
try {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
60
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
coverage.unreadableDirs.push(relative(baseDir, dir) || dir);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
coverage.dirsScanned++;
|
|
67
|
+
for (const entry of entries) {
|
|
68
|
+
const isDotEnv = /^\.env(\..+)?$/.test(entry.name);
|
|
69
|
+
if ((entry.name.startsWith('.') && !isDotEnv) || entry.name === 'node_modules' || entry.name === 'dist')
|
|
70
|
+
continue;
|
|
71
|
+
const fullPath = join(dir, entry.name);
|
|
72
|
+
if (entry.isDirectory()) {
|
|
73
|
+
findSecretsInDir(fullPath, depthLimit - 1, baseDir, findings, coverage);
|
|
74
|
+
}
|
|
75
|
+
else if (entry.isFile() && (/\.(ts|js|json|yml|yaml)$/.test(entry.name) || isDotEnv) && !entry.name.endsWith('.d.ts')) {
|
|
76
|
+
let content;
|
|
77
|
+
try {
|
|
78
|
+
if (statSync(fullPath).size > 1024 * 1024) {
|
|
79
|
+
coverage.oversizedFiles.push(relative(baseDir, fullPath));
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
content = readFileSync(fullPath, 'utf-8');
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
coverage.unreadableFiles.push(relative(baseDir, fullPath));
|
|
23
86
|
continue;
|
|
24
|
-
const fullPath = join(dir, entry.name);
|
|
25
|
-
if (entry.isDirectory()) {
|
|
26
|
-
findSecretsInDir(fullPath, depthLimit - 1, baseDir, findings);
|
|
27
87
|
}
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
type: 'Hardcoded Secret',
|
|
42
|
-
location: `${relative(baseDir, fullPath)}:${i + 1}`,
|
|
43
|
-
description: type,
|
|
44
|
-
});
|
|
45
|
-
}
|
|
46
|
-
}
|
|
88
|
+
coverage.filesScanned++;
|
|
89
|
+
const lines = content.split('\n');
|
|
90
|
+
for (let i = 0; i < lines.length; i++) {
|
|
91
|
+
for (const { pattern, type } of SECRET_PATTERNS) {
|
|
92
|
+
pattern.lastIndex = 0;
|
|
93
|
+
let m;
|
|
94
|
+
while ((m = pattern.exec(lines[i])) !== null) {
|
|
95
|
+
findings.push({
|
|
96
|
+
severity: output.warning('HIGH'),
|
|
97
|
+
type: 'Hardcoded Secret',
|
|
98
|
+
location: `${relative(baseDir, fullPath)}:${i + 1}`,
|
|
99
|
+
description: type,
|
|
100
|
+
});
|
|
47
101
|
}
|
|
48
102
|
}
|
|
49
|
-
catch { /* file read error */ }
|
|
50
103
|
}
|
|
51
104
|
}
|
|
52
105
|
}
|
|
53
|
-
catch { /* dir read error */ }
|
|
54
106
|
}
|
|
55
107
|
// ─── scan subcommand ─────────────────────────────────────────────────────────
|
|
56
108
|
export const scanCommand = {
|
|
@@ -92,6 +144,7 @@ export const scanCommand = {
|
|
|
92
144
|
const spinner = output.createSpinner({ text: `Scanning ${target}...`, spinner: 'dots' });
|
|
93
145
|
spinner.start();
|
|
94
146
|
const findings = [];
|
|
147
|
+
const coverage = createScanCoverage();
|
|
95
148
|
let criticalCount = 0, highCount = 0, mediumCount = 0, lowCount = 0;
|
|
96
149
|
try {
|
|
97
150
|
const fs = await import('fs');
|
|
@@ -156,7 +209,7 @@ export const scanCommand = {
|
|
|
156
209
|
spinner.setText('Scanning for hardcoded secrets...');
|
|
157
210
|
const scanDepth = depth === 'deep' ? 10 : depth === 'standard' ? 5 : 3;
|
|
158
211
|
const prevCount = findings.length;
|
|
159
|
-
findSecretsInDir(path.resolve(target), scanDepth, path.resolve(target), findings);
|
|
212
|
+
findSecretsInDir(path.resolve(target), scanDepth, path.resolve(target), findings, coverage);
|
|
160
213
|
highCount += findings.length - prevCount;
|
|
161
214
|
}
|
|
162
215
|
if ((scanType === 'all' || scanType === 'code') && depth !== 'quick') {
|
|
@@ -168,53 +221,74 @@ export const scanCommand = {
|
|
|
168
221
|
{ pattern: /child_process.*exec[^S]/g, type: 'Command Injection', severity: 'high', desc: 'Possible command injection' },
|
|
169
222
|
{ pattern: /\$\{.*\}.*sql|sql.*\$\{/gi, type: 'SQL Injection', severity: 'high', desc: 'Possible SQL injection' },
|
|
170
223
|
];
|
|
224
|
+
// Same coverage accounting as findSecretsInDir: gaps are recorded, never
|
|
225
|
+
// swallowed, so an unreadable tree cannot masquerade as a clean one.
|
|
226
|
+
const codeBase = path.resolve(target);
|
|
171
227
|
const scanCodeDir = (dir, depthLimit) => {
|
|
172
|
-
if (depthLimit <= 0)
|
|
228
|
+
if (depthLimit <= 0) {
|
|
229
|
+
coverage.depthTruncatedDirs.push(path.relative(codeBase, dir) || dir);
|
|
173
230
|
return;
|
|
231
|
+
}
|
|
232
|
+
let entries;
|
|
174
233
|
try {
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
234
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
235
|
+
}
|
|
236
|
+
catch {
|
|
237
|
+
coverage.unreadableDirs.push(path.relative(codeBase, dir) || dir);
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
for (const entry of entries) {
|
|
241
|
+
if (entry.name.startsWith('.') || entry.name === 'node_modules' || entry.name === 'dist')
|
|
242
|
+
continue;
|
|
243
|
+
const fullPath = path.join(dir, entry.name);
|
|
244
|
+
if (entry.isDirectory()) {
|
|
245
|
+
scanCodeDir(fullPath, depthLimit - 1);
|
|
246
|
+
}
|
|
247
|
+
else if (entry.isFile() && /\.(ts|js|tsx|jsx)$/.test(entry.name) && !entry.name.endsWith('.d.ts')) {
|
|
248
|
+
let content;
|
|
249
|
+
try {
|
|
250
|
+
if (fs.statSync(fullPath).size > 1024 * 1024) {
|
|
251
|
+
coverage.oversizedFiles.push(path.relative(codeBase, fullPath));
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
content = fs.readFileSync(fullPath, 'utf-8');
|
|
255
|
+
}
|
|
256
|
+
catch {
|
|
257
|
+
coverage.unreadableFiles.push(path.relative(codeBase, fullPath));
|
|
178
258
|
continue;
|
|
179
|
-
const fullPath = path.join(dir, entry.name);
|
|
180
|
-
if (entry.isDirectory()) {
|
|
181
|
-
scanCodeDir(fullPath, depthLimit - 1);
|
|
182
259
|
}
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
severity: severity === 'high' ? output.warning('HIGH') : output.warning('MEDIUM'),
|
|
200
|
-
type,
|
|
201
|
-
location: `${path.relative(target, fullPath)}:${i + 1}`,
|
|
202
|
-
description: desc,
|
|
203
|
-
});
|
|
204
|
-
}
|
|
205
|
-
}
|
|
260
|
+
const lines = content.split('\n');
|
|
261
|
+
for (let i = 0; i < lines.length; i++) {
|
|
262
|
+
for (const { pattern, type, severity, desc } of codePatterns) {
|
|
263
|
+
pattern.lastIndex = 0;
|
|
264
|
+
let m;
|
|
265
|
+
while ((m = pattern.exec(lines[i])) !== null) {
|
|
266
|
+
if (severity === 'high')
|
|
267
|
+
highCount++;
|
|
268
|
+
else
|
|
269
|
+
mediumCount++;
|
|
270
|
+
findings.push({
|
|
271
|
+
severity: severity === 'high' ? output.warning('HIGH') : output.warning('MEDIUM'),
|
|
272
|
+
type,
|
|
273
|
+
location: `${path.relative(target, fullPath)}:${i + 1}`,
|
|
274
|
+
description: desc,
|
|
275
|
+
});
|
|
206
276
|
}
|
|
207
277
|
}
|
|
208
|
-
catch { /* file read error */ }
|
|
209
278
|
}
|
|
210
279
|
}
|
|
211
280
|
}
|
|
212
|
-
catch { /* dir read error */ }
|
|
213
281
|
};
|
|
214
282
|
const scanDepth = depth === 'deep' ? 10 : 5;
|
|
215
283
|
scanCodeDir(path.resolve(target), scanDepth);
|
|
216
284
|
}
|
|
217
|
-
|
|
285
|
+
const gaps = describeScanGaps(coverage);
|
|
286
|
+
if (gaps.length > 0) {
|
|
287
|
+
spinner.stop(output.warning('Scan finished with INCOMPLETE coverage'));
|
|
288
|
+
}
|
|
289
|
+
else {
|
|
290
|
+
spinner.succeed('Scan complete');
|
|
291
|
+
}
|
|
218
292
|
output.writeln();
|
|
219
293
|
if (findings.length > 0) {
|
|
220
294
|
output.printTable({
|
|
@@ -229,9 +303,19 @@ export const scanCommand = {
|
|
|
229
303
|
if (findings.length > 20)
|
|
230
304
|
output.writeln(output.dim(`... and ${findings.length - 20} more issues`));
|
|
231
305
|
}
|
|
306
|
+
else if (gaps.length > 0) {
|
|
307
|
+
// Never present an incomplete scan as a clean bill of health.
|
|
308
|
+
output.writeln(output.warning('No security issues found in the parts that could be scanned — coverage was INCOMPLETE (see below).'));
|
|
309
|
+
}
|
|
232
310
|
else {
|
|
233
311
|
output.writeln(output.success('No security issues found!'));
|
|
234
312
|
}
|
|
313
|
+
if (gaps.length > 0) {
|
|
314
|
+
output.writeln();
|
|
315
|
+
output.writeln(output.warning('Incomplete coverage:'));
|
|
316
|
+
for (const g of gaps)
|
|
317
|
+
output.writeln(output.warning(` - ${g}`));
|
|
318
|
+
}
|
|
235
319
|
output.writeln();
|
|
236
320
|
output.printBox([
|
|
237
321
|
`Target: ${target}`,
|
|
@@ -240,6 +324,9 @@ export const scanCommand = {
|
|
|
240
324
|
``,
|
|
241
325
|
`Critical: ${criticalCount} High: ${highCount} Medium: ${mediumCount} Low: ${lowCount}`,
|
|
242
326
|
`Total Issues: ${findings.length}`,
|
|
327
|
+
``,
|
|
328
|
+
`Coverage: ${coverage.filesScanned} file(s) in ${coverage.dirsScanned} dir(s) scanned`,
|
|
329
|
+
`Coverage status: ${gaps.length === 0 ? 'complete' : `INCOMPLETE (${gaps.length} gap type(s))`}`,
|
|
243
330
|
].join('\n'), 'Scan Summary');
|
|
244
331
|
if (fix && criticalCount + highCount > 0) {
|
|
245
332
|
const resolvedTarget = realpathSync(path.resolve(target));
|
|
@@ -263,6 +350,11 @@ export const scanCommand = {
|
|
|
263
350
|
fixSpinner.fail('Some fixes could not be applied automatically');
|
|
264
351
|
}
|
|
265
352
|
}
|
|
353
|
+
// A scan that hit real read errors cannot certify anything — fail loudly.
|
|
354
|
+
// Depth truncation is a configured limit, not an error, so it is reported
|
|
355
|
+
// above but does not by itself flip the exit status.
|
|
356
|
+
if (scanHadErrors(coverage))
|
|
357
|
+
return { success: false };
|
|
266
358
|
return { success: findings.length === 0 || (criticalCount === 0 && highCount === 0) };
|
|
267
359
|
}
|
|
268
360
|
catch (error) {
|
|
@@ -307,11 +399,21 @@ export const secretsCommand = {
|
|
|
307
399
|
const spinner = output.createSpinner({ text: `Scanning ${targetPath}...`, spinner: 'dots' });
|
|
308
400
|
spinner.start();
|
|
309
401
|
const findings = [];
|
|
402
|
+
const coverage = createScanCoverage();
|
|
310
403
|
const scanDepth = depth === 'deep' ? 10 : depth === 'standard' ? 5 : 3;
|
|
311
|
-
findSecretsInDir(resolve(targetPath), scanDepth, resolve(targetPath), findings);
|
|
312
|
-
|
|
404
|
+
findSecretsInDir(resolve(targetPath), scanDepth, resolve(targetPath), findings, coverage);
|
|
405
|
+
const gaps = describeScanGaps(coverage);
|
|
406
|
+
if (gaps.length > 0) {
|
|
407
|
+
spinner.stop(output.warning('Scan finished with INCOMPLETE coverage'));
|
|
408
|
+
}
|
|
409
|
+
else {
|
|
410
|
+
spinner.succeed('Scan complete');
|
|
411
|
+
}
|
|
313
412
|
output.writeln();
|
|
314
|
-
if (findings.length === 0) {
|
|
413
|
+
if (findings.length === 0 && gaps.length > 0) {
|
|
414
|
+
output.writeln(output.warning('No secrets found in the parts that could be scanned — coverage was INCOMPLETE.'));
|
|
415
|
+
}
|
|
416
|
+
else if (findings.length === 0) {
|
|
315
417
|
output.writeln(output.success('No secrets found.'));
|
|
316
418
|
}
|
|
317
419
|
else {
|
|
@@ -326,9 +428,20 @@ export const secretsCommand = {
|
|
|
326
428
|
if (findings.length > 20)
|
|
327
429
|
output.writeln(output.dim(`... and ${findings.length - 20} more`));
|
|
328
430
|
}
|
|
431
|
+
if (gaps.length > 0) {
|
|
432
|
+
output.writeln();
|
|
433
|
+
output.writeln(output.warning('Incomplete coverage:'));
|
|
434
|
+
for (const g of gaps)
|
|
435
|
+
output.writeln(output.warning(` - ${g}`));
|
|
436
|
+
}
|
|
329
437
|
output.writeln();
|
|
330
|
-
output.writeln(output.bold('Summary: ') +
|
|
331
|
-
|
|
438
|
+
output.writeln(output.bold('Summary: ') +
|
|
439
|
+
`${findings.length} secret(s) found in ${targetPath} ` +
|
|
440
|
+
`(${coverage.filesScanned} file(s) in ${coverage.dirsScanned} dir(s) scanned, ` +
|
|
441
|
+
`coverage ${gaps.length === 0 ? 'complete' : 'INCOMPLETE'})`);
|
|
442
|
+
// Read errors mean the tree was not fully examined — "no secrets" is not
|
|
443
|
+
// a result we can stand behind, so do not exit 0 on it.
|
|
444
|
+
return { success: findings.length === 0 && !scanHadErrors(coverage) };
|
|
332
445
|
},
|
|
333
446
|
};
|
|
334
447
|
//# sourceMappingURL=security-scan.js.map
|