Skip to content

feat(diagnose): add omac diagnose — explain why a sandboxed run failed#128

Open
nhuelstng wants to merge 8 commits into
mainfrom
worktree-improve-doctor
Open

feat(diagnose): add omac diagnose — explain why a sandboxed run failed#128
nhuelstng wants to merge 8 commits into
mainfrom
worktree-improve-doctor

Conversation

@nhuelstng

@nhuelstng nhuelstng commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

What

Adds omac diagnose, the reactive counterpart to omac doctor (doctor = "is my setup correct?"; diagnose = "why did my run fail?"). It reads the previously write-only audit trail back and correlates the sandbox's actual network decisions against the effective policy. It is pure, read-only inspection — it never launches a sandbox or contacts the network.

  • Surfaces what the sandbox blocked — groups net.decision denials from audit.jsonl (structured, persistent, CI-safe), scoped to the last run.
  • Explains non-obvious clashes — a host in allow_domain yet DENIED (names the shadowing deny/learned/hard-deny rule); unused/typo allow_domain entries; DNS failures.
  • Names the invisible blind spots — proxy-unaware tools (JVM/gradle connecting directly), raw-TCP/SSH (allow_tcp_connect), and filesystem/socket/loopback denials (docker.sock, local services) that leave no trace in the network log.
  • --probe host[:port] — static admission check reusing the real filter (no DNS, no dialing, no sandbox launch): reports the HTTP(S)-via-proxy path and the raw-TCP path; loopback-aware. The verdict cannot drift from runtime behavior because it evaluates the same netproxy filter the sandbox enforces.
  • Focused output — one-line status → problems (with fixes) → collapsed advisories; -v expands everything; --json for a shareable bundle.
  • omac doctor gains the existing profileaudit security lint (advisory) and points at diagnose.

Why

Every network decision omac makes is already recorded, but no command read it back — so when gradle/npm/kubectl/etc. failed inside the sandbox, users had no way to see what was blocked or which rule did it. The doctor can't cover every external tool; the design surfaces evidence + generic, least-privilege hints so a user can debug an unknown tool themselves. It reuses provenance (effective config) and profileaudit (security lint) rather than reimplementing them.

Least-privilege by design: hints recommend the exact host/port, legitimize leaving a host denied, and flag over-broad/unused rules for removal — never "allow everything".

How

  • internal/diagnose — pure, stdlib-only analysis (DTOs + hint engine); zero omac deps, insulated from schema churn.
  • Volatile concerns injected / single-sourced: audit owns read+write of its format (audit.ReadLog); netproxy.MatchDomainList exported and injected (no re-implemented matching); path resolution reuses audit.EffectivePath.
  • internal/cli/diagnose.go (analysis + render) and diagnose_probe.go (static probe) are thin composition roots that adapt concrete types into the DTOs.

Verification

  • go test ./... green; gofmt/go vet clean.
  • Behavioral tests: audit reader (round-trip + malformed-line tolerance), every hint rule, focused-vs--v rendering, and all static probe outcomes (proxy allow/deny/prompt, raw-TCP, loopback, hard-deny).
  • Exercised end-to-end against real sandboxed failures: blocked npm/pypi repo, allow∧deny clash, gradle proxy-unaware, kubectl :6443, docker socket, git-over-SSH, loopback services.

Notes for review

  • Suggested reading order: internal/diagnose/diagnose.go (the model) → internal/audit/read.gointernal/cli/diagnose.godiagnose_probe.go.
  • Scope note: an earlier revision also shipped an opt-in --probe --live sandboxed-connection check (spawned a real sandbox + temp-file IPC + watchdog) and a doctor bridge-port-in-use check. Both were dropped to keep the PR to a single, clearly-defined value: read-only diagnosis with no side effects. The static --probe already gives an authoritative verdict by reusing the real filter, so --live added machinery for marginal gain.

🤖 Generated with Claude Code

nhuelstng and others added 8 commits July 18, 2026 13:18
What
- New `omac diagnose` command: the reactive counterpart to `omac doctor`.
  It reads the (previously write-only) audit trail back and correlates the
  network decisions the sandbox actually made against the effective policy,
  surfacing blocked hosts and non-obvious config clashes.
  - blocked hosts grouped by host, scoped to the last run (`--run all`).
  - clash hints: a host in allow_domain yet DENIED (names the shadow:
    deny_domain / learned / hard-deny); allow_domain entries that matched no
    traffic (typos); DNS failures; prompt fallbacks; proxy/env notes.
  - `--probe host[:port]`: static ALLOW/DENY/PROMPT verdict via the real
    netproxy filter (no DNS, no sandbox launch). `--json` for both modes.
