Overview
The DefenseClaw TUI never holds mutable state the CLI cannot reproduce. Every write is invoked as an argv-visible subprocess — palette entries, panel action menus, and forms all go through the same code path. This is deliberate: SOC operators working in the TUI and CI pipelines running the CLI produce byte-identical audit trails, and there is no way to perform an action in the TUI that would go undocumented on the CLI.
The subprocess bridge
Source: internal/tui/command.go (CommandExecutor).
Key properties:
- Argv-visible — every action becomes a literal
exec.Command(binary, args...).ps auxfwwduring a scan shows the same command line the operator would have typed. - PTY-backed — subprocess output is streamed through a pty so progress spinners and TTY-aware prompts render correctly (
github.com/creack/pty). - Cancellable —
Ctrl+Cin the TUI sends SIGINT to the subprocess only; the TUI itself keeps running. - Sibling-resolved binary —
resolveSiblingBin("defenseclaw")prefers a sibling of the runningdefenseclaw tuiexecutable, falling back toPATH. A pipx install and a system package can coexist without the TUI accidentally invoking the wrong one.
What this buys you
| Concern | Resolution |
|---|---|
| Audit trail | Every action is logged at both the CLI layer (by defenseclaw) and the sidecar layer (by the REST call the CLI makes). The TUI adds nothing and hides nothing. |
| CI parity | Running the TUI's commands from CI produces the same rows in ~/.defenseclaw/audit.db and the same webhook notifications. |
| Reproducibility | Bug reports can include the exact subprocess command line; operators rarely have to guess what the TUI "did under the hood". |
| Safety | A buggy TUI build cannot corrupt state in a way the CLI can't; worst case, it invokes the wrong subcommand. |
How actions resolve arguments
When a palette entry has NeedsArg=true, the subprocess bridge fills the argument from (in order):
- The argument the palette prompted for.
- The currently-selected row in the panel that invoked the action.
- A panel-computed default (e.g.
scan skill --allresolves to no argument because it is scoped--all).
The resolver is covered by command_resolve_test.go — adding a new CmdEntry without a corresponding resolver test case breaks CI.
Auditing TUI-originated actions
Three complementary audit trails exist:
| Layer | What it captures |
|---|---|
| TUI log | ~/.defenseclaw/tui.log — every subprocess invocation (binary, args, exit code, duration) |
| CLI audit | The CLI writes an entry to ~/.defenseclaw/audit.db for every verb (scan, block, set, etc.) |
| Sidecar audit | ~/.defenseclaw/audit.db also receives a row from the sidecar's REST call path |
To confirm a suspected TUI action after the fact:
# Find recent subprocess invocations
tail -n 200 ~/.defenseclaw/tui.log | jq '{ts, cmd, args, exit_code}'
# Correlate with the audit DB
sqlite3 ~/.defenseclaw/audit.db \
"select event_type, verdict, rule_id, timestamp
from activity
where timestamp > datetime('now','-1 hour')
order by timestamp desc limit 20;"
correlation_id ties the two — the CLI emits it in its JSON output and the sidecar receives it as X-DefenseClaw-Correlation-Id on every REST call.
Deviations (intentional)
A handful of TUI behaviors are not 1:1 with the CLI. They are cosmetic, not functional:
- Live refresh (5s / 30s intervals) is TUI-only; the CLI has
status/audit statsbut doesn't poll. - Filter bars are TUI UI — they produce filtered views by passing extra flags to the CLI (
--severity,--type). - Fuzzy search in the palette has no CLI equivalent; it's a discovery aid.
None of these alter state or emit events on their own.
Parity tests
Parity is not a documentation promise — it's tested in CI:
cli_parity_test.go— everyCmdEntrymust haveCLIBinaryandCLIArgsset; the test executes a dry-run of each entry and asserts the CLI accepts the exact argv.command_test.go— asserts there are no duplicateTUINamevalues; operators never see two palette entries for the same action.command_resolve_test.go— asserts argument resolution for everyNeedsArg=trueentry.