diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 57669aec..a7785fdb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -142,7 +142,7 @@ jobs: libgtk-3-0 libasound2t64 xvfb zsh - name: Build the app the e2e test launches - # e2e/smoke.spec.ts asserts out/main/index.js exists — build it first. + # Electron journeys launch out/main/index.js — build it first. run: npm run build - name: Playwright for Electron diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e05434a..5f036fbe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,20 @@ for full commit body + generated notes. ## Unreleased +## v0.18.3 — 2026-10-16 + +- Consolidate test coverage owners and current test/install walkthroughs. Remove + duplicate startup/package-text checks, historical plugin-file absence checks, + repeated contrast assertions and an obsolete permanently skipped HUD case. + Keep manifest validation, actual installed verifier checks and recording regressions. + +- Recheck PTY readiness after observing command completion before closing the + retained slave copies. A previously empty poll could be stale if a command + wrote and finished while the recorder was descheduled, still losing output + on macOS. A deterministic scheduling regression covers that exact window. +- v0.18.2 was published, then marked prerelease after this remaining loss was + found by native CI. It is superseded; its original tag and evidence remain. + ## v0.18.2 — 2026-10-16 v0.18.1 was tagged but its macOS recording tests blocked publication; no diff --git a/README.md b/README.md index ee34d933..628fb06e 100644 --- a/README.md +++ b/README.md @@ -15,15 +15,15 @@ Install RedLog on **your operator machine** (the Kali box or laptop you attack from), not on a target. It records what you do; it is not an implant. Grab the installer for your platform from the -[**releases page**](https://github.com/guan4tou2/REDLOG/releases/latest) — current version **v0.18.2**. +[**releases page**](https://github.com/guan4tou2/REDLOG/releases/latest) — current version **v0.18.3**. Builds are unsigned (ad-hoc signed, not notarised), so each OS asks you to confirm once. | Platform | File | Install | |----------|------|---------| -| Kali / Debian / Ubuntu (x64) | [`redlog_0.18.2_amd64.deb`](https://github.com/guan4tou2/REDLOG/releases/download/v0.18.2/redlog_0.18.2_amd64.deb) | `sudo apt install ./redlog_*.deb` | -| Windows (x64 installer) | [`RedLog.Setup.0.18.2.exe`](https://github.com/guan4tou2/REDLOG/releases/download/v0.18.2/RedLog.Setup.0.18.2.exe) | Run it; at SmartScreen choose **More info → Run anyway** | -| macOS (Apple Silicon) | [`RedLog-0.18.2-arm64.dmg`](https://github.com/guan4tou2/REDLOG/releases/download/v0.18.2/RedLog-0.18.2-arm64.dmg) | Drag to `/Applications`, then see below | +| Kali / Debian / Ubuntu (x64) | [`redlog_0.18.3_amd64.deb`](https://github.com/guan4tou2/REDLOG/releases/download/v0.18.3/redlog_0.18.3_amd64.deb) | `sudo apt install ./redlog_*.deb` | +| Windows (x64 installer) | [`RedLog.Setup.0.18.3.exe`](https://github.com/guan4tou2/REDLOG/releases/download/v0.18.3/RedLog.Setup.0.18.3.exe) | Run it; at SmartScreen choose **More info → Run anyway** | +| macOS (Apple Silicon) | [`RedLog-0.18.3-arm64.dmg`](https://github.com/guan4tou2/REDLOG/releases/download/v0.18.3/RedLog-0.18.3-arm64.dmg) | Drag to `/Applications`, then see below | | macOS (Intel) | — | No installer; [build from source](#development) | Official downloads are installed packages: NSIS on Windows, DEB on Kali / diff --git a/docs/README.md b/docs/README.md index 33451185..dc541bfa 100644 --- a/docs/README.md +++ b/docs/README.md @@ -11,7 +11,7 @@ Wiki-style index for **v0.14.3**. Every page is self-contained; follow the links ## Start here - **[操作者手冊](USER-GUIDE.md)** — for the person *using* RedLog rather than building it: the first ten minutes, what each screen answers, how to wire the three capture sources that matter, how to export. 繁體中文, like the interface. -- **Picking the work up?** Work is specified and tracked in [`specs/`](../specs/) (Spec Kit — one directory per change, each with spec, plan, tasks and verification); the rules every change is checked against are in [`.specify/memory/constitution.md`](../.specify/memory/constitution.md). [Architecture](ARCHITECTURE.md) is the current map of the code, and [Testing](TESTING.md) lists every config option with the test that proves it. +- **Picking the work up?** Work is specified and tracked in [`specs/`](../specs/) (Spec Kit — one directory per change, each with spec, plan, tasks and verification); the rules every change is checked against are in [`.specify/memory/constitution.md`](../.specify/memory/constitution.md). [Architecture](ARCHITECTURE.md) is the current map of the code, and [Testing](TESTING.md) lists the current test layers, commands and coverage owners. - **[Agent integration](agent-integration.md)** — if you're wiring an agent to RedLog, start here. Covers everything from hook setup to driving RedLog from an agent. ## Product & planning diff --git a/docs/RELEASE-SMOKE-TEST.md b/docs/RELEASE-SMOKE-TEST.md index 3648cbc5..ba909ca8 100644 --- a/docs/RELEASE-SMOKE-TEST.md +++ b/docs/RELEASE-SMOKE-TEST.md @@ -1,159 +1,81 @@ -# 發版 Smoke Test - -每次發版(含 patch)都用**打包好的安裝檔**,在**乾淨環境**跑一遍:沒裝過 RedLog、沒有 -`~/.redlog`、沒有 hook、沒裝 mitmproxy。不要用 `npm run dev`,開發環境會掩蓋打包問題 -(例如下方 EXPORT 一節的已知缺漏)。 - -順序:**Kali / Debian → Windows(PowerShell、WSL)→ macOS ARM**,最後跑 UPGRADE。 - -每一項照這個格式記,**Friction 最重要**: - -| 欄位 | 內容 | -|---|---| -| Action | 做了什麼 | -| Expected | 以為接下來會怎樣 | -| Actual | 實際發生什麼 | -| Friction | 有沒有停下來思考、查文件或進設定(無/低/高) | - -勾選框代表「照預期」。沒勾的,在表格裡記下 Actual 與 Friction,不要只寫「有 bug」。 - ---- - -## INSTALL - -- [ ] **下載檔對得上 checksum**。在下載目錄:`sha256sum -c SHA256SUMS.txt --ignore-missing` - (macOS 用 `shasum -a 256 -c SHA256SUMS.txt --ignore-missing`)→ 你下載的那個檔 `OK`。 -- [ ] **安裝**。 - - Kali:`sudo apt install ./redlog_*.deb`,沒有相依錯誤。 - - Windows:SmartScreen 選「其他資訊 → 仍要執行」。 - - macOS:拖進 `/Applications`,右鍵 ▸ 打開。 - - 以上都照 README 的說明就過得去。 -- [ ] **啟動**。從選單 / Dock / 開始功能表開(**不是**從終端機),視窗出現。 -- [ ] **專案選擇畫面乾淨**。第一次啟動只有專案選擇畫面 —— 沒有浮動的就緒卡,沒有舊版 hook 橫幅。環境檢查在開專案後的首次執行畫面上。 -- [ ] **故意少一個**。需要哪些工具**依平台而異**(`src/core/runtime-preflight.ts`),照自己測的平台選: - - **Linux / macOS**(zsh、bash hook):環境檢查列出 python3、curl、你的 shell、mitmproxy。移除或改名 python3,開一個專案,首次執行畫面的「指令」點名 python3,並說明內建終端**仍可開啟、畫面輸出仍會記錄,但指令不會進時間軸**,直到裝好 python3 與 curl(#209)。**不應**出現「內建終端仍可記錄指令」這類說法。 - - **Windows**(PowerShell hook):只列 PowerShell 與 mitmproxy。**python3 與 curl 在 Windows 原生不是需求**,把 Store 版 python3 從 PATH 拿掉卡片也**不會**提到它——這是預期,不是漏報(#257)。要看這一項,改成移除或改名 `mitmdump`;python3/curl 的預期只在測 WSL 或 Git Bash 裡的 zsh/bash 時適用。 - - 裝回去之後**切到別的視窗再切回來**,畫面自己更新,不需要按任何按鈕。 - - 失敗時查:`src/core/runtime-preflight.ts`、`FirstRunView.tsx`、`hooks/useRevalidateOnFocus.ts`。 -- [ ] **PATH(macOS/Linux)**:先 `uv tool install mitmproxy`,再從 Dock / 選單開 app 並開專案,HTTP 擷取自動啟動。 - - 失敗時查:`src/main/login-path.ts`、`src/core/http-autostart.ts`。 - -## PROJECT - -> 建立卡現在只問專案名稱(另有「匯入設定檔」),範圍與排除目標改由 設定 ▸ 範圍 管理(`test/project-picker-create-card.test.tsx`)。下面前三項仍照舊寫成「在建立卡貼上」,**要改在 設定 ▸ 範圍 做**;預期結果(無效項被標出、留空的說明)尚未對照 ScopePage 重新確認,下次跑 smoke 時順手核對並更新這幾行。 - -- [ ] **建立專案時貼上範圍**。範圍貼 `10.10.11.0/24, *.corp.local, bad$host`: - - `bad$host` 當場被標成無效; - - 建好後,設定 ▸ 範圍 只列出前兩筆。 -- [ ] **範圍留空也能建立**,卡片顯示「無法判斷越界」的說明。 -- [ ] **排除目標**:貼一個 IP,建好後出現在排除清單。 -- [ ] **建立卡只問名稱**:建立專案的卡片**沒有**「忽略本機流量」勾選項(70438fe9 刻意移除,範圍由 設定 ▸ 範圍 管理)。建好後 設定 ▸ 範圍 的個人流量是預設值 `127.0.0.0/8`、`::1`、`localhost`;自己的 IP 要在那裡手動加(#253)。 - -## BUILTIN TERMINAL - -- [ ] 新專案開在「讓這場開始被完整記錄」畫面,右側並列「指令與終端」與「HTTP(S)」,兩項都是「○ 尚未驗證」;沒有「Web/主機/兩者」選項,HTTP(S) 不需先跑指令就能開始。 -- [ ] 在內建終端執行 `echo first-test`: - - 「指令與終端」變成「✓ 已驗證」,並寫明外部終端尚未接上; - - 「HTTP(S)」仍是「○ 尚未驗證」。 -- [ ] 按「稍後處理」離開 → 儀表板「核心擷取」列顯示 HTTP(S) 尚未完成。 -- [ ] 時間軸出現一筆 `command_start` 和一筆 `command_end`,結束碼是 0。 -- [ ] 專案目錄的 `casts/` 下有 `.cast` 檔(`~/.redlog/projects//casts/`),逐字稿看得到輸出。 - -## EXTERNAL SHELL - -- [ ] **按「記我的 Zsh/Bash/PowerShell 終端」**: - - 畫面顯示 `echo redlog-ok-xxxx`; - - `~/.zshrc`(或 `~/.bashrc`、`$PROFILE`)多一行 source,而且**只多一行**。 -- [ ] **沒開新分頁就貼**:在**已經開著**的終端貼上,不會通過驗證。60 秒後列出原因,第一條是「開一個新的分頁」。 -- [ ] **開新分頁貼上** → 顯示「✓ … 已連線」。 -- [ ] **(#159 合併後)** 已連線的畫面寫明「不含輸出」,並提供 `redlog-session`(PowerShell 則是建議內建終端或 Windows 輸出 pack)。 -- [ ] 在外部終端執行 `pwd`、`whoami`、`nmap -sV 127.0.0.1`,三道指令都進時間軸,而且有結束碼、耗時、工作目錄。 -- [ ] **比較三種記錄方式**。在外部終端依序: - - 直接跑 `nmap -sV 127.0.0.1`:只有中繼資料,沒有輸出; - - `redlog-run nmap -sV 127.0.0.1`:有輸出; - - `redlog-session`,然後跑同一道指令,再 `exit`:有輸出。 - - 記下:你**直覺**以為第一種會不會記到輸出? -- [ ] **輸出驗證(#218)**:連線後的畫面有「確認輸出有被留下」: - - 直接貼 `printf '%s-%s\n' redlog-out <碼>`(不加 redlog-run)→ 顯示「只收到指令本身」; - - 貼畫面給的 `redlog-run printf …` → 顯示「✓ 輸出已記錄(經由 redlog-run)」; - - 在 `redlog-session` 裡跑同一個 printf → 顯示「經由 redlog-session」,搜尋 `redlog-out-<碼>` 找得到。 -- [ ] **tmux 與長 session(#218,需真機)**。每一項記下 Action / Expected / Actual: - - 先 `redlog-session`,裡面開 `tmux`:新 pane 的輸出有被錄到(外層 PTY 看得到整個畫面);在 pane 裡再跑 `redlog-session` 會被拒絕並說明原因(避免重複錄製)。 - - 反過來:在 tmux **裡**的每個 pane 各自跑 `redlog-session`:各 pane 分開錄;detach 後 attach 回來,錄製不會中斷或重複。 - - `ssh`、`nc` 互動 session 在 `redlog-session` 裡:遠端輸出有進時間軸;`Ctrl-C` 中斷前景程式不會結束錄製;`exit` 正常結束時有 `session_end`(含 outputBytes)。 - - 錄到一半關掉 RedLog 再開:shell 不中斷;`session_end` 或終端上的 `[redlog] omitted N bytes` 說明缺了多少,**不會**假裝補回。 - - 暫停錄製一段:`pausedBytes` 反映那段;恢復後繼續錄。 - - 用 `--max-bytes 1000` 跑大量輸出:終端照常,`truncated: true`。 -- [ ] **WSL(Windows)**:「記我的 WSL 終端」流程與上面相同,nonce 能驗證通過。 -- [ ] **破壞測試**: - - 關掉 RedLog 後打指令:不記錄(這是設計)。重開後,你能不能理解剛才為什麼沒記到? - - 關掉專案(回到專案選擇)後打指令:一樣不記錄。 - - 失敗時查:`hooks/shell-common.sh`、`RecordTerminalFlow.tsx`、`lib/terminalActivation.ts`。 - -## HTTP - -- [ ] **沒裝 mitmproxy**:首次畫面的 HTTP 卡顯示「mitmproxy 尚未安裝」和 `uv tool install mitmproxy`;裝好後按重新檢查,就能啟動。 -- [ ] **按「開始 HTTP 擷取」**(首次畫面的 HTTP 卡或 設定 ▸ Browser;標題列沒有這顆鈕),顯示正在監聽 `127.0.0.1:`。儀表板擷取健康度的 HTTP 列也要寫「擷取中」並附同一個位址,**不得**寫「未安裝」(#254)。 -- [ ] **背景流量不會讓驗證通過(#220)**:啟動擷取瀏覽器但不按驗證,等 30 秒——HTTP、HTTPS 都仍是「○ 尚未驗證」。 -- [ ] 什麼都不做 60 秒: - - 列出原因:瀏覽器、HTTPS 憑證(未就緒時才出現)、終端代理選項; - - 之後再驗證一次,仍然會變成「✓ HTTP 擷取已驗證」。 -- [ ] **按「用擷取瀏覽器驗證」**:HTTP、HTTPS 都顯示「✓ 已驗證(Chromium)」,HTTPS 附帶「擷取瀏覽器會忽略憑證錯誤」的提醒;時間軸與 HTTP 頁**沒有** `redlog.verify.invalid` 的紀錄。 -- [ ] **終端驗證(CA 未信任)**:在內建終端跑畫面上的 HTTPS 指令 → curl 失敗,HTTPS 列顯示「有 client 拒絕了 RedLog 的憑證」。 -- [ ] **信任後再驗證**:跑「HTTPS 憑證」裡的信任指令,按「重新驗證」後再跑 HTTPS 指令 → 「✓ 已驗證(curl/…)」,沒有提醒。 - - Windows 的 HTTPS 指令是 `curl.exe -sS --ssl-no-revoke -x …`。`curl.exe` 走 Schannel,會檢查憑證是否被撤銷,而 mitmproxy 簽的 leaf 憑證沒有 CRL/OCSP,少了這個旗標即使 CA 已受信任也會回 `000`(#256)。它只關掉撤銷檢查,**不是** `-k`,信任鏈仍會驗證;**不應**在指令裡看到 `-k` 或 `--insecure`。 -- [ ] **移除信任**:跑移除指令,確認它以指紋(Windows/macOS)或 `redlog-mitmproxy-<指紋>.crt`(Linux)移除;另一個工具的 mitmproxy CA(若有)仍在。重新驗證 HTTPS 指令應再次失敗。 -- [ ] **打開一個 HTTP 網站**,HTTP 頁出現這筆往返(有 request 和 response)。 -- [ ] **終端走代理的選項**: - - 關閉時:內建終端的 `curl http://example.com` 不進 HTTP 頁; - - 打開後**新開**內建終端,再 `curl` 一次:會進 HTTP 頁; - - `nmap` 永遠不會進 HTTP 頁。確認 UI 沒讓你以為「所有網路流量都會被擷取」。 -- [ ] **HTTP 流量走 Burp 時**:記下 RedLog 看得到什麼、看不到什麼,以及 UI 有沒有講清楚。 - -## SEARCH - -- [ ] 搜尋頁搜 `whoami` → 找到外部終端那筆指令。 -- [ ] 搜剛才瀏覽的網域 → 找到 HTTP 那筆。 -- [ ] 對範圍內的一個 lab 目標跑 `nmap`,目標頁出現那個 IP(從指令自動編目),點進去看得到相關證據。 - (不要用 `127.0.0.1`:它在預設的個人流量裡。) - -## EXPORT - -- [ ] 標題列「匯出 ▸ 證據包(含驗證器)」: - - 預覽列出事件總數、包含數與丟棄數; - - 按執行,產出一個資料夾。 -- [ ] 資料夾裡有 `manifest.json`、`manifest.sha256`、`events.jsonl`、**`redlog-verify.py`、`verify.sh`、`verify.cmd`、`README.md`**。 - - v0.17.0 以前的版本會少掉後面四個檔:打包版沒有附上 `tools/redlog-verify.py`。v0.17.1 已修正(#157), - 現在找不到驗證器時,匯出會直接失敗並說明原因。 -- [ ] 在資料夾裡跑 `bash verify.sh`(Windows 用 `verify.cmd`),exit 0,並回報 chain 完整。 -- [ ] 改掉 `events.jsonl` 的任一個字元再驗證 → 失敗(exit 不是 0)。 - -## UPGRADE(從 v0.16 以前升級) - -準備:在乾淨的帳號,於 `~/.zshrc` 加上 `source ~/.redlog/shell-preexec-hook.sh`,然後安裝新版。 - -- [ ] 啟動後出現舊 hook 的提示,並指出是哪個檔案。 -- [ ] 按一下更新: - - 產生 `~/.zshrc.redlog-bak-<時間戳>`,內容就是更新前的原檔; - - 舊的那行被移除,新的 source 行只有**一行**。 -- [ ] 開新分頁 → 用 nonce 驗證通過。 -- [ ] 重開 app → **不再**出現提示。 -- [ ] **故意讓遷移失敗**:先把 `~/.zshrc` 改成唯讀再按更新 → - - 顯示失敗原因; - - `.zshrc` 內容不變,沒有寫出一半的檔(可能會留下一份備份檔,這沒關係)。 -- [ ] **pack 設定**:用舊版開過剪貼簿或 AI agent 監聽的專案,升級後照 CHANGELOG「Upgrading」的說明在 設定 ▸ 擷取 打開 pack,就恢復記錄。 -- [ ] **剪貼簿要另外勾選(#224)**:新專案打開「主機監看」pack → 行程/連線/檔案監看開始記錄,剪貼簿**沒有**;儀表板的剪貼簿列顯示關閉。勾選剪貼簿後才開始記錄。 - -## 真實工作流(30–60 分鐘,每個大版本一次) - -在 lab 或 HTB 正常操作:recon、nmap、ffuf、curl、ssh、瀏覽器、代理、戰利品、截圖、標記、搜尋、匯出。 - -只記錄一件事:**RedLog 有沒有逼你停下手上的工作去照顧它?** - -例如: -- 我是不是要去看擷取健康度? -- HTTP 擷取還開著嗎? -- 這個輸出有沒有被記到? -- 我是不是該用 `redlog-run`? - -結束後,回頭找三件你一小時前做過的事,每件記下花了多久才找到。 +# 安裝包人工驗收 + +這份清單補足自動測試碰不到的下載、桌面啟動、系統信任提示與操作摩擦。 +自動測試入口見 [TESTING.md](TESTING.md),既有結果與未測範圍見 +[PACKAGED-SMOKE.md](PACKAGED-SMOKE.md)。不要把原始碼測試當成正式下載包驗收。 + +每次記錄版本、下載 URL、檔案 SHA-256、OS/架構、shell、工具版本與日期。 +每項填 Action / Expected / Actual / Friction,以及 PASS / FAIL / NOT TESTED; +失敗保留原始輸出及畫面。未操作、被略過或前置條件不足均不算 PASS。 + +## 1. 下載與安裝 + +- [ ] 從正式 Release 下載與平台相符的安裝包,對照 SHA256SUMS.txt。 + Windows 使用 NSIS、Kali/Debian/Ubuntu x64 使用 DEB、macOS ARM 使用 DMG。 + 不再驗收或提供 portable/AppImage。 +- [ ] 在乾淨使用者或測試 VM 安裝,記錄所需步驟與系統提示。 + Linux 用 `sudo apt install ./redlog__amd64.deb`;Mac 拖入 Applications。 +- [ ] 從開始選單/應用程式選單/Dock 啟動,記錄真實 Gatekeeper、SmartScreen + 或權限提示。從未隔離的建置目錄直接啟動不能替代這一步。 +- [ ] 第一次畫面無別人的專案、來源、憑證或 hook。安裝流程不改既有個人設定。 +- [ ] 分別計時「安裝開始到第一份可驗證的記錄」與「接入後 100 道指令」; + 不把後者當成前者,不把工具已預裝的環境當成缺依賴情境。 + +## 2. 建立專案與擷取設定 + +- [ ] 以名稱建立專案;在 Settings → Scope 設定測試 CIDR、exclude 和私人網域。 + 目前建立卡不輸入 scope,排除是顯示/交付規則,不是停止本機記錄。 +- [ ] 開啟 Capture setup。缺少相關工具時先 Prepare,再 Prove; + 完成工具安裝或按略過不能直接變成「已驗證」。 +- [ ] 在隔離環境的 PATH/測試帳號模擬缺依賴,不移除主機的全域 Python/curl。 + 按準備、取消、重試,確認等待時間、失敗原因與可執行的手動修復。 +- [ ] 離開設定、重新整理、關閉 App,確認只清理它啟動的安裝程序,無關程序仍存活。 +- [ ] 略過後從 Dashboard → Capture Health → Capture setup 重新進入; + 即使已有事件或重啟過也能找到,未驗證項目仍保持未驗證。 +- [ ] 在 800×720 視窗用鍵盤完成操作,錯誤及修復按鈕不被裁切。 + +## 3. 指令與輸出 + +- [ ] 在內建終端立即輸入第一道指令,確認指令、stdout/stderr、退出碼及 cast。 + 提示符先出現不能當成 hook 已接上;不能靠人工等待掩蓋第一筆遺漏。 +- [ ] 安裝外部 shell hook 兩次只留一行;開新互動終端再做 canary。 + 驗證結果要區分「指令有到」及「輸出有到」。 +- [ ] 用普通 Zsh 執行 echo、printf、多行、stderr、失敗指令、pipeline 和 redirect, + 逐一對照終端、事件及輸出。Zsh 自動輸出有界;Bash/PowerShell 普通 hook + 主要記指令/退出資訊,輸出改測內建終端或對應明確錄製方式。 +- [ ] `redlog-session` 長互動錄製、Ctrl-C、Ctrl-Z/fg、終端 resize 能恢復; + 區分 native nc 不擷取輸出與明確 PTY 錄製,不把刻意未錄當作完整輸出。 +- [ ] 100 道不同指令逐筆核對數量、stdout、stderr、退出碼及重複事件。 + cap、暫停、工具失敗及離線皆須留下正確的完整性/缺口狀態。 +- [ ] App 停止、恢復或重啟時 shell 仍可用;離線 spool 不覆寫、不進錯專案, + 身分不明的資料保留隔離。重啟後能找回已保存輸出。 +- [ ] Windows PowerShell 與 Windows App+原生 WSL 分開驗收; + Linux 或模擬路徑的結果不代替原生 WSL。 + +## 4. HTTP/HTTPS + +- [ ] App 準備並啟動自己的代理;程序存在或埠有 listener 不足以顯示 running。 + 遇到外來 listener/啟動失敗有明確原因與 retry,不終止外來程序。 +- [ ] 從受管瀏覽器或明確設好的終端送出真正測試請求,核對 request、response、 + status、headers、body 與 flow_id。開代理不代表所有工具或所有協定會自動經過它。 +- [ ] HTTP、HTTPS 各完成本次 nonce 驗證;略過憑證錯誤不算信任 CA。 + 測試用暫存 CA 與系統/瀏覽器信任變更分別記錄,只清理測試自己安裝的項目。 +- [ ] 停止代理後狀態反映停止或失敗;記錄既有終端仍帶代理環境時的實際限制。 + +## 5. 證據交付與隔離 + +- [ ] 截圖、拖入檔案、標記均能找到原始附件,空截圖不當成有效證據。 +- [ ] 兩個專案/多個 pane 同時有工作:切換後 PID/cwd/輸出仍存在, + 錄製片段與附件各屬自己的專案;停止一個 pane 不停止另一個。 +- [ ] 套用時間/目標/類型等篩選後預覽並匯出,核對實際事件、數量及逐檔附件。 + 預覽後修改附件不得悄悄交付不同內容。 +- [ ] 包含 loopback/scope/私人內容排除的交付副本,以隨包 verifier 驗證。 + 正常投影要明說省略及驗證邊界;修改一列或附件必須報錯。 +- [ ] 重啟 App 仍能搜尋記錄、讀輸出及再次匯出。卸載測試 hook 後恢復自己的 + profile;移除測試安裝,但不要把刪除使用者證據當成卸載通過條件。 + +## 結果 + +每平台獨立列結果與剩餘摩擦,不設「全部已完成」的預填勾選。 +原生 WSL、完整人工 Kali quickstart/首次安裝計時,以及 Mac 真正 quarantine +下載/Dock/Developer ID/公證,在沒有新證據前仍是未完成的驗收或發布限制。 diff --git a/docs/RELEASE-v0.18.3.md b/docs/RELEASE-v0.18.3.md new file mode 100644 index 00000000..d5990336 --- /dev/null +++ b/docs/RELEASE-v0.18.3.md @@ -0,0 +1,38 @@ +# RedLog v0.18.3 + +本版以「安裝後能開始記錄,並能核對交付證據」為主。 + +v0.18.1 未發布安裝包;v0.18.2 發布後,後續 macOS CI 仍抓到一筆時序漏記,已改列預發行並標明限制。本版修復該視窗,保留前兩版原始標籤與失敗證據。 + +## 安裝 + +- Windows x64:`RedLog.Setup.0.18.3.exe`。 +- Kali/Debian/Ubuntu x64:`redlog_0.18.3_amd64.deb`,以 `sudo apt install ./redlog_0.18.3_amd64.deb` 安裝。 +- macOS Apple Silicon:`RedLog-0.18.3-arm64.dmg`,拖入 Applications。 +- 每份安裝包都列在 `SHA256SUMS.txt`。macOS ZIP 保留為配套封裝;一般安裝使用 DMG。 +- 本版起不再發布 Windows portable 與 Linux AppImage。其他 Linux 發行版可從原始碼建置。 + +## 主要改善 + +- 安裝與驗收文件改用目前實際操作路徑,統一測試入口,移除重複或過期測試;錄製與證據完整性要求保留。 + +- macOS 自動 Zsh 擷取會先讀完排隊的 stdout/stderr,再關閉 PTY 寫入端,避免短指令完成太快而漏記輸出;完成訊號之後會重新檢查排隊資料,不使用較早的空輪詢結果。 + +- 擷取設定可從 Dashboard → Capture Health → Capture setup 重新進入,略過設定不會被當成已驗證。 +- 缺少工具時可準備、重試或取消。慢下載有十五分鐘上限與已等待時間;離開、重新整理或關閉 App 會清理自己的安裝程序。 +- 內建終端先載入擷取 hook,再接受輸入,修正第一道指令可能漏記。 +- 切換專案保留執行中的終端及工作,各專案使用獨立錄製片段;停止某個 pane 也停止其原始錄影。 +- 修正離線 spool 碰撞、身分歸屬與 PTY 暫時 EOF/EIO 造成的遺漏。 +- HTTP 必須收到本次受管 addon 的私有就緒回應才顯示啟動。HTTP/HTTPS 仍分別以實際請求驗證。 +- 篩選匯出固定選取事件與附件內容;證據包驗證器能區分交付投影與完整來源鏈。 + +## 驗證與限制 + +合併前的 Windows NSIS、Linux x64 DEB 與 macOS ARM 安裝副本已跑過記錄、輸出、匯出驗證及重啟流程;正式發版另以本標籤建置並檢查資產版本、完整性與更新資訊。 + +- macOS 為 ad-hoc 封裝簽章,沒有 Developer ID 或 Apple 公證;Gatekeeper 可能阻擋。Windows 可能出現 SmartScreen 提示。 +- 原生 WSL、完整人工 Kali quickstart/首次安裝計時、macOS 真正 quarantine 下載/Dock 啟動尚未完成驗收;不宣稱所有平台流程已驗證。 +- 外部 Zsh 可自動留下有界輸出;Bash/PowerShell 的普通 hook 主要記錄指令與退出資訊,輸出請使用已驗證的內建終端或對應錄製方式。 +- 來源狀態、工具安裝成功與完整流量覆蓋是不同的事;請完成 Capture setup 的指令、輸出及 HTTP/HTTPS 驗證。 + +詳細變更見 CHANGELOG,驗收邊界見 `docs/PACKAGED-SMOKE.md`。 diff --git a/docs/TESTING.md b/docs/TESTING.md index 795d0426..694962ee 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -1,823 +1,88 @@ -# RedLog Test Matrix +# Testing RedLog -Every configurable option, every value that behaves differently, and where each -one is proven. If you change a default, add a field, or touch the alert path, -this is the file that says what the change is allowed to break. +Use the smallest test layer that proves the behavior. The current suites are +`test/` (Vitest) and `e2e/` (Playwright/Electron); installed-package journeys +reuse `e2e/` against an actual executable. Historical results are not new runs. -``` -npm test # the whole vitest suite (unit + renderer) -npx vitest run test/ # one file -npx vitest # watch mode -npm run e2e # Playwright, needs a built app -npm run typecheck # renderer types -``` +## Commands -**After an e2e run, `npm test` will fail to start.** `pree2e` points -`node_modules/.bin/node-gyp` at `@electron/node-gyp`, so `pretest`'s -`npm rebuild better-sqlite3` then builds the native module against Electron -headers and vitest cannot load it (`Could not locate the bindings file`, even -though the file is right there — it is the ABI that is wrong). Recover with: +Run the repository gates in this order before pushing: +```sh +git fetch origin main +npm run typecheck +npm run verify:specs +npm run verify:architecture +npm run verify:i18n +npm test +npm run build ``` -rm -rf node_modules/better-sqlite3/build -(cd node_modules/better-sqlite3 && ../../node_modules/node-gyp/bin/node-gyp.js rebuild --release) -``` - -## How to read a row - -| Column | Meaning | -|---|---| -| **Value** | the literal a config file can hold, including the junk values | -| **Behaviour** | what RedLog does with it — stated as an observable outcome | -| **Proof** | vitest file that asserts it, `e2e` for Playwright, `manual` for §5, `—` for an open gap | -Proof names drop the `test/` prefix and the `.test.ts(x)` suffix. - ---- - -# Part 1 — The alert path - -The one sequence that has to be right: an address or a target is observed, a -verdict is computed, and the verdict reaches the operator's eye. RedLog never -blocks (`ALERT-ROLES.md` Part D), so a verdict that is wrong or invisible is the -whole defence failing — there is nothing downstream to catch it. - -``` -egress IP ──► IPSignalProducer ──► IPPolicy ──► verdictToSafety ──► HUD + card + status bar -command ──► ingest ──► classifyScope ──► alert floor ──► scope_violation row ──► status bar + ScopeStatus +`npm test` rebuilds SQLite for Node, then runs Vitest. `npm run e2e` rebuilds it +for Electron. Do not run these concurrently. Use `npm test` to request a Node +rebuild again. If the binding still fails, check the rebuild log, node-gyp +selection and headers before changing product code or skipping database tests. + +| Purpose | Command | What it proves | +| --- | --- | --- | +| One behavior | `npx vitest run test/.test.ts` | The selected domain, storage or rendered behavior; native modules must already use the Node ABI. | +| Local iteration | `npm run test:watch` | Vitest watch mode; same ABI requirement. | +| Source app journey | `npm run build` then `npm run e2e -- e2e/.spec.ts` | Real Electron/IPC interaction with isolated test homes. | +| Whole source journey suite | `npm run e2e` | All source journeys; explicit platform/installed-only skips remain skips. | +| Package contents/signature | `npm run verify:package-resources` after packaging | Shipped adapters, verifier, manifests and macOS resource seal. | +| Installed app | Set `REDLOG_PACKAGED_APP` and run the relevant `e2e/packaged-*.spec.ts` | The actual installed executable and shipped resources. | +| Strict installation result | `node scripts/verify-smoke-report.mjs ` | Every selected test passed, with no skip, flaky result or `not-exercised` annotation. | +| Release asset contract | `RELEASE_TAG=v node scripts/verify-release-assets.mjs ` | All versioned installers and Windows updater metadata match the actual files. | + +For an installed smoke, keep the JSON report outside Playwright's output folder +so a following invocation cannot clear it: + +```sh +REDLOG_PACKAGED_APP='/absolute/path/to/RedLog' \ +PLAYWRIGHT_JSON_OUTPUT_NAME='/absolute/path/to/smoke.json' \ +npx playwright test e2e/packaged-smoke.spec.ts --reporter=list,json +node scripts/verify-smoke-report.mjs /absolute/path/to/smoke.json ``` -## 1.1 IP verdict matrix — which list wins - -Two lists, each independently set or unset, and an address that may hit either. -Nine reachable cells. - -Five verdicts, because the two lists answer different kinds of question. Three -are **observations** and one is an **inference**, and §3 says an inference may -never wear a fact's colour: - -| Verdict | Authority | Means | -|---|---|---| -| `safe` | fact | on an address you declared safe | -| `exposed` | fact | on an address you declared to be *you* | -| `off_profile` | fact | you declared an expectation and this is **outside** it | -| `presumed_safe` | **inference** | "not obviously you" — a blacklist is set and this missed it | -| `unknown` | — | nothing declared; there is no question to answer | - -| # | whitelist | blacklist | address hits | Verdict | Why | -|---|---|---|---|---|---| -| A-1 | — | — | — | `unknown` | nothing declared, nothing to judge against | -| A-2 | — | set | blacklist | `exposed` | your own IP is showing | -| A-3 | — | set | neither | `presumed_safe` | an inference, and rendered as one | -| A-4 | set | — | whitelist | `safe` | on a declared exit | -| A-5 | set | — | neither | `off_profile` | an observed deviation from a declared expectation | -| A-6 | set | set | both | `exposed` | config conflict resolves to the alarming side | -| A-7 | set | set | blacklist | `exposed` | identity leak is never masked | -| A-8 | set | set | whitelist | `safe` | | -| A-9 | set | set | neither | `off_profile` | **the VPN-dropped-onto-café-NAT case** | - -Proof: `alert/ip-policy`, one `it` per verdict rather than per row. A-1, A-3, -A-4, A-5 and A-6 each have a case; A-7 and A-8 are asserted in passing by its -dedup case. A-2 (blacklist only, hit) and A-9 (both set, neither hit) are -asserted by no test (`—`). - -Two fixes, and what still pins them: - -- **G-A1** — A-9 answered `safe`, because the blacklist-only shortcut ran even - after a configured whitelist had already missed. The policy checks the - whitelist miss first (`IPPolicy` in `src/core/alert/policies.ts`), but no - test pins it any more: `ip-monitor.test.ts` did, and was deleted with the - old `IPMonitor` in v0.12.0 (`b69502b`) (`—`). -- **G-A2** — A-3 answered `safe` (an inference in a fact's solid green) and A-5 - answered `unknown`, which understated an observed deviation into the same - amber bucket as "nothing is configured at all". Both now have their own - verdict at the policy (`alert/ip-policy`). The screen still folds them: - `presumed_safe` renders as `safe` and `off_profile` as `exposed` (§1.8). - -## 1.2 Range matching — `network.whitelist` / `network.blacklist` - -| Value | Behaviour | Proof | -|---|---|---| -| `10.8.0.0/24` | matches `10.8.0.0` through `10.8.0.255`, including network + broadcast | `—` | -| `10.8.0.0/24` vs `10.8.1.0` / `10.7.255.255` | one address either side is outside | `—` | -| `172.16.0.0/12` | matches up to `172.31.255.255`, not `172.32.0.1` | `—` | -| `203.0.113.42/32` | single host | `—` | -| `0.0.0.0/0` | matches every IPv4 address | `—` | -| `203.0.113.42` (no mask) | exact equality, never a prefix — `203.0.113.4` does not match | `alert/ip-policy` (the exact hit); `—` (the prefix case) | -| `2001:db8::1/64` | **never matches any IPv6 address**, `2001:db8::1` included: `IPV6_RE` rejects the `/`, so the family check fails before the exact-address fallback `matchesCIDR` means to run (G-IP1) | `—` | -| IPv4 CIDR vs IPv6 address | family mismatch never matches. The reverse does not hold: an IPv6 CIDR with a prefix of 32 or less is read as IPv4 arithmetic, so `2001:db8::/1` can match an IPv4 address (G-IP1) | `—` | -| multiple entries | a hit on any one entry is a hit | `—` | -| `[]` (default) | list is not configured → see A-1/A-3/A-5 | `config-options` (the default), `alert/ip-policy` (the unconfigured verdicts) | - -The blacklist is evaluated first and wins outright; a whitelist that is set but -missed answers `off_profile` — never `safe`, and never the same `unknown` that -"nothing configured" gets (`alert/ip-policy`). The matcher itself, -`matchesCIDR` in `src/core/alert/policies.ts`, is private and asserted by no -test; `scope-evaluator` tests a different one (`matchPattern`). - -## 1.2.0 The internal address is reported, not judged - -There is no `network.lanProfile` option and no LAN verdict. The IP producer -reads the internal address on every check, and main's 20 s link poll pushes the -link into it (`link`: the Wi-Fi SSID or wired, via `setLink()` in -`src/main/index.ts`). Both ride on every signal and are displayed, but nothing -classifies them: `IPPolicy` is configured with the whitelist and blacklist only -(`src/main/services/alert-runtime.ts`) and reads neither `internal` nor `link` -(`src/core/alert/policies.ts`). The "`lanProfile` verdict pathway" named in -`src/core/alert/signal.ts` was not built. So a laptop that silently reassociated to a guest SSID reads exactly like -one still on the client VLAN, and the external IP can be perfectly fine while -you are on the wrong network. - -A failed external lookup still re-reads the internal address rather than -discarding it with the rejection (`read()` in -`src/main/services/producers/ip-signal-producer.ts`; `—`). A failure to read the -interfaces leaves only the internal address empty; the external read keeps its -result (`ip-signal-producer`). - -## 1.2.1 A failed read expires the verdict - -There is no `network.staleAfter` option. The first failed read marks the -reading stale (`read()` in `src/main/services/producers/ip-signal-producer.ts`): -the last stable address is kept, and `IPPolicy` answers `unknown` with -`stale: true`, whatever the lists say. - -| Case | Behaviour | Proof | -|---|---|---| -| a read fails (a provider error, every provider down, or air-gap `offline`) | `stale: true` at once; the verdict is `unknown`, never the last good answer | `alert/ip-policy` (the verdict); `ip-signal-producer` (the producer marks it) | -| a later read succeeds | the verdict is recomputed and `stale` clears | `ip-signal-producer` (`stale` clears); `alert/ip-policy` (a fresh reading is classified) | - -A dropped VPN and a dead IP provider look identical from outside the process, so -neither may keep rendering the last good answer at full confidence. The screen -does not show `stale` as its own state: the renderer receives `unknown`, and -reads neither `stale` nor `settling` (§1.8). - -## 1.3 `network.confirmations` — flap protection - -A new address is held until it has been read this many times in a row. The -displayed address, and therefore the verdict, does not change during the hold. - -| Value | Behaviour | Proof | -|---|---|---| -| `1` | promote on sight, no flap protection | `ip-signal-producer` | -| `3` (default) | two reads hold the old address, the third promotes | `config-options` (the default), `ip-signal-producer` (the hold) | -| `5` | four reads hold, the fifth promotes | `—` | -| `0`, negative | ignored: the previous value is kept (3 on a fresh producer), **not** "promote instantly" (`configure()`) | `ip-signal-producer` | -| first ever reading | taken as-is; there is nothing to flap against | `ip-signal-producer` | -| candidate changes each poll (CGNAT) | nothing is ever promoted; `settling` stays true | `ip-signal-producer` | -| old address returns mid-hold | the half-confirmed candidate is dropped | `ip-signal-producer` | - -`settling: true` is the "displayed value is the last stable read" flag. It is -set during the hold and cleared on promotion (`ip-signal-producer`). A failed -read in mid-hold also clears it, although the hold goes on (`read()` in -`ip-signal-producer.ts`; `—`). The policy carries it into the verdict as a -modifier without changing the verdict (`alert/ip-policy`). - -## 1.4 `network.ipMode` and `network.providers` - -| Option | Value | Behaviour | Proof | -|---|---|---|---| -| `ipMode` | `auto` (default) | DNS first, HTTP only if DNS fails/blocked | `config-options` (the default), `ip-signal-producer` (the fallback) | -| `ipMode` | `dns` | direct query to OpenDNS/Google resolvers, no HTTP at all | `ip-signal-producer` | -| `ipMode` | `http` | HTTP echo only, DNS never queried | `ip-signal-producer` | -| `providers` | `[]` (default) | the three built-in echo services are used | `config-options` (the default), `ip-signal-producer` (the services) | -| `providers` | custom list | tried in order; first success wins | `ip-signal-producer` | -| `providers` | first entry throws | falls through to the next | `ip-signal-producer` | -| `providers` | first entry answers non-2xx | treated as a failure, moves on | `ip-signal-producer` | -| `providers` | the answer | the address is `ip`, else `origin`, else a bare JSON string, and only when that is one IPv4 address (`addressIn()`). Anything else — another JSON shape, a comma-separated `origin`, an IPv6 address — means that provider failed, and the next is tried. IPv6 is refused because the list matcher cannot judge an IPv6 range (G-IP1) | `ip-signal-producer` | -| `providers` | every entry fails | `error: 'All IP providers failed'`, **last known address is kept**, and the reading goes stale (§1.2.1) | `ip-signal-producer` | -| `providers` | a later poll succeeds | error clears | `ip-signal-producer` | - -## 1.5 `network.checkInterval` - -| Value | Behaviour | Proof | -|---|---|---| -| unset (monitor default) | polls every 10 s | `ip-signal-producer` | -| `60` (config default) | polls every 60 s | `config-options` (the default), `ip-signal-producer` (the cadence) | -| `0` | ignored, the previous interval is kept — a zero-second poll would be a busy loop; negatives too | `ip-signal-producer` | -| `stop()` | clears the timer, idempotent | `ip-signal-producer` | -| slow poll still in flight | the next tick is skipped, not queued | `ip-signal-producer` | - -## 1.6 Scope distance ladder — `classifyScope` - -How far a target sits from declared intent (`ALERT-ROLES.md` Part B), computed -by `classifyScope()` in `src/core/scope-evaluator.ts`; `classifyScopeTarget()` -in `src/core/alert/policies.ts` adds authority and severity. The tier decides -whether an alert fires at all, so every rung needs its own row. (The titles in -`alert/scope-policy` number the rungs differently: D1 excluded, D2 subnet, D3 -domain, D4 in scope.) - -| Tier | Distance | Meaning | Alerts? | -|---|---|---|---| -| D0 | `in_scope` | hits a scope entry | no | -| D1 | `excluded` | hits an exclude entry — a **fact** | **always**, even with warnings off | -| D2 | `adjacent_subnet` | same /24 as a single-IP scope entry, or as the base address of a CIDR entry — **inferred** | only when `warnOnViolation` | -| D2 | `adjacent_domain` | same registrable domain as a scope entry — **inferred** | only when `warnOnViolation` | -| D3 | `unrelated` | no relationship to anything declared | never, and not counted: `ScopePolicy.evaluate()` drops the verdict before any surface sees it | - -D1 outranks D0: an explicit exclusion inside a broad CIDR still fires -(`scope-evaluator`, `alert/scope-policy`, both with wildcard entries; no test -has a CIDR with an excluded address inside it). - -| Case | Scope | Target | Distance | Proof | -|---|---|---|---|---| -| no scope set | `[]` | anything | `in_scope` | `alert/scope-policy`, `scope-evaluator` | -| single-IP entry expands into its /24 | `192.168.1.10` | `192.168.1.55` | `adjacent_subnet` | `alert/scope-policy`, `scope-one-classifier` | -| an address outside a /24 CIDR | `10.0.0.0/24` | `192.168.50.1` | `unrelated` | `—` | -| wildcard expands to the registrable domain | `*.staging.example.com` | `prod.example.com` | `adjacent_domain` | `—` (every adjacency test uses a bare-host entry) | -| unrelated domain | `*.staging.example.com` | `google.com` | `unrelated` | `alert/scope-policy` | -| a different /24 | `192.168.1.10` | `192.168.2.55` | `unrelated` | `—` | - -The container is always a /24 (`subnetOf` in `src/core/scope-evaluator.ts`); -there is no option to change it. The design intent was an asymmetry: a bare IP -carries no boundary, so deriving one fills a gap, while a CIDR *states* a -boundary, and widening it would invent authorisation the operator never gave. -The code does not hold to the second half for CIDRs narrower than /24: -`buildScopeIndexes` seeds the /24 of every CIDR entry's base address, so -`10.0.0.0/28` makes `10.0.0.200` `adjacent_subnet` (G-S1). A domain always -expands to its registrable domain because that is the ownership boundary the -authorisation is about — hitting `prod` while scoped to `staging` is exactly -what D2 exists for. - -## 1.6.1 Scope verdicts — `evaluateScope` - -The coarser four-value status from `evaluateScope()` in -`src/core/scope-evaluator.ts`: `in-scope`, `out-of-scope`, `excluded` or -`no-scope`. It is what the shared filter's in-scope condition uses -(`src/core/db/event-queries.ts`); D2 and D3 both collapse to `out-of-scope` -here. - -| Target | Scope | Status | Proof | -|---|---|---|---| -| `null` / `''` / `undefined` | a scope set | `out-of-scope` — there is no `unknown`; callers that must not judge an empty target check for it first | `scope-evaluator` (empty subject); `scope-sanitize`, `scope-classify` (the callers) | -| anything | no targets, no excludes | `no-scope` — no scope set means nothing is out of scope | `scope-evaluator`, `scope-classify` | -| `10.0.0.9` | `10.0.0.0/24` | `in-scope` | `scope-evaluator` | -| `10.0.1.9` | `10.0.0.0/24` | `out-of-scope` | `scope-evaluator` | -| `api.app.example.com` | `*.app.example.com` | `in-scope` | `scope-evaluator` | -| `app.example.com` | `*.app.example.com` | `in-scope` — the wildcard covers its anchor host | `scope-evaluator`, `scope-classify` | -| `vpn.example.com` | `*.app.example.com` | `out-of-scope` (same root, outside the wildcard) | `—` | -| `google.com` | `*.app.example.com` | `out-of-scope` | `scope-evaluator`, `scope-sanitize` | -| `10.0.0.1` | in `targets`, also in `excludeTargets` | `excluded` — exclusion beats the enclosing CIDR | `—` | -| `dc01.app.example.com` | in the wildcard, also excluded | `excluded` | `scope-evaluator`, `alert/scope-policy` | - -CIDR and wildcard mechanics themselves are asserted one `it` each rather than -in a table: `/0`, `/24` and `/32` and the `*.` prefix in `scope-evaluator` -(`matchPattern`), `/16` in `scope-classify`, `/8` in `scope-sanitize`, and -registrable-domain extraction in `alert/scope-policy`. - -## 1.7 `scope.warnOnViolation` — how much the operator is told - -The setting is the `scope.warnOnViolation` boolean (default `true`; the -checkbox in Settings ▸ Scope). There is no `scope.alertFloor` key. The policy -works with an *alert floor*, the list of distances that raise a violation, and -`alertFloorFor()` in `src/core/alert/policies.ts` derives it from the boolean: -`true` gives `excluded`, `adjacent_subnet` and `adjacent_domain`; `false` gives -`excluded` only. The live path (`src/main/services/alert-runtime.ts`) and the -retroactive recompute (`src/main/index.ts`) both take the floor from there. - -| Distance | `false` | `true` (default) | Proof | -|---|---|---|---| -| D0 in scope | no violation | no violation | `alert/scope-policy`, `scope-recompute` | -| D1 excluded | **violation** | **violation** | `alert/scope-policy`, `scope-recompute` | -| D2 adjacent subnet | silent | **violation** | `alert/scope-policy` (on); `—` (off) | -| D2 adjacent domain | silent | **violation** | `alert/scope-policy` | -| D3 unrelated | silent | silent | `alert/scope-policy`, `scope-recompute` | - -D1 fires either way: "keep off X" is not a preference call. D3 raises a -violation through no setting, and is not counted either: `ScopePolicy.evaluate()` -drops the verdict before any surface sees it. - -Bookkeeping: violations accumulate rather than dedupe. The in-memory log -(`ViolationLog` in `src/core/alert/surface.ts`) keeps the newest 500, each with -target, command and timestamp; it cuts the command to 200 characters, as the -chained event does, and `list()` returns a copy. The Scope page reads the chain -instead (`scope:getViolations`). A violation event is `system` / -`subtype: scope_violation` carrying `distance` (`excluded` \| `adjacent_subnet` -\| `adjacent_domain`), with `authority` (`fact` \| `inferred`) and `severity` -alongside, so downstream can tell an observation from an inference. The log and -the live event payload are asserted by no test (`—`); the recompute's rows carry -their distance (`scope-recompute-run`). The chain also gets a row for every -in-scope hit, with `distance: in_scope` but under the same `scope_violation` -subtype (G-S2). - -Whatever the tier, the command itself still lands in the timeline — only the -*alert* is suppressed, never the record: `src/core/ingest.ts` inserts the event -before it dispatches to the policies (`—`). - -`configure()` semantics: omitting `alertFloor` on a later call keeps the current -floor, and replacing `targets` rebuilds the containers used for D2 -(`ScopePolicy.configure()`). Neither is asserted by a test (`—`). - -## 1.7.1 One severity scale for both alarms - -The self alarm (IP) and the target alarm (scope) share one severity type, four -steps: `clean` < `notice` < `warning` < `critical` (`Severity` in -`src/core/alert/policy.ts`). No code ranks or folds them: the status bar draws -the IP dot and the scope count separately, and the scope side is still an -on/off light, so a D1 hit and a D2 inference look the same there. - -| Input | Severity | -|---|---| -| `exposed` / D1 `excluded` | `critical` — the thing you must not do, observed, non-silenceable | -| `off_profile` / D2 adjacent | `warning` | -| D3 `unrelated` | `notice`, though no setting lets it reach a surface (§1.7) | -| `unknown` (including stale) / `presumed_safe` | `notice` | -| `safe` / D0 `in_scope` | `clean` | - -Proof: `alert/ip-policy` and `alert/scope-policy` assert the severity of each -verdict except `unknown` and `unrelated`, and `scope-one-classifier` that every -spelling of one target gets the same severity. No surface renders severity. - -Authority (`fact` \| `inferred` \| `unknown`) travels alongside severity on each -verdict and on the chained events (`src/core/alert/surface.ts`). No surface -renders it either, and `insertEvent` stamps none of its own. On the Timeline, -`dotShape()` (`src/renderer/src/lib/timelineDomain.ts`) keys on the event type -and a marker's severity, not on authority: every scope violation is a diamond -and a critical marker a hollow ring (`e2e`: `timeline-encoding.spec.ts`). - -## 1.8 Alert display — the verdict has to be seen - -The same verdict has to be legible on three surfaces. The renderer receives -three states, not five: `verdictToSafety()` in -`src/main/services/alert-runtime.ts` shows `presumed_safe` as `safe` and -`off_profile` as `exposed`, and a stale reading arrives as `unknown`. So on -screen the two qualified verdicts do borrow a plain one's confidence (G-UI2). -No test asserts what any surface renders: the decision is `alert/ip-policy`, -and every display claim in this section is `—` unless it names a test. - -| State | Verdicts | HUD label | Dashboard card | Status bar | -|---|---|---|---|---| -| `safe` | `safe`, `presumed_safe` | `SAFE` | green dot, `Safe IP` | green dot, `SAFE` | -| `exposed` | `exposed`, `off_profile` | `EXPOSED` | red pulsing dot, `Exposed IP`, and the hint that the address is on the Exposed IP list — shown for `off_profile` too, where it is not true | red dot, `EXPOSED` | -| `unknown` | `unknown`, any stale reading | `IP?` | yellow dot, `Unknown IP`, and the hint to set the two lists — shown for a failed read too, when both may be set | amber dot, `IP?` | - -The HUD frame turns red on the `exposed` state (`#d75f63`) and stays cyan -(`#3fc7d6`) otherwise; `flashOnExposed` controls only the flash, never the -colour. The `exposed` state also opens the HUD and keeps it from collapsing -(`src/renderer/src/OverlayApp.tsx`). - -The expanded HUD pane spells the state out in words (`Safe IP — Protected`, -`Exposed IP — Not Protected`, or `Unknown IP` and the same set-the-lists hint). -A provider error is printed in the expanded pane and on the card, and the last -address stays on display: a stale reading keeps it. The status bar prints the -address after the label in every state; before the first successful reading the -HUD and card show `—` and the status bar shows no address. - -**Scope alerts:** the status bar shows `SCOPE ` with a red dot, `SCOPE OK`, -or `SCOPE —` when no scope is set. `` counts the violations that still stand -across the whole chain (`countActiveScopeViolations()` in -`src/core/scope-recompute-run.ts`), and the Scope & Evidence view shows the same -number as "violations still standing" (`scope:getViolationCount`). The view -distinguishes `NOT SET` (with the hint that fixes it) from `ACTIVE`, and says -"all commands within scope" only when nothing stands and nothing was recorded. -It lists the standing violations with target, command and time, 200 at a time -with a shown / total footer. Withdrawn ones sit behind "Show withdrawn", which -appears only while at least one violation still stands. There is no Export -button on it. The list is read from the newest 500 violation records -(`queryScopeViolationRows()`, same file): in-scope rows and records a newer one -replaced are left out before the cut, and the page says so whenever the window -is cut. Proof: `scope-recompute-run` for the read model; `scope-status` for the -count, "all commands within scope" and the truncation note; `e2e` -(`scope-recompute.spec.ts`) for the recompute banner, the "judged later" tag on -rows a recompute flagged, and withdrawn rows kept rather than deleted. The rest -is `—`. - -**Recording vs capture health:** the status bar's recording control has a dot -and a label. Paused: a grey dot and `PAUSED`, which turns amber, pulses and -shows the minutes after five minutes. Recording: the label reads -`Waiting for events…` before the first event and whenever capture is `dark`, -and `REC` otherwise. The dot pulses red on a healthy or not-yet-reported -capture, pulses amber on `partial`, and stays amber and still on `dark`. Proof: -`e2e` (`recording-flow.spec.ts`) asserts `PAUSED` and `Waiting for events…`; -the `REC` label and the dot colours are `—`. - -**Live updates:** the open HUD subscribes to all five overlay settings -(`showMark`, `flashExposed`, `scale`, `emphasizeIp`, `passThrough`), and each -takes effect without a restart. A pass-through opacity of `0` is ignored on the -live message as well as on the config read; a live `scale` of `0` is clamped to -0.75 rather than ignored. `—`. - ---- - -# Part 2 — Config option matrix - -Defaults below are asserted one-per-`it` in `config-options`, so a changed -default fails a named test rather than a distant integration. - -## 2.1 `engagement` / `operator` - -| Option | Default | Behaviour | Proof | -|---|---|---|---| -| `engagement.id` | `default` | stamped on every event | `config-options` | -| `engagement.activeTarget` | `null` | the operator's current target: canonical ingest labels shell, marker and screenshot rows that carry no target of their own with it, and never overwrites an observed one (SPEC-target-identity) | `ingest` | -| `operator.id` | `operator-1` | attribution; no operator = no capture (see 2.5, 2.9) | `config-options` | -| `operator.name` | `Operator` | display only | `config-options` | - -There is no `engagement.name`: the project's name is the one name, shown by the -title bar and the picker, and Settings ▸ General renames the project. - -## 2.2 `network` - -| Option | Default | Values | Behaviour | Proof | -|---|---|---|---|---| -| `whitelist` | `[]` | IPs / CIDRs | §1.1–1.2 | `config-options` (the default), `alert/ip-policy` (the verdicts) | -| `blacklist` | `[]` | IPs / CIDRs | §1.1–1.2, wins over whitelist | `config-options` (the default), `alert/ip-policy` (the verdicts) | -| `checkInterval` | `60` | seconds | §1.5; the Settings field turns junk, and `0`, into 60 (`parseInt(v) \|\| 60` in `NetworkPage.tsx`) | `config-options` (the default), `ip-signal-producer` (the cadence); `—` (the field) | -| `providers` | `[]` | URLs | §1.4 | `config-options` (the default), `ip-signal-producer` (§1.4) | -| `confirmations` | `3` | ≥1 | §1.3; the Settings field clamps to ≥1 and turns junk into 3 | `config-options` (the default), `ip-signal-producer` (§1.3); `—` (the field) | -| `ipMode` | `auto` | `dns` \| `http` \| `auto` | §1.4 | `config-options` (the default), `ip-signal-producer` (§1.4) | -| `offline` | `false` | bool | air-gap: RedLog makes no outbound requests of its own — OpenTimestamps anchoring, NTP, the update check and the external-IP lookup. The IP verdict reads `unknown`, with the internal address still shown. Capture and the local API are unaffected | `ip-signal-producer` (no external-IP lookup, the reading stale); `—` (the rest) | -| `showWifiName` | `false` | bool | off drops the SSID from the link before it reaches any surface, keeping the link **type** (the UI renders a generic "Wi-Fi"); on shows it. The toggle shows on every platform; on macOS it also asks for Location Services, since the OS redacts the SSID without it. Turning it off applies immediately rather than at the next 20 s poll | `network-link-display` | -| `vpnAdapters` | 12 built-ins, all enabled | `{name, pattern, enabled}` | patterns are user regexes matched case-insensitively against interface names | `config-options` (the default); `—` (the matching) | - -`vpnAdapters` detail (`setVpnAdapters()` and `detectVpnInterfaces()` in -`src/main/services/opsec-state.ts`): the result feeds the OPSEC state events of -the 30 s poll there, not the IP verdict. A disabled adapter matches nothing; an -empty list means no interface is ever VPN; several adapters can match and the -interface list is sorted; an interface with no non-internal address is skipped; -**a malformed regex is dropped instead of throwing**, so one bad pattern cannot -take the OPSEC poller down; re-configuring replaces the pattern set. The shipped -patterns (`DEFAULT_VPN_ADAPTERS` in `src/core/config.ts`) match `wg0`, `tun0`, -`tap0`, `tailscale0`, `nordlynx`, `proton0`, `utun4`, `ipsec0` and `ppp0`, and -none of them matches `en0` / `eth0` / `wlan0`. No test runs any of this (`—`). - -There is no `staleAfter` option: the first failed read marks the verdict stale, -and it reads `unknown` until a read succeeds (`alert/ip-policy`). - -## 2.3 `scope` - -| Option | Default | Behaviour | Proof | -|---|---|---|---| -| `warnOnViolation` | `true` | the alert floor (§1.7): `true` alerts on excluded targets and on the adjacent rungs, the same /24 and the same registrable domain; `false` alerts on excluded targets only. Unrelated traffic never alerts. There is no `alertFloor` key: the floor is derived from this (`alertFloorFor`) | `alert/scope-policy` | -| `targets` | `[]` | §1.6; empty = everything in scope | `config-options` (the default), `alert/scope-policy`, `scope-evaluator` (empty = in scope) | -| `excludeTargets` | `[]` | D1: always warns when hit | `config-options` (the default), `alert/scope-policy` (D1) | -| `personalDomains` | `127.0.0.0/8`, `::1`, `localhost` | hosts that are never part of any engagement, in the `excludeTargets` syntax. The personal-traffic filter hides them (`events`), a project created with an initial `personalDomains` appends it to the defaults rather than replacing them (`create-config-merge` — the create card no longer sends any, 70438fe9), and exports drop their rows rather than masking them (`—`) | `events`, `create-config-merge` | -| `scopeFile` | `null` | external scope document, loaded on open | `config-options` | - -Adjacency is not configurable: a single-IP scope entry's neighbours are its /24 -(`subnetOf` in `scope-evaluator.ts`), and registrable domains come from a -built-in table of two-label suffixes (`co.uk`, `co.jp`, …). There is no -`proximityBits` or `publicSuffixes` option. - -`scopeFile` parsing (`loadScopeFile`): plain text is one target per line with -`#` comments and blank lines dropped; a JSON array is read as the target list; -a missing file yields `[]` rather than throwing (`config`). Also what the code -does, but asserted by no test (`—`): a file with no extension, or any extension -but `.json`, is read as text; a JSON array keeps only its string entries; -Burp/ZAP's `target.scope` JSON is decoded; malformed JSON and an unrecognised -JSON shape yield `[]`. An unreadable scope must never take the project open -down with it. - -Burp/ZAP hold a scope host as a **regex**, so `burpHostToTarget()` decodes the -shapes those tools write into RedLog target syntax: - -| Burp host | Decoded | Meaning | -|---|---|---| -| `^example\.com$` | `example.com` | exact host | -| `\Qexample.com\E` | `example.com` | literal-quoted | -| `.*\.example\.com$` | `*.example.com` | "and subdomains" | -| `.*\Qcorp.example.com\E` | `*.corp.example.com` | same, literal-quoted | - -`\Q…\E` runs are expanded wherever they appear and however many times; a -hand-written pattern that is still regex-shaped afterwards (`(dev|prod)\.…`) is -handed back **untouched** rather than dropped or half-converted. It matches -nothing, but it stays visible in the scope list — a scope target that vanishes -is the failure with no symptom, since the operator sees a scope load -successfully and never learns which hosts are missing from it. - -## 2.4 `screenshot` - -| Option | Default | Values | Behaviour | Proof | -|---|---|---|---|---| -| `quality` | `85` | 1–100 | passed to the JPEG encoder verbatim; `0` is ignored and the previous value is kept, 85 on a fresh agent (`configure()` in `src/main/services/screenshot-agent.ts`) | `config-options` (the default); `—` | -| `intervalSec` | `0` | seconds, `0` = off | `0` and negatives schedule nothing; `30` schedules a 30 s loop; `12.9` floors to 12 s; setting it back to `0` cancels the running loop; re-configuring replaces the timer instead of stacking one | `config-options` (the default); `—` | -| `diffThreshold` | `5` | bits, `0` = off | automatic captures only: a frame whose dHash distance from the last stored frame is below this is skipped as no visible change; `0` stores every non-identical frame; manual captures ignore it | `dhash` (the distance and the threshold); `—` (which captures it gates) | -| `captureOnCommand` | `false` | bool | on captures a screenshot when a shell command finishes, linked to it by `_causes`; the perceptual dedup still applies | `—` | - -Capture gating (`captureNow()` in `src/main/services/screenshot-agent.ts`): a -periodic capture of an unchanged screen is skipped (exact-bytes then perceptual -dHash), a changed screen is captured again, and a **manual** capture always -lands — including while recording is paused, where periodic capture is -suppressed. No operator id means nothing is scheduled and nothing is captured. -Files land in `/screenshots/` and the event records trigger, size, -dimensions and sha256. `ScreenshotAgent` has no unit test (`—`); `e2e` -(`screenshot-capture.spec.ts`) asserts only that an empty screen-source list is -reported to capture health rather than dropped. - -## 2.5 `overlay` — HUD appearance - -| Option | Default | Values | Behaviour | Proof | -|---|---|---|---|---| -| `showMarkButton` | `true` | bool | `false` hides both mark buttons in the expanded pane, keeping the keep-open toggle | `config-options` (the default); `—` | -| `showInDock` | `true` | bool | macOS only: keeps a Dock icon once the HUD is shown. Stays manual — `app.dock.hide()` takes effect asynchronously via the activation policy and its result is **not observable** through `app.dock.isVisible()` in an automated Electron run (polled 5 s, still reports visible), which is also why main re-runs `applyDock()` on a timer and on overlay show | manual §5.4 | -| `flashOnExposed` | `true` | bool | `false` keeps the red frame but stops the flashing; never flashes while `safe` | `config-options` (the default); `—` | -| `scale` | `1.0` | six stops in the UI, 0.75 / 0.85 / 1.0 / 1.25 / 1.5 / 1.75; the slider snaps to them | scales type and padding; **clamped to 0.75–1.75** (`OverlayApp.tsx`). `0` and negatives are ignored when the HUD first reads the config, but a saved `0` reaches the open HUD as a live update and is clamped to 0.75 (§1.8) | `config-options` (the default); `e2e` (`hud-overlay.spec.ts`: no expanded-pane label wraps at 1.0, 1.25 or 1.5); `—` (the clamp) | -| `emphasizeExternalIp` | `false` | bool | multiplies **only** the external IP by a further 1.4×, compounding with `scale` | `config-options` (the default); `—` | -| `passThrough` | `false` | bool | dims HUD chrome and lets mouse events through (`setIgnoreMouseEvents` in `src/main/ipc/overlay.ts`). A toggle from the HUD, ⌘⇧P or the menu bar is saved as the setting. The `exposed` state turns it off for the session and makes the window fully opaque; the stored setting is untouched, so it comes back at the next project open | `config-options` (the default), `overlay-pass-through` (a runtime toggle is saved); `—` (the rest) | -| `passThroughOpacity` | `0.4` | 0.1–0.9 in the UI | applied while `passThrough` is on; `0` is ignored (an invisible HUD is worse than none). In the compact bar it dims everything but the frame and the external IP column (address and pivot), so the verdict label and dot dim with the rest. The expanded pane is dimmed as a whole, its external IP row included | `config-options` (the default); `—` | - -The external IP's font size (base 12 px), computed by `fsIp()` in -`OverlayApp.tsx` rather than measured by any test: `0.85 → 10.2`, `1.0 → 12`, -`1.25 → 15`, `1.5 → 18`, `1.75 → 21`; `99 → 21` and `0.01 → 9` (clamped); with -`emphasizeExternalIp`: `1.0 → 16.8`, `1.5 → 25.2`. - -## 2.6 `terminal` / `retention` - -Every store's retention sits under `retention.` with the same two knobs -(Spec 028): `keepDays` sweeps by age on project open, `maxBytes` evicts under -size pressure. `0` means keep forever / unbounded, and is the default for all -of them (`one-retention-model`). - -| Option | Default | Behaviour | Proof | -|---|---|---|---| -| `terminal.maxCastBytes` | `52428800` (50 MB) | a session's `.cast` is truncated at the cap with an inline marker; values ≤ 0 are ignored. A capture limit on one recording, not retention | `cast-slice` (slicing), manual §5.5 (truncation) | -| `retention.casts.keepDays` | `0` | `N` deletes `.cast` files older than N days, one `system.cast_pruned` audit event per deletion; the recording leaves the search index too | `retention` | -| `retention.screenshots.keepDays` | `0` | same shape, `system.screenshot_pruned` | `retention` | -| `retention.httpBodies.keepDays` | `0` | same shape for `http-bodies/*.body`, `system.http_body_pruned` | `retention` | -| `retention.agentTranscripts.keepDays` | `0` | same shape for agent transcript sidecars, `system.agent_transcript_pruned` | `retention` | -| `retention.casts.maxBytes` / `retention.screenshots.maxBytes` | `0` | over budget, the coldest files are evicted first; a file of an in-scope target is pinned and never evicted, even if that leaves the store over budget (reported as a shortfall) | `artifact-eviction` | -| `retention.httpBodies.maxBytes` | `0` | same model for the body store | `body-eviction-sweep` | -| `retention.loggedTier.keepDays` / `sweepIntervalHours` | `0` / `24` | row-level sweep of `events_logged`, on project open and every N hours | `retention-logged-tier` | -| `retention.bookmarks.keepDays` | `0` | deletes bookmarks older than N days; one `system.bookmarks_pruned` row with the count only | `retention` | - -Eviction and pruning delete the file, never the event: the sha256 attestation -and the chain survive, so a pruned file verifies as *pruned*, not as tampered. - -## 2.7 `clipboard` - -| Option | Default | Behaviour | Proof | -|---|---|---|---| -| *(runs with)* | `packs.hostMonitors` | with the pack off nothing is captured, or even read. Turning it off, in Settings or by opening a project with it off, stops the poll, and a save replaces the poll rather than adding one. On start the current clipboard is seeded, so pre-session content is never captured | `clipboard-monitor`, `capture-packs` (the pack's members) | -| `pollMs` | `1500` | honoured as given, **clamped up to 500 ms** | `config-options` (the default), `clipboard-monitor` | -| `storePreview` | `false` | off stores hash + length + line count and `preview: null`; on stores the first 120 characters with the spans redaction flags masked to `•` | `config-options` (the default), `clipboard-monitor` | - -What the monitor does (`src/main/clipboard-monitor.ts`): with `storePreview` -off no clipboard text is stored; with it on, the preview is the raw text apart -from the masked spans, so a secret redaction does not flag lands in clear. -Identical consecutive reads dedupe to one event. Paused recording reads -nothing, and resuming seeds what is on the clipboard then, so what was copied -during the pause is not captured (`clipboard-monitor`). Asserted by no test -(`—`): an empty clipboard produces nothing; detected credential *types* are -recorded without copying the credential; a throwing loot detector does not lose -the event; and, the flip side of seeding on resume, a copy made in the last poll -interval before the pause and still on the clipboard at resume is not captured -either. - -## 2.8 `browser` - -| Option | Default | Behaviour | Proof | -|---|---|---|---| -| `binary` | `''` | auto-detect per platform (incl. per-user Windows installs) | `browser-launcher` | -| `proxy` | `http://127.0.0.1:8080` | adds `--proxy-server` **and** `--proxy-bypass-list=<-loopback>` (RedLog's own profile adds `;*.redlog-offline.invalid`, #182); `''` omits both | `browser-launcher` | -| `cdpPort` | `9222` | `--remote-debugging-port`; `0` omits it | `browser-launcher` | -| `isolateProfile` | `true` | `--user-data-dir` + `--no-first-run`; `false` leaves the daily profile alone | `browser-launcher` | -| `ignoreCertErrors` | `true` | `--ignore-certificate-errors` so the intercepting CA is accepted; `false` restores validation | `browser-launcher` | -| `startUrl` | `''` | appended **last** so it is the target, not a flag value; omitted when empty | `browser-launcher` | -| `extraArgs` | `[]` | appended before the start URL; empty strings dropped | `browser-launcher` | - -A fully stripped-down config produces no flags at all (`browser-launcher`). - -## 2.8a `httpCapture` - -| Option | Default | Behaviour | Proof | -|---|---|---|---| -| `port` | `8080` | the port RedLog's managed mitmdump listens on | `—` | -| `routeTerminals` | `false` | on, a built-in terminal opened while HTTP capture is running gets `HTTP_PROXY` / `HTTPS_PROXY` pointing at the capture proxy | `first-run-record-terminal` (the setting), `—` (the environment) | - -## 2.9 `redaction` - -| Option | Default | Behaviour | Proof | -|---|---|---|---| -| `entropyThreshold` | `4.5` | Shannon entropy above which a token is flagged | `redaction` | -| `minLength` | `20` | shorter tokens are never entropy-flagged | `redaction` | -| `denylist` | `[]` | substring match, or `/pattern/` for regex; a malformed regex is ignored | `redaction` | -| `allowlist` | `[]` | suppresses an entropy match | `redaction` | - -Plugin-contributed denylist entries merge in and unregister cleanly -(`redaction`); masking preserves span length (`redaction`, `secret-redaction`). - -## 2.10 `deconfliction` — removed - -The blue-team webhook was removed (`98a7bba`); no code reads a `deconfliction` -block. A config that still has one carries it through unread (Part 3). - -## 2.11 `cloudShare` / `marketplace` — removed - -Cloud share (`1a77090`) and the plugin marketplace were removed, and -`settings-ia` asserts both stay gone. No code reads either block; the evidence -bundle itself remains, in the one export control. - -## 2.12 `fileWatcher` / `processMonitor` / `agentTailer` (tuning; see `packs`) - -| Option | Default | Behaviour | Proof | -|---|---|---|---| -| *(runs with)* | `packs.hostMonitors` | starts only with the pack on + non-empty `watchPaths` + an engagement id; turning the pack off stops the watcher, in Settings or by opening a project with it off, and a save keeps one watcher rather than adding one | `file-watcher`, `watcher-restart`, `capture-packs` | -| `fileWatcher.watchPaths` | `[]` | empty is a no-op even with the pack on | `file-watcher` | -| `fileWatcher.ignorePatterns` | `[]` | added on top of the built-in ignores | `file-watcher` | -| *(runs with)* | `packs.hostMonitors` | off by default; polled on macOS, Linux and Windows and nowhere else. If the first process listing fails (BusyBox `ps` in a minimal container), a `process_monitor_ps_unavailable` event says why, once per capture start: the first, after a project close or switch, or when the pack is turned back on, not on every save (`restart()` in `src/main/services/process-monitor.ts`). A `ps` run that finishes after a restart or a stop records nothing | `capture-packs` (the pack's members), `process-monitor-restart` (the advisory, late runs); `—` (the platforms) | -| `processMonitor.pollMs` | `500` | poll cadence; floored at 200 ms, and at **2000 ms on Windows** where a cold PowerShell spawn is 800–1500 ms and a 500 ms cadence would stack calls | `config-options` (the default); `—` (the floors) | -| `processMonitor.ignoreCommands` | `[]` | leading-token match, on top of the built-ins | `process-monitor` | -| *(runs with)* | `packs.hostMonitors` | the connection monitor states its SYN-scan blind spot (`connection_capture_started`) once per capture start: the first, after a project close or switch, or when the pack is turned back on, not on every save. A socket-table read that finishes after a restart or a stop records nothing | `connection-monitor-restart` | -| `connectionMonitor.pollMs` | `2000` | socket-table poll cadence of the connection monitor, which runs with `packs.hostMonitors`; floored at 1000 ms | `—` | -| *(runs with)* | `packs.aiAgents` | agent transcripts are sensitive: off until the operator turns the pack on for the project; a `.redlog-app-root` marker still opts a repo out | `capture-packs`, `agent-tailer` | -| `agentTailer.emitThinking` | `false` | thinking blocks are excluded unless turned on | `agent-tailer` | -| *(runs with)* | `packs.windowsOutput` | Windows: follows `~/.redlog/transcripts/*.txt` written by `start-transcript-hook.ps1` and emits each command once; opening a project with the pack off stops following, and a project switch keeps one follower | `powershell-transcript`, `watcher-restart`, `capture-packs` | - -## 2.12a `packs` — optional capture (Spec 035) - -| Option | Default | Behaviour | Proof | -|---|---|---|---| -| `packs.hostMonitors` | `false` | runs the process, connection, file and clipboard monitors | `capture-packs` | -| `packs.aiAgents` | `false` | runs the Claude Code / Codex / OpenCode transcript tailers | `capture-packs` | -| `packs.windowsOutput` | `false` | runs the PowerShell Start-Transcript follower | `capture-packs` | - -A pack runs only when it is on **and** its bundled plugin (`pack-host-monitors`, -`pack-ai-agents`, `pack-windows-output`) is active; disabling the plugin in -Plugins removes the pack's sources from Settings and capture health. The -sources' own `enabled` keys were removed and are not read (`capture-packs`). - -## 2.13 `loot` - -| Option | Default | Behaviour | Proof | -|---|---|---|---| -| `loot.disabledRules` | `['jwt', 'generic_api_key']` | rule ids (built-in `type`, or `pluginId:patternName`) not recorded as loot; a switched-off rule still matches, so its values are still masked in the record and in exports. `[]` records every rule | `loot-rule-switches`, `loot-rules-group` | - -What any rule records (Spec 031): every occurrence is masked; one loot row per -secret per target, compared on the full value; a failed write is reported in -Capture Health and retried on the next sighting (`loot-correctness`). - ---- - -# Part 3 — Config file handling - -| Case | Behaviour | Proof | -|---|---|---| -| no `config.yaml` | full defaults | `config`, `config-options` | -| corrupt / empty YAML | falls back to defaults, never throws | `—` | -| partial file | merges over defaults; siblings keep their defaults | `config` | -| arrays | **replace**, never concatenate | `—` | -| explicit `[]` | wins over a non-empty default | `—` | -| explicit `false` / `0` | kept, not treated as "unset" | `—` | -| unknown key | carried through, not dropped | `—` | -| save → load | round-trips (asserted for `engagement.id` and `network.whitelist`) | `config` | -| `saveConfig` into a missing directory | creates it | `—` | - -Each `—` row is what `loadConfig` / `saveConfig` do today (a `deepMerge` over -the defaults, a catch-all fallback, `mkdirSync({ recursive: true })`), but no -test asserts it. - -No legacy key is migrated. Spec 006 (the pre-release contract reset) removed -the migrations: `loadConfig` merges the file over the defaults and nothing -else, so an old key such as `network.vpnIPs` or `scope.enforcement` is carried -through unread. - ---- - -# Part 4 — Renderer surfaces - -| Surface | Covered | Proof | -|---|---|---| -| every view mounts with one event of each agent type | crash-on-open regressions | `renderer-smoke` | -| HUD verdict colour / label / flash / scale / emphasis / pass-through / mark buttons | §1.8, §2.5 | `—` (`renderer-smoke` mounts the HUD and drives expand and hide; `e2e` `hud-overlay.spec.ts` covers its geometry) | -| dashboard IP card: three states, both addresses, hints, provider error | §1.8 | `—` (`renderer-smoke` mounts it) | -| status bar: IP state, scope count, recording × capture-health dot, loot | §1.8 | `—` (`renderer-smoke`: the clock's title; `e2e` `recording-flow.spec.ts`: the `PAUSED` and `Waiting for events…` labels; `loot-count`: the number it reads) | -| scope violations list: not-set / all-in-scope states, the standing count, paging, withdrawn rows, the truncation note, chain length | §1.8 | `scope-recompute-run` (the read model), `scope-status` (the count, all-in-scope, the truncation note), `e2e` (`scope-recompute.spec.ts`), `list-keyboard` (the list keyboard contract) | -| HUD live-update subscriptions (Settings → open overlay) | §1.8 | `—` | -| Settings controls: each toggle / number field writes the right config key, with its UI-layer coercion | §2.x | `—` (`scope-entry-ui`: the create card's excludes and the project rename; `overlay-pass-through`: the pass-through toggle) | -| process-monitor poll cadence + platform floors | §2.12 | `—` | -| one severity scale across both alarms; nothing ranks or folds them | §1.7.1 | `alert/ip-policy`, `alert/scope-policy` | -| Timeline dot shape by event type and marker severity, not by authority | §1.7.1 | `e2e` (`timeline-encoding.spec.ts`) | -| `authority` on each verdict; `insertEvent` stamps none of its own | §1.7.1 | `alert/ip-policy`, `alert/scope-policy`; `—` (the chained events) | -| `scope_violation` payload: `distance` + `authority` per rung | §1.7 | `—` (the live payload); `scope-recompute-run` (the recompute's rows) | -| registrable-domain table (built-in two-label suffixes; not configurable) | §2.3 | `alert/scope-policy` | -| settings search index covers every group | option discoverability | `settings-search` | -| timeline lanes, axis, clustering, modes, wheel, geometry | timeline behaviour | `timeline-*` | -| capture onboarding + readiness | empty/partial states | `capture-onboarding-render`, `capture-readiness`; `—` (the shared `EmptyState`) | -| keyboard shortcuts, split pane, lane visibility/colours | UI plumbing | `shortcuts`, `lane-*`; `—` (the split pane) | - -Playwright (`npm run e2e`) runs against a built app. Among others it covers -the HUD overlay window's geometry, recording pause, project flow, command IO, -scope entry and recompute, transcript view and timeline presentation (the -specs are in `e2e/`). The marketplace and cloud-share flows were removed with -their features (`5603ae9`, `1a77090`). - -No e2e spec drives the IP alert path through real windows: nothing pushes a -verdict and checks the HUD frame, the flash or the status bar, so §1.8 stays -`—` for every surface. - -**End to end, scope:** `e2e/scope-recompute.spec.ts` seeds commands through -the local API and then changes the scope, so the retroactive half of §1.6–1.7 -— rows flagged or withdrawn by a recompute — is covered end to end. A live -violation is not: the shell hook's own endpoint (`/api/events`, which -`recording-pause.spec.ts` posts to) could raise one, but no spec checks one on -screen. That part stays at the unit level plus the manual walk-through in §5.3. - ---- - -# Part 5 — Manual QA - -What no unit test can reach. Run before a release, or after touching the alert -path. - -### 5.1 Trigger an EXPOSED alert end to end - -1. Settings ▸ Network: put your real egress IP (`curl ifconfig.me`) in the - **blacklist**, leave the whitelist empty. Set `confirmations: 1` to skip the - hold. -2. Disconnect the VPN. -3. Within `checkInterval` seconds the HUD frame turns red, flashes, and reads - `EXPOSED`; the dashboard card shows a red pulsing dot and the "check your - VPN" hint. -4. Reconnect the VPN → back to `SAFE`. With only a blacklist set the verdict is - `presumed_safe` (A-3), which the surfaces show as `SAFE` (§1.8). -5. Set `overlay.flashOnExposed: false` and repeat: still red, no flashing. - -### 5.2 Trigger the off-profile (dropped-VPN) case - -Whitelist your VPN exit CIDR, blacklist your home IP, then connect through a -third network (phone hotspot). This is the A-9 case from §1.1: the verdict is -`off_profile`, which the surfaces show as `EXPOSED` / red — **never** green. The -card's hint then says the address is on the Exposed IP list, which it is not -(G-UI2). - -### 5.3 Trigger a scope violation - -1. Settings ▸ Scope: `targets: *.app.example.com, 192.168.1.10`, warnings on. -2. Run `curl vpn.example.com` in a hooked shell → D2 violation: the status bar - reads `SCOPE 1`, a red row appears in Scope & Evidence, and the - `system/scope_violation` event carries `distance: adjacent_domain`, - `authority: inferred`. -3. Run `nmap 192.168.1.55` → D2 again (`distance: adjacent_subnet`), because the - single-IP entry expands to `/24`. -4. Run `curl google.com` and `nmap 8.8.8.8` → no alert (D3, by design); the - commands are still in the timeline. No setting makes D3 alert (§1.7). -5. Add `dc01.app.example.com` to `excludeTargets` and turn warnings off - (`scope.warnOnViolation: false`). Every save schedules a recompute, and this - one runs under the new floor: the banner counts the two D2 violations from - steps 2–3 as withdrawn, and the status bar goes back to `SCOPE OK`. Hit - `dc01.app.example.com` → still alerts (`distance: excluded`, - `authority: fact`). Hit `vpn.example.com` and `192.168.1.55` again → silent. - -### 5.4 `overlay.showInDock` (macOS) - -Show the HUD with the option on → Dock icon stays. Turn it off → Dock icon -disappears while the HUD keeps working. **This step cannot be automated**: the -Dock change goes through the macOS activation policy asynchronously and -`app.dock.isVisible()` keeps reporting the old value in an automated run, so an -e2e assertion here would be a false green rather than coverage. - -### 5.5 `terminal.maxCastBytes` - -Set a small cap (e.g. 4096), run `yes | head -100000` in the built-in terminal, -and confirm the `.cast` ends with the `[redlog: cast truncated at N bytes]` -marker and the event records `castTruncated`. - -### 5.6 Wi-Fi SSID (`network.showWifiName`) - -Toggle it on, accept the Location Services prompt, and confirm the HUD shows the -real SSID instead of a generic `Wi-Fi`. Toggle it back off — the HUD must drop -to `Wi-Fi` straight away, not at the next poll. The SSID names the building you -are sitting in and the HUD is the surface most likely to be in frame on a -screenshot, so "off" has to mean off everywhere, immediately. - ---- - -# Part 6 — Known gaps - -| ID | Gap | Impact | -|---|---|---| -| **G-UI1** | `overlay.showInDock` is the last manual-only option. `app.dock.hide()` changes the macOS activation policy asynchronously and the result does not surface through `app.dock.isVisible()` in an automated run, so the assertion would be a false green. | One setting, verified by §5.4. Of the other three once filed here, `cloudShare.authToken` and `marketplace.defaultRegistryUrl` went with their features (`1a77090`, `5603ae9`), and `processMonitor.pollMs` is decided synchronously in `restart()`, though no test pins its floors (§2.12). | -| **G-UI2** | The renderer gets three states for five verdicts (`verdictToSafety()` in `src/main/services/alert-runtime.ts`): `presumed_safe` shows as `SAFE`, `off_profile` as `EXPOSED` with a hint that the address is on the Exposed IP list, and a stale reading as `IP?` with a hint to set lists that may already be set (§1.8). | The policy's distinctions (§1.1, §1.2.1) stop at the main process, and two of the hints point at the wrong fix. | -| **G-UI3** | No test asserts what an alert surface renders: HUD label and frame, card, status bar dot and label, live updates (§1.8, Part 4). | A display regression on a correct verdict passes the suite. | -| **G-IP1** | An IPv6 CIDR entry in either list never matches, not even its own address; only a bare IPv6 address matches, and only exactly. An IPv6 CIDR with a prefix of 32 or less is read as IPv4 arithmetic instead, so it can match IPv4 addresses (`matchesCIDR` in `src/core/alert/policies.ts`, §1.2). | An IPv6 exit or home range gives no verdict, and a short IPv6 prefix can put IPv4 addresses on a list. | -| **G-S1** | A CIDR entry narrower than /24 still seeds the /24 of its base address (`buildScopeIndexes`, §1.6). | `10.0.0.0/28` makes `10.0.0.200` `adjacent_subnet`: an inferred boundary the operator did not state. | -| **G-S2** | Every in-scope hit is chained as a `scope_violation` row with `distance: in_scope` (`ChainEmitter`, §1.7), and the Timeline titles each one as a violation (`eventTitle.ts`). | Adherence records read as violations to anyone looking at the Timeline or the raw chain. | -**Fixed.** These IDs stay listed because code comments cite them: - -| ID | What it was | Fixed by | Proof | -|---|---|---|---| -| **G-IP2** | The HTTP path took `data.ip ?? data.origin ?? String(data)` unchecked, so a provider answering another JSON shape yielded the address `[object Object]`, which with only a blacklist set read `presumed_safe`, shown as `SAFE`. A throw from `getInternalIP()` left the in-flight flag set and stopped every later check. | #169: only one IPv4 address counts as an answer (§1.4); the flag is cleared in a `finally` | `ip-signal-producer` | -| **G-S3** | The Scope page cut its 500-row window before dropping in-scope rows, so after enough in-scope traffic it listed none of the standing violations and said "All commands within scope" while the status bar read `SCOPE `. | #168: in-scope and replaced rows are left out before the cut, and the page takes its totals from the chain (§1.8) | `scope-recompute-run`, `scope-status` | -| **G-CB1** | What was copied while recording was paused, and still on the clipboard, was captured at the first poll after resume. | #167: resuming seeds the clipboard (§2.7) | `clipboard-monitor` | -| **G-CB2** | Each restart armed its interval after an `await`, and config:save restarts the monitor twice in one tick, so a save leaked a poller and turning Host monitors off did not stop clipboard capture. | #167: only the newest restart arms the poll (§2.7) | `clipboard-monitor` | - -The same late-continuation guard went into the file watcher and the transcript -follower (#167, `watcher-restart`), the connection monitor (#165, -`connection-monitor-restart`) and the process monitor (#166, -`process-monitor-restart`), §2.12. - -Adding a config option? Add its default to the table in `config-options` and -its behaviour to the relevant Part 2 section. If it changes what the operator -sees, it lands in G-UI3 until a surface test exists. - -The first of those is **enforced, not requested**: `config-options` walks the -real default config and fails on any option the table does not name (and on any -table entry whose option has been removed). Its first run, with the table taken -from this document as it stood, found drift both ways: nine options the -defaults held that Part 2 never listed (`engagement.activeTarget`, -`network.offline`, `scope.warnOnViolation`, `scope.personalDomains`, -`screenshot.diffThreshold`, `screenshot.captureOnCommand`, `httpCapture.port`, -`httpCapture.routeTerminals`, `connectionMonitor.pollMs`), and fifteen rows for -options no code reads (`engagement.name`, `network.staleAfter`, -`scope.alertFloor`, `scope.proximityBits`, `scope.publicSuffixes`, and the -`deconfliction`, `cloudShare` and `marketplace` blocks). That is exactly the -drift a sentence in a doc cannot prevent. +## Coverage owners + +| Requirement | Primary tests | +| --- | --- | +| Plain Zsh command/output/exit and terminal behavior | `zsh-auto-capture.pty`, `zsh-engagement-100.pty`, `zsh-transparency.pty`, `relay-pty-boundary` | +| Hook transport, identity, spool and outages | `shell-*`, `spool-replay`, `per-project-token`, `powershell-*` | +| Capture setup, preparation/cancel and recovery | `capture-preparation`, `first-run`, `terminal-enrollment`, `dependency-cancel` | +| HTTP readiness, actual requests and proof | `managed-proxy-readiness`, `http-verification`, `managed-http-capture`, `packaged-smoke` | +| Query/filter/export and evidence integrity | `query-*`, `export-plan*`, `filtered-export`, `redlog-verify`, `packaged-smoke` | +| Project/pane isolation and saved recordings | `terminal-session-rotation`, `packaged-session-smoke`, `packaged-command-integrity` | +| UI tokens/contrast | `design-tokens` owns numerical contrast; `design-rules`/`design-palette` own source conventions. | +| Plugin manifests/contributions/trust | `plugins`, `starter-pack`, `builtin-tools-pack` | + +File stems refer to `test/*.test.ts(x)` unless a journey is under `e2e/`. +Producer tests must compare real command output, exits and artifacts. Seeded +UI events prove rendering and interaction, not producer coverage. + +## CI and platforms + +- CI runs the five gates and build on Linux, Windows and macOS; Electron source + journeys run on Linux. A green Linux result does not prove macOS PTY behavior. +- Linux package smoke installs/removes the x64 DEB and checks real capture, + HTTP(S), export/restart and cancellation. Windows smoke installs/removes NSIS + and checks native PowerShell plus capture/export. Both require strict JSON. +- Installed-only, native WSL, performance and public dependency-download tests + have explicit prerequisites/opt-ins. Do not delete them merely because a + source-only run skips them, or count them as passed. +- Manual download, desktop launch, trust prompts and first-install friction use + [the release walkthrough](RELEASE-SMOKE-TEST.md). Exact prior package results + and remaining limitations are in [the package matrix](PACKAGED-SMOKE.md). + +## Keep the suite focused + +Prefer observable behavior over assertions about source spelling. Keep unique +regressions for lost evidence, wrong attribution, privacy and terminal control. +Remove a duplicate only after identifying its retained coverage owner. An +obsolete requirement belongs in history, not a permanently skipped runnable +test. UI snapshots and a window opening do not prove successful recording. + +The former Part 1–6 option/audit matrix (including G-* references in historical +tests/specs) is [archived](archive/TEST-MATRIX-2026-10-16.md). It is not the +current procedure or an assertion that those historical gaps remain open. diff --git a/docs/archive/README.md b/docs/archive/README.md index 75a20b11..4691955c 100644 --- a/docs/archive/README.md +++ b/docs/archive/README.md @@ -39,3 +39,9 @@ describing code that has since changed. Still live, and deliberately **not** archived: [`ALERT-ROLES.md`](../ALERT-ROLES.md) (cited by `src/core/alert/`), [`DESIGN-PRINCIPLES.md`](../DESIGN-PRINCIPLES.md) (§1 superseded, the rest in force) and [`TESTING.md`](../TESTING.md). + +### Archived 2026-10-16 — historical test inventory + +[The former test/config matrix](TEST-MATRIX-2026-10-16.md) preserves the old +Part 1–6 and G-* audit references. Its listed gaps are historical; current test +commands and coverage owners are in [Testing](../TESTING.md). diff --git a/docs/archive/TEST-MATRIX-2026-10-16.md b/docs/archive/TEST-MATRIX-2026-10-16.md new file mode 100644 index 00000000..9b1fca3e --- /dev/null +++ b/docs/archive/TEST-MATRIX-2026-10-16.md @@ -0,0 +1,829 @@ +# Historical test/config audit matrix + +Archived 2026-10-16 during test-suite cleanup. This is historical audit context, +not the current runbook or a claim that its listed gaps still exist. Use +[Testing](../TESTING.md) and [release acceptance](../RELEASE-SMOKE-TEST.md). + +# RedLog Test Matrix + +Every configurable option, every value that behaves differently, and where each +one is proven. If you change a default, add a field, or touch the alert path, +this is the file that says what the change is allowed to break. + +``` +npm test # the whole vitest suite (unit + renderer) +npx vitest run test/ # one file +npx vitest # watch mode +npm run e2e # Playwright, needs a built app +npm run typecheck # renderer types +``` + +**After an e2e run, `npm test` will fail to start.** `pree2e` points +`node_modules/.bin/node-gyp` at `@electron/node-gyp`, so `pretest`'s +`npm rebuild better-sqlite3` then builds the native module against Electron +headers and vitest cannot load it (`Could not locate the bindings file`, even +though the file is right there — it is the ABI that is wrong). Recover with: + +``` +rm -rf node_modules/better-sqlite3/build +(cd node_modules/better-sqlite3 && ../../node_modules/node-gyp/bin/node-gyp.js rebuild --release) +``` + +## How to read a row + +| Column | Meaning | +|---|---| +| **Value** | the literal a config file can hold, including the junk values | +| **Behaviour** | what RedLog does with it — stated as an observable outcome | +| **Proof** | vitest file that asserts it, `e2e` for Playwright, `manual` for §5, `—` for an open gap | + +Proof names drop the `test/` prefix and the `.test.ts(x)` suffix. + +--- + +# Part 1 — The alert path + +The one sequence that has to be right: an address or a target is observed, a +verdict is computed, and the verdict reaches the operator's eye. RedLog never +blocks (`ALERT-ROLES.md` Part D), so a verdict that is wrong or invisible is the +whole defence failing — there is nothing downstream to catch it. + +``` +egress IP ──► IPSignalProducer ──► IPPolicy ──► verdictToSafety ──► HUD + card + status bar +command ──► ingest ──► classifyScope ──► alert floor ──► scope_violation row ──► status bar + ScopeStatus +``` + +## 1.1 IP verdict matrix — which list wins + +Two lists, each independently set or unset, and an address that may hit either. +Nine reachable cells. + +Five verdicts, because the two lists answer different kinds of question. Three +are **observations** and one is an **inference**, and §3 says an inference may +never wear a fact's colour: + +| Verdict | Authority | Means | +|---|---|---| +| `safe` | fact | on an address you declared safe | +| `exposed` | fact | on an address you declared to be *you* | +| `off_profile` | fact | you declared an expectation and this is **outside** it | +| `presumed_safe` | **inference** | "not obviously you" — a blacklist is set and this missed it | +| `unknown` | — | nothing declared; there is no question to answer | + +| # | whitelist | blacklist | address hits | Verdict | Why | +|---|---|---|---|---|---| +| A-1 | — | — | — | `unknown` | nothing declared, nothing to judge against | +| A-2 | — | set | blacklist | `exposed` | your own IP is showing | +| A-3 | — | set | neither | `presumed_safe` | an inference, and rendered as one | +| A-4 | set | — | whitelist | `safe` | on a declared exit | +| A-5 | set | — | neither | `off_profile` | an observed deviation from a declared expectation | +| A-6 | set | set | both | `exposed` | config conflict resolves to the alarming side | +| A-7 | set | set | blacklist | `exposed` | identity leak is never masked | +| A-8 | set | set | whitelist | `safe` | | +| A-9 | set | set | neither | `off_profile` | **the VPN-dropped-onto-café-NAT case** | + +Proof: `alert/ip-policy`, one `it` per verdict rather than per row. A-1, A-3, +A-4, A-5 and A-6 each have a case; A-7 and A-8 are asserted in passing by its +dedup case. A-2 (blacklist only, hit) and A-9 (both set, neither hit) are +asserted by no test (`—`). + +Two fixes, and what still pins them: + +- **G-A1** — A-9 answered `safe`, because the blacklist-only shortcut ran even + after a configured whitelist had already missed. The policy checks the + whitelist miss first (`IPPolicy` in `src/core/alert/policies.ts`), but no + test pins it any more: `ip-monitor.test.ts` did, and was deleted with the + old `IPMonitor` in v0.12.0 (`b69502b`) (`—`). +- **G-A2** — A-3 answered `safe` (an inference in a fact's solid green) and A-5 + answered `unknown`, which understated an observed deviation into the same + amber bucket as "nothing is configured at all". Both now have their own + verdict at the policy (`alert/ip-policy`). The screen still folds them: + `presumed_safe` renders as `safe` and `off_profile` as `exposed` (§1.8). + +## 1.2 Range matching — `network.whitelist` / `network.blacklist` + +| Value | Behaviour | Proof | +|---|---|---| +| `10.8.0.0/24` | matches `10.8.0.0` through `10.8.0.255`, including network + broadcast | `—` | +| `10.8.0.0/24` vs `10.8.1.0` / `10.7.255.255` | one address either side is outside | `—` | +| `172.16.0.0/12` | matches up to `172.31.255.255`, not `172.32.0.1` | `—` | +| `203.0.113.42/32` | single host | `—` | +| `0.0.0.0/0` | matches every IPv4 address | `—` | +| `203.0.113.42` (no mask) | exact equality, never a prefix — `203.0.113.4` does not match | `alert/ip-policy` (the exact hit); `—` (the prefix case) | +| `2001:db8::1/64` | **never matches any IPv6 address**, `2001:db8::1` included: `IPV6_RE` rejects the `/`, so the family check fails before the exact-address fallback `matchesCIDR` means to run (G-IP1) | `—` | +| IPv4 CIDR vs IPv6 address | family mismatch never matches. The reverse does not hold: an IPv6 CIDR with a prefix of 32 or less is read as IPv4 arithmetic, so `2001:db8::/1` can match an IPv4 address (G-IP1) | `—` | +| multiple entries | a hit on any one entry is a hit | `—` | +| `[]` (default) | list is not configured → see A-1/A-3/A-5 | `config-options` (the default), `alert/ip-policy` (the unconfigured verdicts) | + +The blacklist is evaluated first and wins outright; a whitelist that is set but +missed answers `off_profile` — never `safe`, and never the same `unknown` that +"nothing configured" gets (`alert/ip-policy`). The matcher itself, +`matchesCIDR` in `src/core/alert/policies.ts`, is private and asserted by no +test; `scope-evaluator` tests a different one (`matchPattern`). + +## 1.2.0 The internal address is reported, not judged + +There is no `network.lanProfile` option and no LAN verdict. The IP producer +reads the internal address on every check, and main's 20 s link poll pushes the +link into it (`link`: the Wi-Fi SSID or wired, via `setLink()` in +`src/main/index.ts`). Both ride on every signal and are displayed, but nothing +classifies them: `IPPolicy` is configured with the whitelist and blacklist only +(`src/main/services/alert-runtime.ts`) and reads neither `internal` nor `link` +(`src/core/alert/policies.ts`). The "`lanProfile` verdict pathway" named in +`src/core/alert/signal.ts` was not built. So a laptop that silently reassociated to a guest SSID reads exactly like +one still on the client VLAN, and the external IP can be perfectly fine while +you are on the wrong network. + +A failed external lookup still re-reads the internal address rather than +discarding it with the rejection (`read()` in +`src/main/services/producers/ip-signal-producer.ts`; `—`). A failure to read the +interfaces leaves only the internal address empty; the external read keeps its +result (`ip-signal-producer`). + +## 1.2.1 A failed read expires the verdict + +There is no `network.staleAfter` option. The first failed read marks the +reading stale (`read()` in `src/main/services/producers/ip-signal-producer.ts`): +the last stable address is kept, and `IPPolicy` answers `unknown` with +`stale: true`, whatever the lists say. + +| Case | Behaviour | Proof | +|---|---|---| +| a read fails (a provider error, every provider down, or air-gap `offline`) | `stale: true` at once; the verdict is `unknown`, never the last good answer | `alert/ip-policy` (the verdict); `ip-signal-producer` (the producer marks it) | +| a later read succeeds | the verdict is recomputed and `stale` clears | `ip-signal-producer` (`stale` clears); `alert/ip-policy` (a fresh reading is classified) | + +A dropped VPN and a dead IP provider look identical from outside the process, so +neither may keep rendering the last good answer at full confidence. The screen +does not show `stale` as its own state: the renderer receives `unknown`, and +reads neither `stale` nor `settling` (§1.8). + +## 1.3 `network.confirmations` — flap protection + +A new address is held until it has been read this many times in a row. The +displayed address, and therefore the verdict, does not change during the hold. + +| Value | Behaviour | Proof | +|---|---|---| +| `1` | promote on sight, no flap protection | `ip-signal-producer` | +| `3` (default) | two reads hold the old address, the third promotes | `config-options` (the default), `ip-signal-producer` (the hold) | +| `5` | four reads hold, the fifth promotes | `—` | +| `0`, negative | ignored: the previous value is kept (3 on a fresh producer), **not** "promote instantly" (`configure()`) | `ip-signal-producer` | +| first ever reading | taken as-is; there is nothing to flap against | `ip-signal-producer` | +| candidate changes each poll (CGNAT) | nothing is ever promoted; `settling` stays true | `ip-signal-producer` | +| old address returns mid-hold | the half-confirmed candidate is dropped | `ip-signal-producer` | + +`settling: true` is the "displayed value is the last stable read" flag. It is +set during the hold and cleared on promotion (`ip-signal-producer`). A failed +read in mid-hold also clears it, although the hold goes on (`read()` in +`ip-signal-producer.ts`; `—`). The policy carries it into the verdict as a +modifier without changing the verdict (`alert/ip-policy`). + +## 1.4 `network.ipMode` and `network.providers` + +| Option | Value | Behaviour | Proof | +|---|---|---|---| +| `ipMode` | `auto` (default) | DNS first, HTTP only if DNS fails/blocked | `config-options` (the default), `ip-signal-producer` (the fallback) | +| `ipMode` | `dns` | direct query to OpenDNS/Google resolvers, no HTTP at all | `ip-signal-producer` | +| `ipMode` | `http` | HTTP echo only, DNS never queried | `ip-signal-producer` | +| `providers` | `[]` (default) | the three built-in echo services are used | `config-options` (the default), `ip-signal-producer` (the services) | +| `providers` | custom list | tried in order; first success wins | `ip-signal-producer` | +| `providers` | first entry throws | falls through to the next | `ip-signal-producer` | +| `providers` | first entry answers non-2xx | treated as a failure, moves on | `ip-signal-producer` | +| `providers` | the answer | the address is `ip`, else `origin`, else a bare JSON string, and only when that is one IPv4 address (`addressIn()`). Anything else — another JSON shape, a comma-separated `origin`, an IPv6 address — means that provider failed, and the next is tried. IPv6 is refused because the list matcher cannot judge an IPv6 range (G-IP1) | `ip-signal-producer` | +| `providers` | every entry fails | `error: 'All IP providers failed'`, **last known address is kept**, and the reading goes stale (§1.2.1) | `ip-signal-producer` | +| `providers` | a later poll succeeds | error clears | `ip-signal-producer` | + +## 1.5 `network.checkInterval` + +| Value | Behaviour | Proof | +|---|---|---| +| unset (monitor default) | polls every 10 s | `ip-signal-producer` | +| `60` (config default) | polls every 60 s | `config-options` (the default), `ip-signal-producer` (the cadence) | +| `0` | ignored, the previous interval is kept — a zero-second poll would be a busy loop; negatives too | `ip-signal-producer` | +| `stop()` | clears the timer, idempotent | `ip-signal-producer` | +| slow poll still in flight | the next tick is skipped, not queued | `ip-signal-producer` | + +## 1.6 Scope distance ladder — `classifyScope` + +How far a target sits from declared intent (`ALERT-ROLES.md` Part B), computed +by `classifyScope()` in `src/core/scope-evaluator.ts`; `classifyScopeTarget()` +in `src/core/alert/policies.ts` adds authority and severity. The tier decides +whether an alert fires at all, so every rung needs its own row. (The titles in +`alert/scope-policy` number the rungs differently: D1 excluded, D2 subnet, D3 +domain, D4 in scope.) + +| Tier | Distance | Meaning | Alerts? | +|---|---|---|---| +| D0 | `in_scope` | hits a scope entry | no | +| D1 | `excluded` | hits an exclude entry — a **fact** | **always**, even with warnings off | +| D2 | `adjacent_subnet` | same /24 as a single-IP scope entry, or as the base address of a CIDR entry — **inferred** | only when `warnOnViolation` | +| D2 | `adjacent_domain` | same registrable domain as a scope entry — **inferred** | only when `warnOnViolation` | +| D3 | `unrelated` | no relationship to anything declared | never, and not counted: `ScopePolicy.evaluate()` drops the verdict before any surface sees it | + +D1 outranks D0: an explicit exclusion inside a broad CIDR still fires +(`scope-evaluator`, `alert/scope-policy`, both with wildcard entries; no test +has a CIDR with an excluded address inside it). + +| Case | Scope | Target | Distance | Proof | +|---|---|---|---|---| +| no scope set | `[]` | anything | `in_scope` | `alert/scope-policy`, `scope-evaluator` | +| single-IP entry expands into its /24 | `192.168.1.10` | `192.168.1.55` | `adjacent_subnet` | `alert/scope-policy`, `scope-one-classifier` | +| an address outside a /24 CIDR | `10.0.0.0/24` | `192.168.50.1` | `unrelated` | `—` | +| wildcard expands to the registrable domain | `*.staging.example.com` | `prod.example.com` | `adjacent_domain` | `—` (every adjacency test uses a bare-host entry) | +| unrelated domain | `*.staging.example.com` | `google.com` | `unrelated` | `alert/scope-policy` | +| a different /24 | `192.168.1.10` | `192.168.2.55` | `unrelated` | `—` | + +The container is always a /24 (`subnetOf` in `src/core/scope-evaluator.ts`); +there is no option to change it. The design intent was an asymmetry: a bare IP +carries no boundary, so deriving one fills a gap, while a CIDR *states* a +boundary, and widening it would invent authorisation the operator never gave. +The code does not hold to the second half for CIDRs narrower than /24: +`buildScopeIndexes` seeds the /24 of every CIDR entry's base address, so +`10.0.0.0/28` makes `10.0.0.200` `adjacent_subnet` (G-S1). A domain always +expands to its registrable domain because that is the ownership boundary the +authorisation is about — hitting `prod` while scoped to `staging` is exactly +what D2 exists for. + +## 1.6.1 Scope verdicts — `evaluateScope` + +The coarser four-value status from `evaluateScope()` in +`src/core/scope-evaluator.ts`: `in-scope`, `out-of-scope`, `excluded` or +`no-scope`. It is what the shared filter's in-scope condition uses +(`src/core/db/event-queries.ts`); D2 and D3 both collapse to `out-of-scope` +here. + +| Target | Scope | Status | Proof | +|---|---|---|---| +| `null` / `''` / `undefined` | a scope set | `out-of-scope` — there is no `unknown`; callers that must not judge an empty target check for it first | `scope-evaluator` (empty subject); `scope-sanitize`, `scope-classify` (the callers) | +| anything | no targets, no excludes | `no-scope` — no scope set means nothing is out of scope | `scope-evaluator`, `scope-classify` | +| `10.0.0.9` | `10.0.0.0/24` | `in-scope` | `scope-evaluator` | +| `10.0.1.9` | `10.0.0.0/24` | `out-of-scope` | `scope-evaluator` | +| `api.app.example.com` | `*.app.example.com` | `in-scope` | `scope-evaluator` | +| `app.example.com` | `*.app.example.com` | `in-scope` — the wildcard covers its anchor host | `scope-evaluator`, `scope-classify` | +| `vpn.example.com` | `*.app.example.com` | `out-of-scope` (same root, outside the wildcard) | `—` | +| `google.com` | `*.app.example.com` | `out-of-scope` | `scope-evaluator`, `scope-sanitize` | +| `10.0.0.1` | in `targets`, also in `excludeTargets` | `excluded` — exclusion beats the enclosing CIDR | `—` | +| `dc01.app.example.com` | in the wildcard, also excluded | `excluded` | `scope-evaluator`, `alert/scope-policy` | + +CIDR and wildcard mechanics themselves are asserted one `it` each rather than +in a table: `/0`, `/24` and `/32` and the `*.` prefix in `scope-evaluator` +(`matchPattern`), `/16` in `scope-classify`, `/8` in `scope-sanitize`, and +registrable-domain extraction in `alert/scope-policy`. + +## 1.7 `scope.warnOnViolation` — how much the operator is told + +The setting is the `scope.warnOnViolation` boolean (default `true`; the +checkbox in Settings ▸ Scope). There is no `scope.alertFloor` key. The policy +works with an *alert floor*, the list of distances that raise a violation, and +`alertFloorFor()` in `src/core/alert/policies.ts` derives it from the boolean: +`true` gives `excluded`, `adjacent_subnet` and `adjacent_domain`; `false` gives +`excluded` only. The live path (`src/main/services/alert-runtime.ts`) and the +retroactive recompute (`src/main/index.ts`) both take the floor from there. + +| Distance | `false` | `true` (default) | Proof | +|---|---|---|---| +| D0 in scope | no violation | no violation | `alert/scope-policy`, `scope-recompute` | +| D1 excluded | **violation** | **violation** | `alert/scope-policy`, `scope-recompute` | +| D2 adjacent subnet | silent | **violation** | `alert/scope-policy` (on); `—` (off) | +| D2 adjacent domain | silent | **violation** | `alert/scope-policy` | +| D3 unrelated | silent | silent | `alert/scope-policy`, `scope-recompute` | + +D1 fires either way: "keep off X" is not a preference call. D3 raises a +violation through no setting, and is not counted either: `ScopePolicy.evaluate()` +drops the verdict before any surface sees it. + +Bookkeeping: violations accumulate rather than dedupe. The in-memory log +(`ViolationLog` in `src/core/alert/surface.ts`) keeps the newest 500, each with +target, command and timestamp; it cuts the command to 200 characters, as the +chained event does, and `list()` returns a copy. The Scope page reads the chain +instead (`scope:getViolations`). A violation event is `system` / +`subtype: scope_violation` carrying `distance` (`excluded` \| `adjacent_subnet` +\| `adjacent_domain`), with `authority` (`fact` \| `inferred`) and `severity` +alongside, so downstream can tell an observation from an inference. The log and +the live event payload are asserted by no test (`—`); the recompute's rows carry +their distance (`scope-recompute-run`). The chain also gets a row for every +in-scope hit, with `distance: in_scope` but under the same `scope_violation` +subtype (G-S2). + +Whatever the tier, the command itself still lands in the timeline — only the +*alert* is suppressed, never the record: `src/core/ingest.ts` inserts the event +before it dispatches to the policies (`—`). + +`configure()` semantics: omitting `alertFloor` on a later call keeps the current +floor, and replacing `targets` rebuilds the containers used for D2 +(`ScopePolicy.configure()`). Neither is asserted by a test (`—`). + +## 1.7.1 One severity scale for both alarms + +The self alarm (IP) and the target alarm (scope) share one severity type, four +steps: `clean` < `notice` < `warning` < `critical` (`Severity` in +`src/core/alert/policy.ts`). No code ranks or folds them: the status bar draws +the IP dot and the scope count separately, and the scope side is still an +on/off light, so a D1 hit and a D2 inference look the same there. + +| Input | Severity | +|---|---| +| `exposed` / D1 `excluded` | `critical` — the thing you must not do, observed, non-silenceable | +| `off_profile` / D2 adjacent | `warning` | +| D3 `unrelated` | `notice`, though no setting lets it reach a surface (§1.7) | +| `unknown` (including stale) / `presumed_safe` | `notice` | +| `safe` / D0 `in_scope` | `clean` | + +Proof: `alert/ip-policy` and `alert/scope-policy` assert the severity of each +verdict except `unknown` and `unrelated`, and `scope-one-classifier` that every +spelling of one target gets the same severity. No surface renders severity. + +Authority (`fact` \| `inferred` \| `unknown`) travels alongside severity on each +verdict and on the chained events (`src/core/alert/surface.ts`). No surface +renders it either, and `insertEvent` stamps none of its own. On the Timeline, +`dotShape()` (`src/renderer/src/lib/timelineDomain.ts`) keys on the event type +and a marker's severity, not on authority: every scope violation is a diamond +and a critical marker a hollow ring (`e2e`: `timeline-encoding.spec.ts`). + +## 1.8 Alert display — the verdict has to be seen + +The same verdict has to be legible on three surfaces. The renderer receives +three states, not five: `verdictToSafety()` in +`src/main/services/alert-runtime.ts` shows `presumed_safe` as `safe` and +`off_profile` as `exposed`, and a stale reading arrives as `unknown`. So on +screen the two qualified verdicts do borrow a plain one's confidence (G-UI2). +No test asserts what any surface renders: the decision is `alert/ip-policy`, +and every display claim in this section is `—` unless it names a test. + +| State | Verdicts | HUD label | Dashboard card | Status bar | +|---|---|---|---|---| +| `safe` | `safe`, `presumed_safe` | `SAFE` | green dot, `Safe IP` | green dot, `SAFE` | +| `exposed` | `exposed`, `off_profile` | `EXPOSED` | red pulsing dot, `Exposed IP`, and the hint that the address is on the Exposed IP list — shown for `off_profile` too, where it is not true | red dot, `EXPOSED` | +| `unknown` | `unknown`, any stale reading | `IP?` | yellow dot, `Unknown IP`, and the hint to set the two lists — shown for a failed read too, when both may be set | amber dot, `IP?` | + +The HUD frame turns red on the `exposed` state (`#d75f63`) and stays cyan +(`#3fc7d6`) otherwise; `flashOnExposed` controls only the flash, never the +colour. The `exposed` state also opens the HUD and keeps it from collapsing +(`src/renderer/src/OverlayApp.tsx`). + +The expanded HUD pane spells the state out in words (`Safe IP — Protected`, +`Exposed IP — Not Protected`, or `Unknown IP` and the same set-the-lists hint). +A provider error is printed in the expanded pane and on the card, and the last +address stays on display: a stale reading keeps it. The status bar prints the +address after the label in every state; before the first successful reading the +HUD and card show `—` and the status bar shows no address. + +**Scope alerts:** the status bar shows `SCOPE ` with a red dot, `SCOPE OK`, +or `SCOPE —` when no scope is set. `` counts the violations that still stand +across the whole chain (`countActiveScopeViolations()` in +`src/core/scope-recompute-run.ts`), and the Scope & Evidence view shows the same +number as "violations still standing" (`scope:getViolationCount`). The view +distinguishes `NOT SET` (with the hint that fixes it) from `ACTIVE`, and says +"all commands within scope" only when nothing stands and nothing was recorded. +It lists the standing violations with target, command and time, 200 at a time +with a shown / total footer. Withdrawn ones sit behind "Show withdrawn", which +appears only while at least one violation still stands. There is no Export +button on it. The list is read from the newest 500 violation records +(`queryScopeViolationRows()`, same file): in-scope rows and records a newer one +replaced are left out before the cut, and the page says so whenever the window +is cut. Proof: `scope-recompute-run` for the read model; `scope-status` for the +count, "all commands within scope" and the truncation note; `e2e` +(`scope-recompute.spec.ts`) for the recompute banner, the "judged later" tag on +rows a recompute flagged, and withdrawn rows kept rather than deleted. The rest +is `—`. + +**Recording vs capture health:** the status bar's recording control has a dot +and a label. Paused: a grey dot and `PAUSED`, which turns amber, pulses and +shows the minutes after five minutes. Recording: the label reads +`Waiting for events…` before the first event and whenever capture is `dark`, +and `REC` otherwise. The dot pulses red on a healthy or not-yet-reported +capture, pulses amber on `partial`, and stays amber and still on `dark`. Proof: +`e2e` (`recording-flow.spec.ts`) asserts `PAUSED` and `Waiting for events…`; +the `REC` label and the dot colours are `—`. + +**Live updates:** the open HUD subscribes to all five overlay settings +(`showMark`, `flashExposed`, `scale`, `emphasizeIp`, `passThrough`), and each +takes effect without a restart. A pass-through opacity of `0` is ignored on the +live message as well as on the config read; a live `scale` of `0` is clamped to +0.75 rather than ignored. `—`. + +--- + +# Part 2 — Config option matrix + +Defaults below are asserted one-per-`it` in `config-options`, so a changed +default fails a named test rather than a distant integration. + +## 2.1 `engagement` / `operator` + +| Option | Default | Behaviour | Proof | +|---|---|---|---| +| `engagement.id` | `default` | stamped on every event | `config-options` | +| `engagement.activeTarget` | `null` | the operator's current target: canonical ingest labels shell, marker and screenshot rows that carry no target of their own with it, and never overwrites an observed one (SPEC-target-identity) | `ingest` | +| `operator.id` | `operator-1` | attribution; no operator = no capture (see 2.5, 2.9) | `config-options` | +| `operator.name` | `Operator` | display only | `config-options` | + +There is no `engagement.name`: the project's name is the one name, shown by the +title bar and the picker, and Settings ▸ General renames the project. + +## 2.2 `network` + +| Option | Default | Values | Behaviour | Proof | +|---|---|---|---|---| +| `whitelist` | `[]` | IPs / CIDRs | §1.1–1.2 | `config-options` (the default), `alert/ip-policy` (the verdicts) | +| `blacklist` | `[]` | IPs / CIDRs | §1.1–1.2, wins over whitelist | `config-options` (the default), `alert/ip-policy` (the verdicts) | +| `checkInterval` | `60` | seconds | §1.5; the Settings field turns junk, and `0`, into 60 (`parseInt(v) \|\| 60` in `NetworkPage.tsx`) | `config-options` (the default), `ip-signal-producer` (the cadence); `—` (the field) | +| `providers` | `[]` | URLs | §1.4 | `config-options` (the default), `ip-signal-producer` (§1.4) | +| `confirmations` | `3` | ≥1 | §1.3; the Settings field clamps to ≥1 and turns junk into 3 | `config-options` (the default), `ip-signal-producer` (§1.3); `—` (the field) | +| `ipMode` | `auto` | `dns` \| `http` \| `auto` | §1.4 | `config-options` (the default), `ip-signal-producer` (§1.4) | +| `offline` | `false` | bool | air-gap: RedLog makes no outbound requests of its own — OpenTimestamps anchoring, NTP, the update check and the external-IP lookup. The IP verdict reads `unknown`, with the internal address still shown. Capture and the local API are unaffected | `ip-signal-producer` (no external-IP lookup, the reading stale); `—` (the rest) | +| `showWifiName` | `false` | bool | off drops the SSID from the link before it reaches any surface, keeping the link **type** (the UI renders a generic "Wi-Fi"); on shows it. The toggle shows on every platform; on macOS it also asks for Location Services, since the OS redacts the SSID without it. Turning it off applies immediately rather than at the next 20 s poll | `network-link-display` | +| `vpnAdapters` | 12 built-ins, all enabled | `{name, pattern, enabled}` | patterns are user regexes matched case-insensitively against interface names | `config-options` (the default); `—` (the matching) | + +`vpnAdapters` detail (`setVpnAdapters()` and `detectVpnInterfaces()` in +`src/main/services/opsec-state.ts`): the result feeds the OPSEC state events of +the 30 s poll there, not the IP verdict. A disabled adapter matches nothing; an +empty list means no interface is ever VPN; several adapters can match and the +interface list is sorted; an interface with no non-internal address is skipped; +**a malformed regex is dropped instead of throwing**, so one bad pattern cannot +take the OPSEC poller down; re-configuring replaces the pattern set. The shipped +patterns (`DEFAULT_VPN_ADAPTERS` in `src/core/config.ts`) match `wg0`, `tun0`, +`tap0`, `tailscale0`, `nordlynx`, `proton0`, `utun4`, `ipsec0` and `ppp0`, and +none of them matches `en0` / `eth0` / `wlan0`. No test runs any of this (`—`). + +There is no `staleAfter` option: the first failed read marks the verdict stale, +and it reads `unknown` until a read succeeds (`alert/ip-policy`). + +## 2.3 `scope` + +| Option | Default | Behaviour | Proof | +|---|---|---|---| +| `warnOnViolation` | `true` | the alert floor (§1.7): `true` alerts on excluded targets and on the adjacent rungs, the same /24 and the same registrable domain; `false` alerts on excluded targets only. Unrelated traffic never alerts. There is no `alertFloor` key: the floor is derived from this (`alertFloorFor`) | `alert/scope-policy` | +| `targets` | `[]` | §1.6; empty = everything in scope | `config-options` (the default), `alert/scope-policy`, `scope-evaluator` (empty = in scope) | +| `excludeTargets` | `[]` | D1: always warns when hit | `config-options` (the default), `alert/scope-policy` (D1) | +| `personalDomains` | `127.0.0.0/8`, `::1`, `localhost` | hosts that are never part of any engagement, in the `excludeTargets` syntax. The personal-traffic filter hides them (`events`), a project created with an initial `personalDomains` appends it to the defaults rather than replacing them (`create-config-merge` — the create card no longer sends any, 70438fe9), and exports drop their rows rather than masking them (`—`) | `events`, `create-config-merge` | +| `scopeFile` | `null` | external scope document, loaded on open | `config-options` | + +Adjacency is not configurable: a single-IP scope entry's neighbours are its /24 +(`subnetOf` in `scope-evaluator.ts`), and registrable domains come from a +built-in table of two-label suffixes (`co.uk`, `co.jp`, …). There is no +`proximityBits` or `publicSuffixes` option. + +`scopeFile` parsing (`loadScopeFile`): plain text is one target per line with +`#` comments and blank lines dropped; a JSON array is read as the target list; +a missing file yields `[]` rather than throwing (`config`). Also what the code +does, but asserted by no test (`—`): a file with no extension, or any extension +but `.json`, is read as text; a JSON array keeps only its string entries; +Burp/ZAP's `target.scope` JSON is decoded; malformed JSON and an unrecognised +JSON shape yield `[]`. An unreadable scope must never take the project open +down with it. + +Burp/ZAP hold a scope host as a **regex**, so `burpHostToTarget()` decodes the +shapes those tools write into RedLog target syntax: + +| Burp host | Decoded | Meaning | +|---|---|---| +| `^example\.com$` | `example.com` | exact host | +| `\Qexample.com\E` | `example.com` | literal-quoted | +| `.*\.example\.com$` | `*.example.com` | "and subdomains" | +| `.*\Qcorp.example.com\E` | `*.corp.example.com` | same, literal-quoted | + +`\Q…\E` runs are expanded wherever they appear and however many times; a +hand-written pattern that is still regex-shaped afterwards (`(dev|prod)\.…`) is +handed back **untouched** rather than dropped or half-converted. It matches +nothing, but it stays visible in the scope list — a scope target that vanishes +is the failure with no symptom, since the operator sees a scope load +successfully and never learns which hosts are missing from it. + +## 2.4 `screenshot` + +| Option | Default | Values | Behaviour | Proof | +|---|---|---|---|---| +| `quality` | `85` | 1–100 | passed to the JPEG encoder verbatim; `0` is ignored and the previous value is kept, 85 on a fresh agent (`configure()` in `src/main/services/screenshot-agent.ts`) | `config-options` (the default); `—` | +| `intervalSec` | `0` | seconds, `0` = off | `0` and negatives schedule nothing; `30` schedules a 30 s loop; `12.9` floors to 12 s; setting it back to `0` cancels the running loop; re-configuring replaces the timer instead of stacking one | `config-options` (the default); `—` | +| `diffThreshold` | `5` | bits, `0` = off | automatic captures only: a frame whose dHash distance from the last stored frame is below this is skipped as no visible change; `0` stores every non-identical frame; manual captures ignore it | `dhash` (the distance and the threshold); `—` (which captures it gates) | +| `captureOnCommand` | `false` | bool | on captures a screenshot when a shell command finishes, linked to it by `_causes`; the perceptual dedup still applies | `—` | + +Capture gating (`captureNow()` in `src/main/services/screenshot-agent.ts`): a +periodic capture of an unchanged screen is skipped (exact-bytes then perceptual +dHash), a changed screen is captured again, and a **manual** capture always +lands — including while recording is paused, where periodic capture is +suppressed. No operator id means nothing is scheduled and nothing is captured. +Files land in `/screenshots/` and the event records trigger, size, +dimensions and sha256. `ScreenshotAgent` has no unit test (`—`); `e2e` +(`screenshot-capture.spec.ts`) asserts only that an empty screen-source list is +reported to capture health rather than dropped. + +## 2.5 `overlay` — HUD appearance + +| Option | Default | Values | Behaviour | Proof | +|---|---|---|---|---| +| `showMarkButton` | `true` | bool | `false` hides both mark buttons in the expanded pane, keeping the keep-open toggle | `config-options` (the default); `—` | +| `showInDock` | `true` | bool | macOS only: keeps a Dock icon once the HUD is shown. Stays manual — `app.dock.hide()` takes effect asynchronously via the activation policy and its result is **not observable** through `app.dock.isVisible()` in an automated Electron run (polled 5 s, still reports visible), which is also why main re-runs `applyDock()` on a timer and on overlay show | manual §5.4 | +| `flashOnExposed` | `true` | bool | `false` keeps the red frame but stops the flashing; never flashes while `safe` | `config-options` (the default); `—` | +| `scale` | `1.0` | six stops in the UI, 0.75 / 0.85 / 1.0 / 1.25 / 1.5 / 1.75; the slider snaps to them | scales type and padding; **clamped to 0.75–1.75** (`OverlayApp.tsx`). `0` and negatives are ignored when the HUD first reads the config, but a saved `0` reaches the open HUD as a live update and is clamped to 0.75 (§1.8) | `config-options` (the default); `e2e` (`hud-overlay.spec.ts`: no expanded-pane label wraps at 1.0, 1.25 or 1.5); `—` (the clamp) | +| `emphasizeExternalIp` | `false` | bool | multiplies **only** the external IP by a further 1.4×, compounding with `scale` | `config-options` (the default); `—` | +| `passThrough` | `false` | bool | dims HUD chrome and lets mouse events through (`setIgnoreMouseEvents` in `src/main/ipc/overlay.ts`). A toggle from the HUD, ⌘⇧P or the menu bar is saved as the setting. The `exposed` state turns it off for the session and makes the window fully opaque; the stored setting is untouched, so it comes back at the next project open | `config-options` (the default), `overlay-pass-through` (a runtime toggle is saved); `—` (the rest) | +| `passThroughOpacity` | `0.4` | 0.1–0.9 in the UI | applied while `passThrough` is on; `0` is ignored (an invisible HUD is worse than none). In the compact bar it dims everything but the frame and the external IP column (address and pivot), so the verdict label and dot dim with the rest. The expanded pane is dimmed as a whole, its external IP row included | `config-options` (the default); `—` | + +The external IP's font size (base 12 px), computed by `fsIp()` in +`OverlayApp.tsx` rather than measured by any test: `0.85 → 10.2`, `1.0 → 12`, +`1.25 → 15`, `1.5 → 18`, `1.75 → 21`; `99 → 21` and `0.01 → 9` (clamped); with +`emphasizeExternalIp`: `1.0 → 16.8`, `1.5 → 25.2`. + +## 2.6 `terminal` / `retention` + +Every store's retention sits under `retention.` with the same two knobs +(Spec 028): `keepDays` sweeps by age on project open, `maxBytes` evicts under +size pressure. `0` means keep forever / unbounded, and is the default for all +of them (`one-retention-model`). + +| Option | Default | Behaviour | Proof | +|---|---|---|---| +| `terminal.maxCastBytes` | `52428800` (50 MB) | a session's `.cast` is truncated at the cap with an inline marker; values ≤ 0 are ignored. A capture limit on one recording, not retention | `cast-slice` (slicing), manual §5.5 (truncation) | +| `retention.casts.keepDays` | `0` | `N` deletes `.cast` files older than N days, one `system.cast_pruned` audit event per deletion; the recording leaves the search index too | `retention` | +| `retention.screenshots.keepDays` | `0` | same shape, `system.screenshot_pruned` | `retention` | +| `retention.httpBodies.keepDays` | `0` | same shape for `http-bodies/*.body`, `system.http_body_pruned` | `retention` | +| `retention.agentTranscripts.keepDays` | `0` | same shape for agent transcript sidecars, `system.agent_transcript_pruned` | `retention` | +| `retention.casts.maxBytes` / `retention.screenshots.maxBytes` | `0` | over budget, the coldest files are evicted first; a file of an in-scope target is pinned and never evicted, even if that leaves the store over budget (reported as a shortfall) | `artifact-eviction` | +| `retention.httpBodies.maxBytes` | `0` | same model for the body store | `body-eviction-sweep` | +| `retention.loggedTier.keepDays` / `sweepIntervalHours` | `0` / `24` | row-level sweep of `events_logged`, on project open and every N hours | `retention-logged-tier` | +| `retention.bookmarks.keepDays` | `0` | deletes bookmarks older than N days; one `system.bookmarks_pruned` row with the count only | `retention` | + +Eviction and pruning delete the file, never the event: the sha256 attestation +and the chain survive, so a pruned file verifies as *pruned*, not as tampered. + +## 2.7 `clipboard` + +| Option | Default | Behaviour | Proof | +|---|---|---|---| +| *(runs with)* | `packs.hostMonitors` | with the pack off nothing is captured, or even read. Turning it off, in Settings or by opening a project with it off, stops the poll, and a save replaces the poll rather than adding one. On start the current clipboard is seeded, so pre-session content is never captured | `clipboard-monitor`, `capture-packs` (the pack's members) | +| `pollMs` | `1500` | honoured as given, **clamped up to 500 ms** | `config-options` (the default), `clipboard-monitor` | +| `storePreview` | `false` | off stores hash + length + line count and `preview: null`; on stores the first 120 characters with the spans redaction flags masked to `•` | `config-options` (the default), `clipboard-monitor` | + +What the monitor does (`src/main/clipboard-monitor.ts`): with `storePreview` +off no clipboard text is stored; with it on, the preview is the raw text apart +from the masked spans, so a secret redaction does not flag lands in clear. +Identical consecutive reads dedupe to one event. Paused recording reads +nothing, and resuming seeds what is on the clipboard then, so what was copied +during the pause is not captured (`clipboard-monitor`). Asserted by no test +(`—`): an empty clipboard produces nothing; detected credential *types* are +recorded without copying the credential; a throwing loot detector does not lose +the event; and, the flip side of seeding on resume, a copy made in the last poll +interval before the pause and still on the clipboard at resume is not captured +either. + +## 2.8 `browser` + +| Option | Default | Behaviour | Proof | +|---|---|---|---| +| `binary` | `''` | auto-detect per platform (incl. per-user Windows installs) | `browser-launcher` | +| `proxy` | `http://127.0.0.1:8080` | adds `--proxy-server` **and** `--proxy-bypass-list=<-loopback>` (RedLog's own profile adds `;*.redlog-offline.invalid`, #182); `''` omits both | `browser-launcher` | +| `cdpPort` | `9222` | `--remote-debugging-port`; `0` omits it | `browser-launcher` | +| `isolateProfile` | `true` | `--user-data-dir` + `--no-first-run`; `false` leaves the daily profile alone | `browser-launcher` | +| `ignoreCertErrors` | `true` | `--ignore-certificate-errors` so the intercepting CA is accepted; `false` restores validation | `browser-launcher` | +| `startUrl` | `''` | appended **last** so it is the target, not a flag value; omitted when empty | `browser-launcher` | +| `extraArgs` | `[]` | appended before the start URL; empty strings dropped | `browser-launcher` | + +A fully stripped-down config produces no flags at all (`browser-launcher`). + +## 2.8a `httpCapture` + +| Option | Default | Behaviour | Proof | +|---|---|---|---| +| `port` | `8080` | the port RedLog's managed mitmdump listens on | `—` | +| `routeTerminals` | `false` | on, a built-in terminal opened while HTTP capture is running gets `HTTP_PROXY` / `HTTPS_PROXY` pointing at the capture proxy | `first-run-record-terminal` (the setting), `—` (the environment) | + +## 2.9 `redaction` + +| Option | Default | Behaviour | Proof | +|---|---|---|---| +| `entropyThreshold` | `4.5` | Shannon entropy above which a token is flagged | `redaction` | +| `minLength` | `20` | shorter tokens are never entropy-flagged | `redaction` | +| `denylist` | `[]` | substring match, or `/pattern/` for regex; a malformed regex is ignored | `redaction` | +| `allowlist` | `[]` | suppresses an entropy match | `redaction` | + +Plugin-contributed denylist entries merge in and unregister cleanly +(`redaction`); masking preserves span length (`redaction`, `secret-redaction`). + +## 2.10 `deconfliction` — removed + +The blue-team webhook was removed (`98a7bba`); no code reads a `deconfliction` +block. A config that still has one carries it through unread (Part 3). + +## 2.11 `cloudShare` / `marketplace` — removed + +Cloud share (`1a77090`) and the plugin marketplace were removed, and +`settings-ia` asserts both stay gone. No code reads either block; the evidence +bundle itself remains, in the one export control. + +## 2.12 `fileWatcher` / `processMonitor` / `agentTailer` (tuning; see `packs`) + +| Option | Default | Behaviour | Proof | +|---|---|---|---| +| *(runs with)* | `packs.hostMonitors` | starts only with the pack on + non-empty `watchPaths` + an engagement id; turning the pack off stops the watcher, in Settings or by opening a project with it off, and a save keeps one watcher rather than adding one | `file-watcher`, `watcher-restart`, `capture-packs` | +| `fileWatcher.watchPaths` | `[]` | empty is a no-op even with the pack on | `file-watcher` | +| `fileWatcher.ignorePatterns` | `[]` | added on top of the built-in ignores | `file-watcher` | +| *(runs with)* | `packs.hostMonitors` | off by default; polled on macOS, Linux and Windows and nowhere else. If the first process listing fails (BusyBox `ps` in a minimal container), a `process_monitor_ps_unavailable` event says why, once per capture start: the first, after a project close or switch, or when the pack is turned back on, not on every save (`restart()` in `src/main/services/process-monitor.ts`). A `ps` run that finishes after a restart or a stop records nothing | `capture-packs` (the pack's members), `process-monitor-restart` (the advisory, late runs); `—` (the platforms) | +| `processMonitor.pollMs` | `500` | poll cadence; floored at 200 ms, and at **2000 ms on Windows** where a cold PowerShell spawn is 800–1500 ms and a 500 ms cadence would stack calls | `config-options` (the default); `—` (the floors) | +| `processMonitor.ignoreCommands` | `[]` | leading-token match, on top of the built-ins | `process-monitor` | +| *(runs with)* | `packs.hostMonitors` | the connection monitor states its SYN-scan blind spot (`connection_capture_started`) once per capture start: the first, after a project close or switch, or when the pack is turned back on, not on every save. A socket-table read that finishes after a restart or a stop records nothing | `connection-monitor-restart` | +| `connectionMonitor.pollMs` | `2000` | socket-table poll cadence of the connection monitor, which runs with `packs.hostMonitors`; floored at 1000 ms | `—` | +| *(runs with)* | `packs.aiAgents` | agent transcripts are sensitive: off until the operator turns the pack on for the project; a `.redlog-app-root` marker still opts a repo out | `capture-packs`, `agent-tailer` | +| `agentTailer.emitThinking` | `false` | thinking blocks are excluded unless turned on | `agent-tailer` | +| *(runs with)* | `packs.windowsOutput` | Windows: follows `~/.redlog/transcripts/*.txt` written by `start-transcript-hook.ps1` and emits each command once; opening a project with the pack off stops following, and a project switch keeps one follower | `powershell-transcript`, `watcher-restart`, `capture-packs` | + +## 2.12a `packs` — optional capture (Spec 035) + +| Option | Default | Behaviour | Proof | +|---|---|---|---| +| `packs.hostMonitors` | `false` | runs the process, connection, file and clipboard monitors | `capture-packs` | +| `packs.aiAgents` | `false` | runs the Claude Code / Codex / OpenCode transcript tailers | `capture-packs` | +| `packs.windowsOutput` | `false` | runs the PowerShell Start-Transcript follower | `capture-packs` | + +A pack runs only when it is on **and** its bundled plugin (`pack-host-monitors`, +`pack-ai-agents`, `pack-windows-output`) is active; disabling the plugin in +Plugins removes the pack's sources from Settings and capture health. The +sources' own `enabled` keys were removed and are not read (`capture-packs`). + +## 2.13 `loot` + +| Option | Default | Behaviour | Proof | +|---|---|---|---| +| `loot.disabledRules` | `['jwt', 'generic_api_key']` | rule ids (built-in `type`, or `pluginId:patternName`) not recorded as loot; a switched-off rule still matches, so its values are still masked in the record and in exports. `[]` records every rule | `loot-rule-switches`, `loot-rules-group` | + +What any rule records (Spec 031): every occurrence is masked; one loot row per +secret per target, compared on the full value; a failed write is reported in +Capture Health and retried on the next sighting (`loot-correctness`). + +--- + +# Part 3 — Config file handling + +| Case | Behaviour | Proof | +|---|---|---| +| no `config.yaml` | full defaults | `config`, `config-options` | +| corrupt / empty YAML | falls back to defaults, never throws | `—` | +| partial file | merges over defaults; siblings keep their defaults | `config` | +| arrays | **replace**, never concatenate | `—` | +| explicit `[]` | wins over a non-empty default | `—` | +| explicit `false` / `0` | kept, not treated as "unset" | `—` | +| unknown key | carried through, not dropped | `—` | +| save → load | round-trips (asserted for `engagement.id` and `network.whitelist`) | `config` | +| `saveConfig` into a missing directory | creates it | `—` | + +Each `—` row is what `loadConfig` / `saveConfig` do today (a `deepMerge` over +the defaults, a catch-all fallback, `mkdirSync({ recursive: true })`), but no +test asserts it. + +No legacy key is migrated. Spec 006 (the pre-release contract reset) removed +the migrations: `loadConfig` merges the file over the defaults and nothing +else, so an old key such as `network.vpnIPs` or `scope.enforcement` is carried +through unread. + +--- + +# Part 4 — Renderer surfaces + +| Surface | Covered | Proof | +|---|---|---| +| every view mounts with one event of each agent type | crash-on-open regressions | `renderer-smoke` | +| HUD verdict colour / label / flash / scale / emphasis / pass-through / mark buttons | §1.8, §2.5 | `—` (`renderer-smoke` mounts the HUD and drives expand and hide; `e2e` `hud-overlay.spec.ts` covers its geometry) | +| dashboard IP card: three states, both addresses, hints, provider error | §1.8 | `—` (`renderer-smoke` mounts it) | +| status bar: IP state, scope count, recording × capture-health dot, loot | §1.8 | `—` (`renderer-smoke`: the clock's title; `e2e` `recording-flow.spec.ts`: the `PAUSED` and `Waiting for events…` labels; `loot-count`: the number it reads) | +| scope violations list: not-set / all-in-scope states, the standing count, paging, withdrawn rows, the truncation note, chain length | §1.8 | `scope-recompute-run` (the read model), `scope-status` (the count, all-in-scope, the truncation note), `e2e` (`scope-recompute.spec.ts`), `list-keyboard` (the list keyboard contract) | +| HUD live-update subscriptions (Settings → open overlay) | §1.8 | `—` | +| Settings controls: each toggle / number field writes the right config key, with its UI-layer coercion | §2.x | `—` (`scope-entry-ui`: the create card's excludes and the project rename; `overlay-pass-through`: the pass-through toggle) | +| process-monitor poll cadence + platform floors | §2.12 | `—` | +| one severity scale across both alarms; nothing ranks or folds them | §1.7.1 | `alert/ip-policy`, `alert/scope-policy` | +| Timeline dot shape by event type and marker severity, not by authority | §1.7.1 | `e2e` (`timeline-encoding.spec.ts`) | +| `authority` on each verdict; `insertEvent` stamps none of its own | §1.7.1 | `alert/ip-policy`, `alert/scope-policy`; `—` (the chained events) | +| `scope_violation` payload: `distance` + `authority` per rung | §1.7 | `—` (the live payload); `scope-recompute-run` (the recompute's rows) | +| registrable-domain table (built-in two-label suffixes; not configurable) | §2.3 | `alert/scope-policy` | +| settings search index covers every group | option discoverability | `settings-search` | +| timeline lanes, axis, clustering, modes, wheel, geometry | timeline behaviour | `timeline-*` | +| capture onboarding + readiness | empty/partial states | `capture-onboarding-render`, `capture-readiness`; `—` (the shared `EmptyState`) | +| keyboard shortcuts, split pane, lane visibility/colours | UI plumbing | `shortcuts`, `lane-*`; `—` (the split pane) | + +Playwright (`npm run e2e`) runs against a built app. Among others it covers +the HUD overlay window's geometry, recording pause, project flow, command IO, +scope entry and recompute, transcript view and timeline presentation (the +specs are in `e2e/`). The marketplace and cloud-share flows were removed with +their features (`5603ae9`, `1a77090`). + +No e2e spec drives the IP alert path through real windows: nothing pushes a +verdict and checks the HUD frame, the flash or the status bar, so §1.8 stays +`—` for every surface. + +**End to end, scope:** `e2e/scope-recompute.spec.ts` seeds commands through +the local API and then changes the scope, so the retroactive half of §1.6–1.7 +— rows flagged or withdrawn by a recompute — is covered end to end. A live +violation is not: the shell hook's own endpoint (`/api/events`, which +`recording-pause.spec.ts` posts to) could raise one, but no spec checks one on +screen. That part stays at the unit level plus the manual walk-through in §5.3. + +--- + +# Part 5 — Manual QA + +What no unit test can reach. Run before a release, or after touching the alert +path. + +### 5.1 Trigger an EXPOSED alert end to end + +1. Settings ▸ Network: put your real egress IP (`curl ifconfig.me`) in the + **blacklist**, leave the whitelist empty. Set `confirmations: 1` to skip the + hold. +2. Disconnect the VPN. +3. Within `checkInterval` seconds the HUD frame turns red, flashes, and reads + `EXPOSED`; the dashboard card shows a red pulsing dot and the "check your + VPN" hint. +4. Reconnect the VPN → back to `SAFE`. With only a blacklist set the verdict is + `presumed_safe` (A-3), which the surfaces show as `SAFE` (§1.8). +5. Set `overlay.flashOnExposed: false` and repeat: still red, no flashing. + +### 5.2 Trigger the off-profile (dropped-VPN) case + +Whitelist your VPN exit CIDR, blacklist your home IP, then connect through a +third network (phone hotspot). This is the A-9 case from §1.1: the verdict is +`off_profile`, which the surfaces show as `EXPOSED` / red — **never** green. The +card's hint then says the address is on the Exposed IP list, which it is not +(G-UI2). + +### 5.3 Trigger a scope violation + +1. Settings ▸ Scope: `targets: *.app.example.com, 192.168.1.10`, warnings on. +2. Run `curl vpn.example.com` in a hooked shell → D2 violation: the status bar + reads `SCOPE 1`, a red row appears in Scope & Evidence, and the + `system/scope_violation` event carries `distance: adjacent_domain`, + `authority: inferred`. +3. Run `nmap 192.168.1.55` → D2 again (`distance: adjacent_subnet`), because the + single-IP entry expands to `/24`. +4. Run `curl google.com` and `nmap 8.8.8.8` → no alert (D3, by design); the + commands are still in the timeline. No setting makes D3 alert (§1.7). +5. Add `dc01.app.example.com` to `excludeTargets` and turn warnings off + (`scope.warnOnViolation: false`). Every save schedules a recompute, and this + one runs under the new floor: the banner counts the two D2 violations from + steps 2–3 as withdrawn, and the status bar goes back to `SCOPE OK`. Hit + `dc01.app.example.com` → still alerts (`distance: excluded`, + `authority: fact`). Hit `vpn.example.com` and `192.168.1.55` again → silent. + +### 5.4 `overlay.showInDock` (macOS) + +Show the HUD with the option on → Dock icon stays. Turn it off → Dock icon +disappears while the HUD keeps working. **This step cannot be automated**: the +Dock change goes through the macOS activation policy asynchronously and +`app.dock.isVisible()` keeps reporting the old value in an automated run, so an +e2e assertion here would be a false green rather than coverage. + +### 5.5 `terminal.maxCastBytes` + +Set a small cap (e.g. 4096), run `yes | head -100000` in the built-in terminal, +and confirm the `.cast` ends with the `[redlog: cast truncated at N bytes]` +marker and the event records `castTruncated`. + +### 5.6 Wi-Fi SSID (`network.showWifiName`) + +Toggle it on, accept the Location Services prompt, and confirm the HUD shows the +real SSID instead of a generic `Wi-Fi`. Toggle it back off — the HUD must drop +to `Wi-Fi` straight away, not at the next poll. The SSID names the building you +are sitting in and the HUD is the surface most likely to be in frame on a +screenshot, so "off" has to mean off everywhere, immediately. + +--- + +# Part 6 — Known gaps + +| ID | Gap | Impact | +|---|---|---| +| **G-UI1** | `overlay.showInDock` is the last manual-only option. `app.dock.hide()` changes the macOS activation policy asynchronously and the result does not surface through `app.dock.isVisible()` in an automated run, so the assertion would be a false green. | One setting, verified by §5.4. Of the other three once filed here, `cloudShare.authToken` and `marketplace.defaultRegistryUrl` went with their features (`1a77090`, `5603ae9`), and `processMonitor.pollMs` is decided synchronously in `restart()`, though no test pins its floors (§2.12). | +| **G-UI2** | The renderer gets three states for five verdicts (`verdictToSafety()` in `src/main/services/alert-runtime.ts`): `presumed_safe` shows as `SAFE`, `off_profile` as `EXPOSED` with a hint that the address is on the Exposed IP list, and a stale reading as `IP?` with a hint to set lists that may already be set (§1.8). | The policy's distinctions (§1.1, §1.2.1) stop at the main process, and two of the hints point at the wrong fix. | +| **G-UI3** | No test asserts what an alert surface renders: HUD label and frame, card, status bar dot and label, live updates (§1.8, Part 4). | A display regression on a correct verdict passes the suite. | +| **G-IP1** | An IPv6 CIDR entry in either list never matches, not even its own address; only a bare IPv6 address matches, and only exactly. An IPv6 CIDR with a prefix of 32 or less is read as IPv4 arithmetic instead, so it can match IPv4 addresses (`matchesCIDR` in `src/core/alert/policies.ts`, §1.2). | An IPv6 exit or home range gives no verdict, and a short IPv6 prefix can put IPv4 addresses on a list. | +| **G-S1** | A CIDR entry narrower than /24 still seeds the /24 of its base address (`buildScopeIndexes`, §1.6). | `10.0.0.0/28` makes `10.0.0.200` `adjacent_subnet`: an inferred boundary the operator did not state. | +| **G-S2** | Every in-scope hit is chained as a `scope_violation` row with `distance: in_scope` (`ChainEmitter`, §1.7), and the Timeline titles each one as a violation (`eventTitle.ts`). | Adherence records read as violations to anyone looking at the Timeline or the raw chain. | +**Fixed.** These IDs stay listed because code comments cite them: + +| ID | What it was | Fixed by | Proof | +|---|---|---|---| +| **G-IP2** | The HTTP path took `data.ip ?? data.origin ?? String(data)` unchecked, so a provider answering another JSON shape yielded the address `[object Object]`, which with only a blacklist set read `presumed_safe`, shown as `SAFE`. A throw from `getInternalIP()` left the in-flight flag set and stopped every later check. | #169: only one IPv4 address counts as an answer (§1.4); the flag is cleared in a `finally` | `ip-signal-producer` | +| **G-S3** | The Scope page cut its 500-row window before dropping in-scope rows, so after enough in-scope traffic it listed none of the standing violations and said "All commands within scope" while the status bar read `SCOPE `. | #168: in-scope and replaced rows are left out before the cut, and the page takes its totals from the chain (§1.8) | `scope-recompute-run`, `scope-status` | +| **G-CB1** | What was copied while recording was paused, and still on the clipboard, was captured at the first poll after resume. | #167: resuming seeds the clipboard (§2.7) | `clipboard-monitor` | +| **G-CB2** | Each restart armed its interval after an `await`, and config:save restarts the monitor twice in one tick, so a save leaked a poller and turning Host monitors off did not stop clipboard capture. | #167: only the newest restart arms the poll (§2.7) | `clipboard-monitor` | + +The same late-continuation guard went into the file watcher and the transcript +follower (#167, `watcher-restart`), the connection monitor (#165, +`connection-monitor-restart`) and the process monitor (#166, +`process-monitor-restart`), §2.12. + +Adding a config option? Add its default to the table in `config-options` and +its behaviour to the relevant Part 2 section. If it changes what the operator +sees, it lands in G-UI3 until a surface test exists. + +The first of those is **enforced, not requested**: `config-options` walks the +real default config and fails on any option the table does not name (and on any +table entry whose option has been removed). Its first run, with the table taken +from this document as it stood, found drift both ways: nine options the +defaults held that Part 2 never listed (`engagement.activeTarget`, +`network.offline`, `scope.warnOnViolation`, `scope.personalDomains`, +`screenshot.diffThreshold`, `screenshot.captureOnCommand`, `httpCapture.port`, +`httpCapture.routeTerminals`, `connectionMonitor.pollMs`), and fifteen rows for +options no code reads (`engagement.name`, `network.staleAfter`, +`scope.alertFloor`, `scope.proximityBits`, `scope.publicSuffixes`, and the +`deconfliction`, `cloudShare` and `marketplace` blocks). That is exactly the +drift a sentence in a doc cannot prevent. diff --git a/e2e/hud-overlay.spec.ts b/e2e/hud-overlay.spec.ts index c720b990..57ed7a40 100644 --- a/e2e/hud-overlay.spec.ts +++ b/e2e/hud-overlay.spec.ts @@ -78,33 +78,6 @@ test.describe.serial('HUD overlay geometry', () => { expect(back.x, 'v0.9.3 leaves x where widening put it').toBe(wide.x) }) - test.skip('content still fits at HUD scale 1.5 with emphasised IP', async () => { - // SKIPPED: the fix for this raised the width ceiling above 720px, which - // was part of the v0.9.4 HUD change the operator reported as wrong. The - // clipping at scale 1.5 is real but is filed rather than patched — see - // docs/AUDIT-2026-08-08.md. Re-enable alongside a verified fix. - // The widest configuration the Settings UI can produce. Content alone - // needs ~726px here, which the hard 720px ceiling clips. - await main.evaluate(async () => { - const api = (window as unknown as { redlog: { config: { get: () => Promise>; save: (c: unknown) => Promise } } }).redlog.config - const cfg = await api.get() - const ov = (cfg.overlay ?? {}) as Record - await api.save({ ...cfg, overlay: { ...ov, scale: 1.5, emphasizeExternalIp: true } }) - }) - await hud.waitForTimeout(700) - - const b = await bounds(app) - expect(b.width, 'window should be allowed past the old 720px cap').toBeGreaterThan(720) - - const fit = await hud.evaluate(() => { - const inner = document.getElementById('root') as HTMLElement - return { need: inner.scrollWidth, have: document.documentElement.clientWidth } - }) - expect(fit.need, `content needs ${fit.need}px, window is ${fit.have}px — clipped`) - .toBeLessThanOrEqual(fit.have) - await hud.screenshot({ path: 'e2e/screenshots/hud-scale-1.5.png' }) - }) - test('reported width covers the rendered content', async () => { const fit = await hud.evaluate(() => { // The measured element is the one autosize reports on. diff --git a/e2e/project-flow.spec.ts b/e2e/project-flow.spec.ts index 009ada76..fd66bcd3 100644 --- a/e2e/project-flow.spec.ts +++ b/e2e/project-flow.spec.ts @@ -1,10 +1,6 @@ import { test, expect, type ElectronApplication, type Page } from '@playwright/test' -import { dirname, join } from 'node:path' -import { mkdirSync } from 'node:fs' import { launchWithTempHome, openTestProject, type RedLogBridge } from './helpers' -const SCREENSHOT_DIR = join(__dirname, 'screenshots') - // Serial + shared electronApp so the three tests reuse one ~2s launch: // Test 1 opens a project, Tests 2 & 3 exercise it. Nothing here should run // against the operator's real ~/.redlog — launchWithTempHome swaps HOME first. @@ -24,6 +20,7 @@ test.describe.serial('project flow', () => { test('creates a project and opens the dashboard', async () => { // Fresh HOME → no projects yet, so ProjectPicker owns the window. + await expect(page).toHaveTitle(/RedLog/) await expect(page.locator('[data-testid="project-picker"]')).toBeVisible() const project = await openTestProject(page, 'e2e-flow') @@ -35,8 +32,7 @@ test.describe.serial('project flow', () => { const viewRoot = page.locator('[data-testid="view-root"]') await expect(viewRoot).toHaveAttribute('data-view', 'dashboard') - mkdirSync(SCREENSHOT_DIR, { recursive: true }) - await page.screenshot({ path: join(SCREENSHOT_DIR, 'project-opened.png') }) + await page.screenshot({ path: test.info().outputPath('project-opened.png') }) }) test('⌘1..9 switches views (v0.6.67 focus fix)', async () => { diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts deleted file mode 100644 index 45a47478..00000000 --- a/e2e/smoke.spec.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { test, expect, _electron as electron, type ElectronApplication, type Page } from '@playwright/test' -import { existsSync, mkdirSync } from 'node:fs' -import { dirname, join } from 'node:path' - -const REPO_ROOT = join(__dirname, '..') -const MAIN_ENTRY = join(REPO_ROOT, 'out', 'main', 'index.js') -const SCREENSHOT_PATH = join(__dirname, 'screenshots', 'smoke.png') - -test('app launches and shows the RedLog window', async () => { - if (!existsSync(MAIN_ENTRY)) { - throw new Error( - `Electron build output not found at ${MAIN_ENTRY}. ` + - `Run "npm run build" before "npm run e2e".` - ) - } - - let app: ElectronApplication | undefined - try { - app = await electron.launch({ - args: [MAIN_ENTRY], - cwd: REPO_ROOT, - // Some CI/headless envs need this; harmless locally. - env: { ...process.env, NODE_ENV: 'test' } - }) - - const window: Page = await app.firstWindow() - await window.waitForLoadState('domcontentloaded') - - // index.html sets RedLog; the main process does not - // override BrowserWindow.title, so document.title wins. - const title = await window.title() - expect(title).toContain('RedLog') - - mkdirSync(dirname(SCREENSHOT_PATH), { recursive: true }) - await window.screenshot({ path: SCREENSHOT_PATH }) - } finally { - if (app) await app.close() - } -}) diff --git a/hooks/redlog-relay.py b/hooks/redlog-relay.py index cc53cd39..b004eb8a 100644 --- a/hooks/redlog-relay.py +++ b/hooks/redlog-relay.py @@ -433,6 +433,11 @@ def sync_size(): except ProcessLookupError: completed = True if completed: + # The empty poll above may predate completion: the command + # can write and close while this process is descheduled. + # Observe the boundary first, THEN prove its queue is empty. + if select.select([leg["master"] for leg in legs], [], [], 0)[0]: + continue # No queued bytes remain, and the foreground command cannot # produce more. Closing our copies now cannot flush its body. # If a background writer survives, keep forwarding until EOF diff --git a/package-lock.json b/package-lock.json index 5a22881e..18a1605b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "redlog", - "version": "0.18.2", + "version": "0.18.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "redlog", - "version": "0.18.2", + "version": "0.18.3", "license": "MIT", "dependencies": { "@dagrejs/dagre": "^3.1.1", diff --git a/package.json b/package.json index 5deee442..c0432d59 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "redlog", - "version": "0.18.2", + "version": "0.18.3", "description": "Red Team Operation Log", "author": "guan4tou2", "repository": { diff --git a/specs/052-terminal-auto-capture/verification.md b/specs/052-terminal-auto-capture/verification.md index 349447f2..47ec2899 100644 --- a/specs/052-terminal-auto-capture/verification.md +++ b/specs/052-terminal-auto-capture/verification.md @@ -550,3 +550,18 @@ every expected body/status: total 48,698ms, mean 483ms, p95 536ms, max 604ms (including driver settle). All terminal transparency and degradation tests pass. macOS is now included in PR unit/build CI so this native regression gates merging, rather than first being observed after a release tag. + +### Follow-up: readiness must be sampled after completion + +PR #338's unchanged 100-command capture check caught one missing stderr body +on macOS after v0.18.2 publication. The preceding empty select result could +predate command completion: a descheduled recorder could resume after bytes +arrived and the shell closed. A deterministic scheduling fixture was RED +(empty output), then GREEN after rechecking queued bytes after the completion +marker. The release was marked prerelease with this limitation; this correction +prepares v0.18.3. No original trace or earlier failure is relabeled retrospectively. + +Correction validation: all five ordered local gates and production build PASS; +3,613 tests PASS / 24 explicit SKIP. All five native PTY boundary cases pass, +including the forced stale-empty-poll window; the 100-command, degradation and +terminal transparency checks remain intact and pass. diff --git a/test/bundle-verifier-packaging.test.ts b/test/bundle-verifier-packaging.test.ts deleted file mode 100644 index fe7d0a84..00000000 --- a/test/bundle-verifier-packaging.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -// A packaged build resolves shipped files under /; dev and tests -// resolve them from the repo, which masked that tools/redlog-verify.py was -// never packaged — every bundle exported from an installed RedLog shipped -// without its verifier. These pin the packaging side of the contract. -import { describe, it, expect } from 'vitest' -import fs from 'fs' -import path from 'path' - -const ROOT = path.resolve(__dirname, '..') -const read = (p: string): string => fs.readFileSync(path.join(ROOT, p), 'utf-8') - -describe('the evidence-bundle verifier ships in packaged builds', () => { - it('electron-builder copies tools/redlog-verify.py to /tools/', () => { - expect(read('electron-builder.yml')).toMatch(/- from: tools\/redlog-verify\.py\s+to: tools\/redlog-verify\.py/) - }) - - it('the release-only packaged resource check requires it', () => { - expect(read('scripts/verify-packaged-resources.mjs')).toContain("'tools/redlog-verify.py'") - }) -}) diff --git a/test/design-rules.test.ts b/test/design-rules.test.ts index 8bd688b6..404870bb 100644 --- a/test/design-rules.test.ts +++ b/test/design-rules.test.ts @@ -2,43 +2,10 @@ import { describe, it, expect } from 'vitest' import fs from 'fs' import path from 'path' -// UIUX-STANDARD §21: the token/contrast rules, parsed from source so a reviewer -// checks "test exists and is green", not "I eyeballed it". Companion to -// lane-colours.test.ts (which owns the lane-uniformity rule). Tokens are read -// out of the CSS @theme block rather than imported — a colour assertion -// shouldn't pull in PostCSS. +// UIUX-STANDARD §21 source conventions. Numerical token contrast belongs in +// design-tokens.test.ts; lane-colours.test.ts owns lane uniformity. const ROOT = path.join(__dirname, '..') -const CSS = fs.readFileSync( - path.join(ROOT, 'src', 'renderer', 'src', 'styles', 'index.css'), 'utf-8' -) - -/** WCAG relative luminance + contrast ratio. */ -function lum(hex: string): number { - const c = hex.replace('#', '') - const ch = [0, 2, 4].map((i) => parseInt(c.slice(i, i + 2), 16) / 255) - const lin = ch.map((v) => (v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4)) - return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2] -} -function contrast(a: string, b: string): number { - const la = lum(a), lb = lum(b) - const [hi, lo] = la > lb ? [la, lb] : [lb, la] - return (hi + 0.05) / (lo + 0.05) -} - -/** A named `redlog` token's hex, e.g. tok('text-dim') / tok('on-accent'). */ -function tok(name: string): string { - const re = new RegExp(`--color-redlog-${name.replace(/-/g, '\\-')}:\\s*(#[0-9a-fA-F]{6})`) - const m = re.exec(CSS) - if (!m) throw new Error(`token '${name}' not found in index.css`) - return m[1].toLowerCase() -} -/** The amber-400 fill (#d4ac5a) that `on-warn` sits on. */ -function amber400(): string { - const m = /--color-amber-400:\s*(#[0-9a-fA-F]{6})/.exec(CSS) - if (!m) throw new Error('amber-400 not found') - return m[1].toLowerCase() -} function rendererFiles(): string[] { const out: string[] = [] @@ -54,36 +21,6 @@ function rendererFiles(): string[] { } const RENDERER = rendererFiles().map((f) => ({ f: path.relative(ROOT, f), src: fs.readFileSync(f, 'utf-8') })) -// Fills that carry text, each paired with its `on-*` token (§4: every solid -// colour uses a dark on-colour; amber is the `warn` fill). -const FILLS: Array<[string, string]> = [ - [tok('accent'), tok('on-accent')], - [tok('danger'), tok('on-danger')], - [amber400(), tok('on-warn')] -] - -describe('UIUX §21 · token contrast', () => { - it('secondary text clears 4.5:1 on the window ground', () => { - expect(contrast(tok('text-dim'), tok('bg'))).toBeGreaterThanOrEqual(4.5) - }) - - it('every fill has an on-colour that clears 4.5:1', () => { - for (const [fill, on] of FILLS) { - expect(contrast(on, fill), `on-colour ${on} on fill ${fill}`).toBeGreaterThanOrEqual(4.5) - } - }) - - it('white on any fill fails AA — the reason the on-colours are dark', () => { - for (const [fill] of FILLS) { - expect(contrast('#ffffff', fill), `white on ${fill}`).toBeLessThan(4.5) - } - }) - - it('brand accent and danger are genuinely different colours (§1)', () => { - expect(tok('accent')).not.toBe(tok('danger')) - }) -}) - describe('UIUX §21 · source rules', () => { it('no text-white anywhere in the renderer', () => { const bad = RENDERER.filter(({ src }) => /\btext-white\b/.test(src)).map((x) => x.f) diff --git a/test/design-tokens.test.ts b/test/design-tokens.test.ts index 396a13e9..39330440 100644 --- a/test/design-tokens.test.ts +++ b/test/design-tokens.test.ts @@ -85,6 +85,15 @@ describe('token palette', () => { expect(failures).toEqual([]) }) + it('keeps text on solid action fills above 4.5:1', () => { + const t = redlogTokens() + const amber = /--color-amber-400:\s*(#[0-9a-fA-F]{6})/.exec(INDEX_CSS)?.[1] + expect(amber, 'warning fill is defined').toBeTruthy() + for (const [fill, on] of [[t.accent, t['on-accent']], [t.danger, t['on-danger']], [amber!, t['on-warn']]]) { + expect(contrast(on, fill), `${on} on ${fill}`).toBeGreaterThanOrEqual(4.5) + } + }) + it('keeps each step of the text ramp visibly apart', () => { // Contrast alone does not make a hierarchy: `text-dim` and `text-faint` // were 2.7 L* apart, which is at the edge of a just-noticeable difference. diff --git a/test/export-mask-toggle.test.tsx b/test/export-mask-toggle.test.tsx index a555f426..52c59bd9 100644 --- a/test/export-mask-toggle.test.tsx +++ b/test/export-mask-toggle.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' -import { render, cleanup, screen, fireEvent, waitFor } from '@testing-library/react' +import { render, cleanup, screen, fireEvent, waitFor, within } from '@testing-library/react' import { I18nProvider } from '../src/renderer/src/i18n' import { ExportMenu } from '../src/renderer/src/components/ExportMenu' @@ -56,11 +56,10 @@ function installBridge(failResolve = false): void { const open = (): void => { fireEvent.click(screen.getByLabelText('Export')) } const clickBundle = (): void => { fireEvent.click(screen.getByText('Evidence bundle (with verifier)')) } const clickConfirm = async (): Promise => { - await waitFor(() => expect(screen.getByText('Export')).toBeTruthy()) - const buttons = screen.getAllByText('Export') - const confirm = buttons.find(b => b.closest('button')?.classList.contains('bg-red-600')) - expect(confirm).toBeTruthy() - fireEvent.click(confirm!.closest('button')!) + const dialog = await screen.findByRole('dialog') + const confirm = await within(dialog).findByTestId('export-confirm') + expect((confirm as HTMLButtonElement).disabled).toBe(false) + fireEvent.click(confirm) } describe('ExportMenu — out-of-scope mask override (A2)', () => { @@ -94,8 +93,7 @@ describe('ExportMenu — out-of-scope mask override (A2)', () => { open() clickBundle() expect((await screen.findByRole('alert')).textContent).toContain('selection-failed') - const confirm = screen.getAllByText('Export').find((node) => node.closest('button')?.classList.contains('bg-red-600')) - expect(confirm).toBeUndefined() + expect(screen.queryByTestId('export-confirm')).toBeNull() expect(executeCalls).toBe(0) }) }) diff --git a/test/plugin-code-host-removed.test.ts b/test/plugin-code-host-removed.test.ts deleted file mode 100644 index dfdfe292..00000000 --- a/test/plugin-code-host-removed.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -// Spec 027 — RED. -// -// The 🔴 utility-process host ran nothing: its `start` and `stop` were empty -// after mcpTools, its only code contribution, was removed in v0.12. What stayed -// was everything around it — a services object in main (including outbound -// `fetch`), a capability checker only a test called, a child script shipped in -// every packaged build and never forked, and two contribution types, -// `exporters` and `monitors`, that marked a plugin privileged, asked the -// operator to grant it trust, and then executed nothing. -// -// The trust gate itself stays: it guards `tailers`, the one privileged -// contribution with a live path. - -import { describe, expect, it } from 'vitest' -import fs from 'fs' -import path from 'path' -import { validateManifest, tierOf } from '../src/core/plugins/manifest' - -const ROOT = path.join(__dirname, '..') -const read = (file: string): string => fs.readFileSync(path.join(ROOT, file), 'utf8') - -const base = { id: 'sample-plugin', name: 'Sample', version: '1.0.0', redlogApi: 1 } - -describe('retired code contributions are refused, not silently inert', () => { - for (const retired of ['exporters', 'monitors']) { - it(`rejects a manifest contributing ${retired}, saying why`, () => { - const parsed = validateManifest({ ...base, contributes: { [retired]: './index.js' } }, '/tmp/x') - expect(parsed.ok).toBe(false) - if (parsed.ok) return - expect(parsed.error).toMatch(new RegExp(retired)) - expect(parsed.error).toMatch(/not supported/) - }) - } - - it('rejects a manifest contributing mappers: nothing applies them (POST /api/ingest was never built)', () => { - const parsed = validateManifest({ ...base, contributes: { - mappers: [{ id: 'm', version: '1', agentType: 'scanner', fields: { host: '$.h' } }] - } }, '/tmp/x') - expect(parsed.ok).toBe(false) - if (parsed.ok) return - expect(parsed.error).toMatch(/contributes\.mappers is not supported/) - expect(parsed.error).toMatch(/api\/events/) - }) - - it('still treats tailers as privileged, so the trust gate keeps guarding it', () => { - expect(tierOf({ ...base, contributes: { tailers: './tailer.js' } } as Parameters[0])).toBe('privileged') - }) -}) - -describe('the empty code host and what surrounded it are gone', () => { - it('has no host module', () => { - expect(fs.existsSync(path.join(ROOT, 'src/core/plugins/host.ts'))).toBe(false) - }) - - it('does not build a services object for a host in main', () => { - const main = read('src/main/index.ts') - expect(main).not.toMatch(/createPluginHost|setPluginHost/) - }) - - it('does not ship a child script nothing forks', () => { - expect(fs.existsSync(path.join(ROOT, 'resources/plugin-runner.js'))).toBe(false) - expect(read('electron-builder.yml')).not.toMatch(/plugin-runner/) - }) -}) diff --git a/test/plugins.test.ts b/test/plugins.test.ts index 19760cb8..9536f843 100644 --- a/test/plugins.test.ts +++ b/test/plugins.test.ts @@ -344,3 +344,31 @@ describe('tailer contributions respect the trust gate', () => { warn.mockRestore(); err.mockRestore() }) }) + +const manifestBase = { id: 'sample-plugin', name: 'Sample', version: '1.0.0', redlogApi: 1 } + +describe('retired code contributions are refused, not silently inert', () => { + for (const retired of ['exporters', 'monitors']) { + it(`rejects a manifest contributing ${retired}, saying why`, () => { + const parsed = validateManifest({ ...manifestBase, contributes: { [retired]: './index.js' } }, '/tmp/x') + expect(parsed.ok).toBe(false) + if (parsed.ok) return + expect(parsed.error).toMatch(new RegExp(retired)) + expect(parsed.error).toMatch(/not supported/) + }) + } + + it('rejects a manifest contributing mappers: nothing applies them (POST /api/ingest was never built)', () => { + const parsed = validateManifest({ ...manifestBase, contributes: { + mappers: [{ id: 'm', version: '1', agentType: 'scanner', fields: { host: '$.h' } }] + } }, '/tmp/x') + expect(parsed.ok).toBe(false) + if (parsed.ok) return + expect(parsed.error).toMatch(/contributes\.mappers is not supported/) + expect(parsed.error).toMatch(/api\/events/) + }) + + it('still treats tailers as privileged, so the trust gate keeps guarding it', () => { + expect(tierOf({ ...manifestBase, contributes: { tailers: './tailer.js' } } as Parameters[0])).toBe('privileged') + }) +}) diff --git a/test/relay-pty-boundary.test.ts b/test/relay-pty-boundary.test.ts index 7103e472..f4af2c85 100644 --- a/test/relay-pty-boundary.test.ts +++ b/test/relay-pty-boundary.test.ts @@ -5,6 +5,60 @@ import os from 'node:os' import path from 'node:path' describe.skipIf(process.platform === 'win32')('automatic relay PTY completion boundary', () => { + it('rechecks queued bytes after completion arrives during an empty readiness poll', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'redlog-relay-stale-poll-')) + const wrapper = ` +import importlib.util, os, sys, time +spec = importlib.util.spec_from_file_location('relay', 'hooks/redlog-relay.py') +relay = importlib.util.module_from_spec(spec) +spec.loader.exec_module(relay) +original = relay.select.select +injected = False +def select(*args): + global injected + ready = original(*args) + if not injected and not ready[0]: + injected = True + print('EMPTY_POLL', file=sys.stderr, flush=True) + deadline = time.monotonic() + 5 + while not os.path.exists(os.path.join(sys.argv[2], 'closed')): + if time.monotonic() > deadline: raise RuntimeError('completion was not supplied') + time.sleep(0.005) + return ready +relay.select.select = select +sys.exit(relay.pty_pipe(sys.argv[1:])) +` + const child = spawn('python3', ['-c', wrapper, '--dir', dir, '--err-fd', '3'], { stdio: ['ignore', 'pipe', 'pipe', 'pipe'] }) + const finished = new Promise((resolve, reject) => { child.once('error', reject); child.once('close', resolve) }) + let stdout = '', stderr = '', diagnostic = '' + child.stdout.on('data', b => { stdout += b }) + child.stderr.on('data', b => { diagnostic += b }) + child.stdio[3]?.on('data', b => { stderr += b }) + const writers: number[] = [] + try { + await expect.poll(() => fs.existsSync(path.join(dir, 'pts'))).toBe(true) + for (const name of fs.readFileSync(path.join(dir, 'pts'), 'utf8').split('\n')) writers.push(fs.openSync(name, 'w')) + fs.writeFileSync(path.join(dir, 'opened'), '') + await expect.poll(() => diagnostic).toContain('EMPTY_POLL') + // Reproduce descheduling after select saw no bytes, then completing the + // whole command before the recorder observes the completion marker. + fs.writeSync(writers[0], 'after-poll-out\n') + fs.writeSync(writers[1], 'after-poll-err\n') + for (const fd of writers.splice(0)) fs.closeSync(fd) + fs.writeFileSync(path.join(dir, 'closed'), '') + expect(await finished).toBe(0) + expect(stdout).toBe('after-poll-out\n') + expect(stderr).toBe('after-poll-err\n') + expect(JSON.parse(fs.readFileSync(path.join(dir, 'stdout.json'), 'utf8'))).toMatchObject({ text: 'after-poll-out\n', bytes: 15 }) + expect(JSON.parse(fs.readFileSync(path.join(dir, 'stderr.json'), 'utf8'))).toMatchObject({ text: 'after-poll-err\n', bytes: 15 }) + } finally { + for (const fd of writers) fs.closeSync(fd) + child.kill('SIGKILL') + await finished + fs.rmSync(dir, { recursive: true, force: true }) + } + }) + it('drains a command that closes both writers before the relay receives its handover', async () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'redlog-relay-fast-close-')) const child = spawn('python3', ['hooks/redlog-relay.py', 'pty', '--dir', dir, '--err-fd', '3'], { stdio: ['ignore', 'pipe', 'pipe', 'pipe'] })