- `omac doctor` now runs the existing profileaudit lint as advisory and
  points users to `omac diagnose` and the log paths.

Why
- Every net decision is already recorded (audit.jsonl + sandbox.log) but no
  command read either back, so when gradle/npm/etc. fails in the sandbox the
  user cannot see what was blocked or which rule blocked it. The doctor can
  never cover every external tool; surfacing evidence + generic hints lets a
  user debug an unknown tool themselves. Reuses provenance (effective config)
  and profileaudit (security lint) rather than reimplementing them.

How
- internal/diagnose: pure, stdlib-only analysis (DTOs + hint engine). Zero
  omac deps so it is insulated from omac's frequent schema churn.
- Volatile concerns are injected/single-sourced: audit owns read+write of its
  format (new audit.ReadLog/ReadFile); netproxy.MatchDomainList is exported
  and injected (no re-implemented matching); path resolution reuses
  audit.EffectivePath. internal/cli/diagnose.go is the thin composition root
  that adapts concrete types into the diagnose DTOs.

Verification
- go test ./... green; gofmt/vet clean.
- New behavioral tests: audit reader (round-trip + malformed-line tolerance),
  every hint rule incl. the allow+deny overlap clash, CLI wiring + all three
  probe outcomes. `go list -deps ./internal/diagnose` shows no omac deps.
- Ran the binary against a fixture trail: clash, dead-rule, and probe outputs
  render correctly.

Signed-off-by: Niclas Hülsmann <niclas.huelsmann@tngtech.com>
What
- `omac diagnose --probe <host> --live` performs a real reachability test:
  it launches the actual sandbox (via sandboxrun.Run) running omac's own
  hidden `sandbox probe-connect` inner command, which connects to the host
  through the injected HTTP(S)_PROXY and reports reached/blocked/dns/refused/
  timeout/tls. Result is captured via a temp file granted into the sandbox.

Why
- The static probe answers "would a rule admit this host?"; the live probe
  answers "does traffic actually get out right now?" — catching proxy-down,
  broken upstream proxy, DNS, and destination-unreachable failures that the
  static view cannot see (the gradle/npm failure class).

Safety (bounded so a diagnostic cannot run amok)
- Opt-in: only with --live; plain --probe stays static/offline.
- Gated on static==ALLOW: a denied or unlisted host is never dialed, so the
  probe can only contact a host the profile's own allowlist already permits.
  It cannot scan, fan out, or reach anything policy forbids.
- Single host, single HTTP HEAD, no retry/loop.
- Child HTTP timeout (12s) + a 30s wall-clock watchdog so the command can
  never hang; the child self-terminates regardless.
- No InsecureSkipVerify (a destination TLS error already counts as reached).
- No persisted state and no audit-trail pollution (probe proxy uses
  audit.Nop()); temp result file is unique and defer-removed.

How
- internal/cli/probe_connect.go: the hidden inner command + pure
  classifyProbeError. Wired as the hidden `sandbox probe-connect` verb.
- internal/cli/diagnose.go: --live flag; runLiveProbe orchestrates the
  gated sandbox launch, watchdog, and result read-back.

Verification
- go test ./... green; gofmt/vet clean. New tests: classifyProbeError table,
  inner command writes result file + keeps stdout clean, and a safety-gate
  regression asserting a non-ALLOW host is never dialed.
- Manual (Linux, bwrap, Landlock ABI 9): allow-listed host -> reached HTTP
  200; unlisted host -> skipped without dialing; --json nests the live block.

Signed-off-by: Niclas Hülsmann <niclas.huelsmann@tngtech.com>
What
- --probe now understands loopback targets (127.0.0.0/8, ::1, localhost):
  their reachability is governed by network.open_port, not the proxy, and a
  kernel (Landlock) denial leaves NO trace in the audit trail — so this
  static check is the only way to diagnose it (e.g. a sandboxed tool reaching
  a local Ollama/DB, or the bridge port). Verified empirically: without
  open_port the connection is kernel-blocked and unlogged; with it, it works.
