residoo 0.23.1 → 0.24.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/README.md CHANGED
@@ -53,8 +53,10 @@ again, since there's nothing to lose. `residoo guard` blocks an obviously
53
53
  sensitive file read before it happens (100% recall, 0% false positives on
54
54
  its own [scored 81-case corpus](bench/guard/RESULTS.md)) and, via a second
55
55
  hook, a secret typed directly into the prompt itself — confirmed against
56
- Claude Code's own docs to block before the model ever processes it. All
57
- four are covered in [docs/features.md](docs/features.md).
56
+ Claude Code's own docs to block before the model ever processes it.
57
+ `residoo dashboard` is this same report as a local, read-only web page
58
+ instead of a terminal, opened in your browser on demand. All five are
59
+ covered in [docs/features.md](docs/features.md).
58
60
 
59
61
  > [!NOTE]
60
62
  > gitleaks and trufflehog scan **commits**. residoo scans the **conversation
@@ -148,10 +150,11 @@ while losing rows, then fixed in public against the classes it was losing
148
150
  acknowledgement ledger. See [docs/architecture.md](docs/architecture.md#rotation-from-found-to-closed).
149
151
  - `--project <dir>` scans a repository checkout instead of the machine,
150
152
  for CI and pre-commit. See [docs/ci.md](docs/ci.md).
151
- - `residoo watch` / `residoo mcp` / `residoo cred` / `residoo guard`:
152
- continuous scanning, conversational queries, credential injection
153
- without pasting, and blocking a sensitive file read or a sensitive
154
- prompt before either happens. See [docs/features.md](docs/features.md).
153
+ - `residoo watch` / `residoo mcp` / `residoo cred` / `residoo guard` /
154
+ `residoo dashboard`: continuous scanning, conversational queries,
155
+ credential injection without pasting, blocking a sensitive file read or
156
+ a sensitive prompt before either happens, and a local read-only web UI.
157
+ See [docs/features.md](docs/features.md).
155
158
 
156
159
  ## What it does not do
157
160
 
@@ -243,7 +246,7 @@ residoo ack <fingerprint> [--note <text>] mark one finding rotated
243
246
  residoo unseal <vault-dir> list a vault's contents
244
247
  residoo unseal <vault-dir> --restore <n> --out <p> restore one file, hash-verified
245
248
 
246
- residoo watch / mcp / cred / guard see docs/features.md
249
+ residoo watch / mcp / cred / guard / dashboard see docs/features.md
247
250
  ```
248
251
 
249
252
  The vault passphrase comes from `RESIDOO_PASSPHRASE` or a hidden interactive
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "residoo",
3
- "version": "0.23.1",
3
+ "version": "0.24.0",
4
4
  "description": "Find secrets leaking through your AI coding agent's session history. Zero network calls in the scan path, zero dependencies.",
5
5
  "license": "MIT",
6
6
  "author": "CloudRoam (https://cloudroam.io)",
package/src/cli.js CHANGED
@@ -12,6 +12,7 @@ const {
12
12
  ROTATION_GUIDANCE, guidanceFor, loadAcks, loadDismissed, ackFinding, dismissFinding, renderRotation,
13
13
  } = require("./rotation");
14
14
  const { startWatch, isTailable } = require("./watch");
15
+ const { startDashboardServer, openBrowser } = require("./dashboard");
15
16
  const { startMcpServer } = require("./mcp");
16
17
  const { buildTools } = require("./mcpTools");
17
18
  const { runGuard: runGuardEngine, buildHookConfig } = require("./guard");
@@ -218,6 +219,30 @@ Watch:
218
219
  Ctrl+C stops cleanly and prints a session summary (skipped with --json,
219
220
  where the same information is one final NDJSON event).
220
221
 
222
+ Dashboard:
223
+ residoo dashboard the exact "scan --html" report, served live at
224
+ a local URL instead of written to disk, and
225
+ opened in your browser automatically. Read-only:
226
+ this only visualizes what a scan already shows
227
+ you, it never acks/dismisses/seals anything.
228
+ Every page reload re-scans from scratch -- there
229
+ is no separate refresh action, and nothing here
230
+ is cached across requests.
231
+ --port <n> use a specific port instead of letting the OS
232
+ assign one
233
+ --no-open print the URL instead of opening a browser tab
234
+ automatically
235
+ --include-noisy, --include-suppressed, --include-pii,
236
+ --include-injection, --no-integrity, --project [dir]
237
+ same meaning as scan
238
+ Bound to 127.0.0.1 only, never your network, and every request must
239
+ present a random token generated fresh for this run (printed as part
240
+ of the URL) -- the same local-security model Jupyter Notebook uses.
241
+ The server also rejects any request whose Host header isn't
242
+ 127.0.0.1/localhost, closing the DNS-rebinding class this project's
243
+ own cve.js already catalogued two real MCP SDK CVEs for. Ctrl+C stops
244
+ it; nothing is left running in the background afterward.
245
+
221
246
  MCP:
222
247
  residoo mcp run residoo as an MCP server over stdio, so
223
248
  Claude Code (or any other MCP client) can query
@@ -853,6 +878,138 @@ async function runWatch(args) {
853
878
  return 0;
854
879
  }
855
880
 
881
+ /**
882
+ * `residoo dashboard`: the exact `--html` report, served live over a local,
883
+ * read-only HTTP server instead of written to disk -- see dashboard.js's
884
+ * own header for the DNS-rebinding defense, the per-run token, and why
885
+ * this is strictly more locked-down than the static file it's built from.
886
+ * On-demand only (this session's own explicit scope decision, matching
887
+ * `watch`'s "you run a command when you want it" model, not an always-on
888
+ * background service): the server runs until Ctrl-C, same shutdown
889
+ * contract as `runWatch` above. Every request re-scans from scratch, so
890
+ * reloading the page in the browser is how you refresh it -- no separate
891
+ * "refresh" action, no cached-at-startup snapshot going stale.
892
+ */
893
+ async function runDashboard(args) {
894
+ const includeNoisy = args.includes("--include-noisy");
895
+ const includeSuppressed = args.includes("--include-suppressed");
896
+ const includePii = args.includes("--include-pii");
897
+ const includeInjection = args.includes("--include-injection");
898
+ const wantsIntegrity = !args.includes("--no-integrity");
899
+ const noBrowser = args.includes("--no-open");
900
+
901
+ let port = 0; // 0: OS assigns an ephemeral port
902
+ const portArg = argValue(args, "--port");
903
+ if (portArg !== null) {
904
+ const n = Number(portArg);
905
+ if (!Number.isFinite(n) || !Number.isInteger(n) || n < 1 || n > 65535) {
906
+ process.stderr.write(`--port must be an integer between 1 and 65535; got "${portArg}".\n`);
907
+ return 2;
908
+ }
909
+ port = n;
910
+ }
911
+
912
+ let projectRoot = null;
913
+ const projectIdx = args.indexOf("--project");
914
+ if (projectIdx >= 0) {
915
+ const next = args[projectIdx + 1];
916
+ projectRoot = path.resolve(next && !next.startsWith("--") ? next : ".");
917
+ }
918
+
919
+ let sources;
920
+ if (projectRoot) {
921
+ const projectArtifacts = require("./sources/project-artifacts");
922
+ const src = projectArtifacts.withRoot(projectRoot);
923
+ if (!src.available()) {
924
+ process.stderr.write(`--project: "${projectRoot}" is not a readable directory.\n`);
925
+ return 2;
926
+ }
927
+ sources = [src];
928
+ } else {
929
+ sources = availableSources();
930
+ if (sources.length === 0) {
931
+ process.stderr.write(
932
+ "No known transcript sources found on this machine; nothing to show.\n" +
933
+ `Checked: ${sourceStatusList()}.\n`
934
+ );
935
+ return 0;
936
+ }
937
+ }
938
+
939
+ // Same crash-degrades-to-a-visible-warning contract runIntegrity() in
940
+ // the scan path above already has, duplicated rather than shared: this
941
+ // function does its own arg parsing and orchestration independently of
942
+ // the scan-command code path (the same "one small self-contained CLI
943
+ // command function" split runWatch/runMcp already establish), rather
944
+ // than risking a regression in the far more heavily depended-on scan
945
+ // path by factoring it out.
946
+ const runIntegrityOnce = () => {
947
+ try {
948
+ const integ = checkIntegrity(projectRoot ? { home: projectRoot, cwd: projectRoot, projectMode: true } : {});
949
+ if (projectRoot) {
950
+ integ.scopeNote = "Integrity checks cover the --project directory only (paths shown relative to it); this machine's home-level agent configs were not examined on this run.";
951
+ }
952
+ return integ;
953
+ } catch (e) {
954
+ const why = String((e && e.message) || e).replace(/[\x00-\x1f\x7f]/g, "").slice(0, 200);
955
+ return {
956
+ findings: [{
957
+ severity: "warn", kind: "integrity-crashed", file: "(integrity checker)",
958
+ detail: `integrity checks crashed (${why}). Config locations are UNVERIFIED, not clean; the secret-scan results are unaffected`,
959
+ }],
960
+ filesChecked: [],
961
+ scopeNote: "Integrity checks did not complete on this run.",
962
+ };
963
+ }
964
+ };
965
+
966
+ // Called fresh on every HTTP request (see dashboard.js) -- never caches
967
+ // across requests, so a reload always reflects the current machine
968
+ // state, not a snapshot from when the server started.
969
+ const gatherData = async () => {
970
+ const acks = loadAcks();
971
+ const dismissed = loadDismissed();
972
+ const integrity = wantsIntegrity ? runIntegrityOnce() : null;
973
+ const result = await scan({
974
+ sources, includeNoisy, includeSuppressed, includePii, includeInjection,
975
+ verify: false, noColor: false,
976
+ });
977
+ const rotation = renderRotation(result.findings, acks, dismissed);
978
+ return { result, integrity, rotation };
979
+ };
980
+
981
+ let started;
982
+ try {
983
+ started = await startDashboardServer({ port, gatherData });
984
+ } catch (err) {
985
+ process.stderr.write(`residoo dashboard: could not start the local server: ${(err && err.message) || err}\n`);
986
+ return 1;
987
+ }
988
+
989
+ process.stderr.write(
990
+ `residoo dashboard: ${started.url}\n` +
991
+ "Read-only, localhost only -- nothing here leaves this machine, and nothing here can change anything on it.\n" +
992
+ "Every page reload re-scans from scratch. Press Ctrl-C to stop.\n"
993
+ );
994
+ if (!noBrowser) openBrowser(started.url);
995
+
996
+ let signalled = false;
997
+ return new Promise((resolve) => {
998
+ const onSignal = () => {
999
+ if (signalled) return;
1000
+ signalled = true;
1001
+ started.stop().then(() => {
1002
+ process.removeListener("SIGINT", onSignal);
1003
+ process.removeListener("SIGTERM", onSignal);
1004
+ process.stderr.write("residoo dashboard: stopped.\n");
1005
+ resolve(0);
1006
+ });
1007
+ };
1008
+ process.once("SIGINT", onSignal);
1009
+ process.once("SIGTERM", onSignal);
1010
+ });
1011
+ }
1012
+
856
1013
  /**
857
1014
  * `residoo guard --print-config`: print the merged .claude/settings.json
858
1015
  * a user would need to register all three guard hooks, without ever
@@ -1084,6 +1241,7 @@ async function main(argv) {
1084
1241
  if (cmd === "ack") return runAck(args);
1085
1242
  if (cmd === "dismiss") return runDismiss(args);
1086
1243
  if (cmd === "watch") return runWatch(args);
1244
+ if (cmd === "dashboard") return runDashboard(args);
1087
1245
  if (cmd === "mcp") return runMcp(args);
1088
1246
  if (cmd === "cred") return runCred(args);
1089
1247
  if (cmd === "guard") {
@@ -0,0 +1,175 @@
1
+ "use strict";
2
+
3
+ const http = require("http");
4
+ const crypto = require("crypto");
5
+ // Not destructured: `cp.spawn(...)` at the call site, matching notify.js's
6
+ // own documented reasoning -- a test can monkey-patch
7
+ // `require("child_process").spawn` on the shared module object.
8
+ const cp = require("child_process");
9
+ const { renderHtml } = require("./report");
10
+
11
+ /**
12
+ * `residoo dashboard`'s engine: a local, read-only HTTP server serving the
13
+ * exact same self-contained page `residoo scan --html` writes to disk
14
+ * (`renderHtml`, unmodified -- see report.js), except regenerated fresh on
15
+ * every request instead of written once. No new rendering code, no new
16
+ * data model: this is `--html`'s own output, served live instead of saved.
17
+ *
18
+ * SECURITY, taken as seriously as this project takes everything else it
19
+ * ships -- this is the first HTTP server residoo has ever run, and this
20
+ * project spent the last two releases cataloguing exactly what goes wrong
21
+ * when a local MCP server gets this wrong (see cve.js: CVE-2025-66414 and
22
+ * CVE-2025-66416, both "DNS rebinding protection not enabled by default"
23
+ * in the MCP TypeScript/Python SDKs). The same lesson, applied here:
24
+ *
25
+ * - **DNS rebinding**: binding to 127.0.0.1 alone does NOT stop a
26
+ * malicious webpage's browser tab from reaching this server -- an
27
+ * attacker-controlled domain can be made to resolve to 127.0.0.1
28
+ * after an initial same-origin check passes, letting page JavaScript
29
+ * read the response as if it were same-origin. The actual fix (the
30
+ * one those two CVEs shipped) is validating the `Host` header
31
+ * server-side on every request: rejected outright unless it's
32
+ * exactly `127.0.0.1:<this server's port>` or `localhost:<port>`.
33
+ * - **A random per-run token**, required on every request (checked
34
+ * AFTER the Host check, so a wrong-Host request never even reaches
35
+ * token comparison) -- the same well-established local-security model
36
+ * Jupyter Notebook has used for years. Defense in depth beyond DNS
37
+ * rebinding: it also stops another local process or another user
38
+ * account on a shared machine from stumbling onto the port and
39
+ * reading a scan (already-redacted, but still real file paths and
40
+ * rotation status) without ever being invited to.
41
+ * - **Response headers** a static file has no mechanism to carry at
42
+ * all: `X-Frame-Options: DENY` and `frame-ancestors 'none'` (no
43
+ * clickjacking-by-iframe), `X-Content-Type-Options: nosniff`, a CSP
44
+ * that blocks any REMOTE resource load (`default-src 'self'`) while
45
+ * still allowing the page's own inline `<style>`/`<script>` --
46
+ * which is server-generated, not reflecting unescaped request data
47
+ * (every finding field already goes through `escapeHtml()` in
48
+ * report.js). This makes the dashboard strictly more defended than
49
+ * the plain `--html` file it's built from, which ships with none of
50
+ * these headers because a file on disk has nowhere to put them.
51
+ * - **No write path at all**: every request is a GET of `/`; nothing
52
+ * here can mutate state, matching this release's own scoped-down
53
+ * "visualize existing data, take no actions" decision. A future
54
+ * action-taking dashboard (ack/dismiss/seal from the UI) is a
55
+ * separate, later decision -- see README/features.md.
56
+ * - **No CORS headers are ever sent.** The browser's own same-origin
57
+ * policy already blocks a different-origin page's JavaScript from
58
+ * reading this response; adding a permissive
59
+ * `Access-Control-Allow-Origin` would undo that for no benefit here.
60
+ */
61
+
62
+ /**
63
+ * Cross-platform "open this URL in the default browser," best-effort only
64
+ * -- the terminal always prints the URL too, so a failure here just means
65
+ * the user clicks/copies it instead of it opening automatically. Never
66
+ * throws, matching notify.js's own "a UI convenience must never affect
67
+ * the caller" contract.
68
+ */
69
+ function openBrowser(url) {
70
+ try {
71
+ if (process.platform === "darwin") {
72
+ const child = cp.spawn("open", [url], { stdio: "ignore" });
73
+ child.on("error", () => {});
74
+ child.unref();
75
+ } else if (process.platform === "win32") {
76
+ // cmd's built-in `start` treats its first quoted argument as the
77
+ // window TITLE, not the target -- the empty "" is required so `url`
78
+ // is parsed as the target instead.
79
+ const child = cp.spawn("cmd.exe", ["/c", "start", "", url], { stdio: "ignore", windowsHide: true });
80
+ child.on("error", () => {});
81
+ child.unref();
82
+ } else {
83
+ const child = cp.spawn("xdg-open", [url], { stdio: "ignore" });
84
+ child.on("error", () => {});
85
+ child.unref();
86
+ }
87
+ } catch {
88
+ // Best-effort only -- see function docstring.
89
+ }
90
+ }
91
+
92
+ const CSP =
93
+ "default-src 'self'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; " +
94
+ "frame-ancestors 'none'; connect-src 'self'; img-src 'self' data:";
95
+
96
+ /**
97
+ * Start the dashboard's HTTP server. `gatherData()` is an async function
98
+ * returning `{ result, integrity, rotation }` (the same three arguments
99
+ * `renderHtml` already takes) -- called fresh on every request, so
100
+ * reloading the page in the browser re-scans rather than showing a
101
+ * cached-at-startup snapshot. `port` 0 (the default) lets the OS assign
102
+ * an ephemeral port; a caller-chosen port is used verbatim.
103
+ *
104
+ * Resolves to `{ url, stop }` once the server is actually listening --
105
+ * `url` already carries the per-run token as a query parameter, ready to
106
+ * open directly. `stop()` closes the server and resolves once fully shut
107
+ * down (never leaves a lingering listener behind on Ctrl-C).
108
+ */
109
+ function startDashboardServer({ port = 0, gatherData }) {
110
+ const token = crypto.randomBytes(16).toString("hex");
111
+
112
+ const server = http.createServer(async (req, res) => {
113
+ try {
114
+ const hostHeader = String(req.headers.host || "");
115
+ const hostname = hostHeader.split(":")[0];
116
+ if (hostname !== "127.0.0.1" && hostname !== "localhost") {
117
+ res.writeHead(403, { "Content-Type": "text/plain" });
118
+ res.end("Forbidden: unexpected Host header");
119
+ return;
120
+ }
121
+
122
+ let url;
123
+ try { url = new URL(req.url, `http://${hostHeader || "localhost"}`); }
124
+ catch { res.writeHead(400, { "Content-Type": "text/plain" }); res.end("Bad request"); return; }
125
+
126
+ if (url.searchParams.get("token") !== token) {
127
+ res.writeHead(403, { "Content-Type": "text/plain" });
128
+ res.end("Forbidden: missing or incorrect token");
129
+ return;
130
+ }
131
+
132
+ if (req.method !== "GET" || url.pathname !== "/") {
133
+ res.writeHead(404, { "Content-Type": "text/plain" });
134
+ res.end("Not found");
135
+ return;
136
+ }
137
+
138
+ const { result, integrity, rotation } = await gatherData();
139
+ const html = renderHtml(result, integrity, rotation);
140
+ res.writeHead(200, {
141
+ "Content-Type": "text/html; charset=utf-8",
142
+ "X-Frame-Options": "DENY",
143
+ "X-Content-Type-Options": "nosniff",
144
+ "Content-Security-Policy": CSP,
145
+ // Every response is a fresh scan; a cached stale one defeats the
146
+ // whole point of reloading to see current state.
147
+ "Cache-Control": "no-store",
148
+ });
149
+ res.end(html);
150
+ } catch {
151
+ // A scan/render failure must not crash the server or hang the
152
+ // request -- degrade to a visible 500, the same "never a silent
153
+ // all-clear, never a silent crash" discipline every other part of
154
+ // this project already holds to.
155
+ try {
156
+ res.writeHead(500, { "Content-Type": "text/plain" });
157
+ res.end("residoo dashboard: the scan failed while rendering this page. Check the terminal residoo is running in for details.");
158
+ } catch { /* response already sent/destroyed */ }
159
+ }
160
+ });
161
+
162
+ return new Promise((resolve, reject) => {
163
+ server.once("error", reject);
164
+ server.listen(port, "127.0.0.1", () => {
165
+ const actualPort = server.address().port;
166
+ const url = `http://127.0.0.1:${actualPort}/?token=${token}`;
167
+ resolve({
168
+ url,
169
+ stop: () => new Promise((r) => server.close(() => r())),
170
+ });
171
+ });
172
+ });
173
+ }
174
+
175
+ module.exports = { startDashboardServer, openBrowser };