- Reframe hints toward least privilege instead of "allow everything":
  - a blocked host recommends allowing the EXACT host and explicitly
    legitimizes leaving it denied (the sandbox blocking unexpected egress is
    a valid outcome, not always a misconfig);
  - unused allow_domain entries are flagged as removal candidates;
  - whole-TLD wildcards (e.g. "*.com") are flagged as over-broad.

Why
- Experiments against real failures showed the loopback/open_port class is
  invisible to the audit-based view; --probe closes that prospectively.
- A sandbox diagnostic must not train users to widen the allowlist; it should
  push the profile toward minimal, specific grants.

How
- internal/cli/diagnose.go: isLoopbackHost + loopbackProbe branch in the
  probe path; --live is a no-op for loopback (kernel-enforced).
- internal/diagnose: reworded blocked-host/dead-rule hints; new
  overBroadAllowHints for whole-TLD wildcards.

Verification
- go test ./... green; gofmt/vet clean. New tests: over-broad wildcard
  flagged (scoped wildcard not), blocked-host hint asserts exact-host +
  leave-denied wording and no wildcard steering.
- Manual: loopback probe DENY/ALLOW flips with open_port; over-permissive
  profile surfaces both the broad-wildcard warning and the unused-rule note.

Signed-off-by: Niclas Hülsmann <niclas.huelsmann@tngtech.com>
What
- Add an info hint that fires in filtered mode when nothing was blocked:
  a proxy-unaware tool (JVM/gradle, some native clients) connects directly
  instead of via HTTP(S)_PROXY, the kernel blocks that with NO audit entry,
  so the report looks empty while the tool fails on an already-allowed host.

Why
- Reproduced the gradle failure mode: `curl --noproxy '*'` to an allow-listed
  host fails ("could not connect") and records nothing — the audit-based view
  is blind to it. This is the exact case proxy_injection exists to fix; the
  diagnoser must name it instead of leaving the user staring at "0 denied".

How
- internal/diagnose: proxyUnawareHint(p, decisions) — gated on mode=filtered
  and zero denials so it never competes with a concrete blocked-host finding;
  steers to routing through the proxy (JAVA_TOOL_OPTIONS / proxy_injection),
  explicitly not to widening the network policy.

Verification
- go test ./... green; gofmt/vet clean. New tests: hint fires when nothing
  blocked + points at the proxy + does not suggest widening; suppressed when
  a concrete block exists. Manual: gradle-style direct-connect run now yields
  the proxy-unaware hint despite an empty decision list.

Signed-off-by: Niclas Hülsmann <niclas.huelsmann@tngtech.com>
…opback blind spots

What
- Generalize the filtered-mode "nothing was blocked" hint to name BOTH classes
  of failure that leave no entry in the network report:
  (1) proxy-unaware tools connecting directly (JVM/gradle) and
  (2) filesystem/socket or loopback access denials (e.g. docker.sock, a
      127.0.0.1 service). Cross-references `omac provenance` and
      `omac diagnose --probe 127.0.0.1:<port>` for the non-network case.

Why
- Experiments with Docker (CLI -> daemon over a Unix socket) and Kubernetes
  (kubectl -> API server over HTTPS) showed the split: kubectl is proxy-aware
  network and already diagnosed cleanly (endpoint:port surfaced); the Docker
  socket path produces zero net.decision events, so the old hint would have
  misattributed it to proxy-unawareness. The blind-spot note must point at the
  socket/filesystem grant (provenance) too, not just the proxy.

How
- internal/diagnose: rename proxyUnawareHint -> invisibleFailureHint; same
  firing condition (filtered mode, zero denials) and least-privilege stance
  (narrowest grant; do not widen the policy).

Verification
- go test ./... green; gofmt/vet clean. Tests updated to assert both the
  proxy-injection and docker.sock/provenance guidance and the no-widen stance.
- Manual: docker-socket blind-spot run now shows both branches of the hint.

Signed-off-by: Niclas Hülsmann <niclas.huelsmann@tngtech.com>
What
- Redesign the default view to stay focused: a one-line status (run/exit ·
  mode · N blocked), the blocked hosts (capped), the PROBLEMS to act on with
  their fixes, and a single collapsed line for least-privilege/context
  ADVISORIES. `-v/--verbose` expands all advisories, the effective config, and
  log paths. Blocked list is capped (+K more) unless -v.
- Add failure modes, folded into that model so they add signal, not noise:
  * raw-TCP / SSH: `--probe host:port` now reports BOTH paths — HTTP(S) via
    proxy (allow_domain) and raw TCP (allow_tcp_connect) — so git@host (SSH:22)
    vs git https is diagnosable; the "nothing blocked" hint names allow_tcp_connect.
  * network.mode=blocked: a single clear problem instead of a confusing empty
    report.
  * corporate-CA/TLS: the upstream-proxy advisory now covers re-signed TLS and
    the per-ecosystem CA knobs (NODE_EXTRA_CA_CERTS, pip --cert, truststore).
  * doctor: bridge/facade loopback ports checked for in-use (unprivileged-userns
    is already covered by CheckPlatform's bwrap smoke test).
  * exit-code correlation: diagnose leads with "last run exited N" (session.stop).

Why
- Adding failure modes to a flat list would overwhelm users. Separating
  "why did my thing fail" (problems, shown) from "hygiene/context" (advisories,
  collapsed) keeps the output actionable and non-scary while covering more.

How
- internal/diagnose: Hint gains a Kind (problem|advisory); Report.Problems()/
  Advisories(); mode=blocked short-circuit; wording updates.
- internal/cli/diagnose.go: rewritten renderer with -v; rawTCPProbe + two-path
  probe output; runExitCode from session.stop.
- internal/cli/doctor.go: doctorBridgePorts.

Verification
- go test ./... green; gofmt/vet clean. New/updated tests: Kind split &
  collapse, -v expansion, raw-TCP probe path, over-broad/dead-rule advisories,
  invisible-failure hint (proxy + docker.sock + allow_tcp_connect).
- Manual: focused vs -v views; SSH-vs-HTTPS probe flips with allow_tcp_connect;
  bridge-port-in-use warning.

Signed-off-by: Niclas Hülsmann <niclas.huelsmann@tngtech.com>
What
- Remove the redundant Severity (warn/info) axis: after Kind (problem/advisory)
  it was a second, ~90%-correlated axis the renderer never printed. Order hints
  by Kind alone.
- Remove dead Decision.When / BlockedHost.LastSeen (computed, never rendered;
  dropped the time dependency from the pure package).
- Collapse profileSummaryV + profileSummary() into the existing diagnose.Policy
  (one profile->struct mapping instead of two near-duplicates).
- Split cli/diagnose.go (592 LOC) into diagnose.go (retrospective analysis +
  render, 357) and diagnose_probe.go (all probe variants, 254) so the two
  concerns review independently.
- Make --json consistent: add snake_case tags to diagnose.Report/Policy/Hint/
  BlockedHost (previously untagged → mixed-case output; tests passed only via
  Go's case-insensitive unmarshal).

Why
- Self-review round: cut redundant abstraction and dead code, right-size files
  for a human reviewer, and fix a half-tagged public JSON contract.

Verification
- go test ./... green; gofmt/vet clean; behavior unchanged (focused view,
  --json now uniform snake_case). Tests updated to assert on Kind, not Severity.

Signed-off-by: Niclas Hülsmann <niclas.huelsmann@tngtech.com>
What:
- Remove the opt-in `--probe --live` machinery (sandbox spawn, temp-file
  IPC, watchdog) and its hidden `omac sandbox probe-connect` inner command.
- Remove doctor's bridge/facade port-in-use check.
- Fix Decision.Source doc (actual audit source is "blocklist", not
  "deny_domain"); suppress the single-run exit code under `--run all`.

Why:
- The static `--probe` already reuses the real netproxy filter, so its
  verdict is authoritative; --live added a real sandbox launch + IPC for
  marginal gain and was the PR's riskiest surface.
- The port nudge is scope creep on this PR and adds a live net.Listen
  side-effect to doctor; the substance is covered elsewhere.

How:
- Delete probe_connect.go/_test.go; strip live handling and probeResult
  from diagnose_probe.go; drop the --live flag and sandbox dispatch.
- Keep the cheap profileaudit lint gap-fill in doctor.

Verification:
- go build/vet/test ./... green; ~330 net LOC removed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Niclas Hülsmann <niclas.huelsmann@tngtech.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant