Skip to content

feat: add enclave network log - #52

Open
planger wants to merge 5 commits into
mainfrom
planger/network-log
Open

feat: add enclave network log#52
planger wants to merge 5 commits into
mainfrom
planger/network-log

Conversation

@planger

@planger planger commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

What it does

Adds enclave network log, a reader for the gateway audit log, and fixes the log format it reads.

The gateway already wrote audit events, but nothing could read them and the format had three defects a reader would otherwise have to work around: raw dnsmasq text appended by a shell tail, no session identity in a file shared by concurrent sessions, and unbounded growth.

  • internal/netlog is the single source of truth for the JSONL contract (event schema, append, scan, filter, aggregate, follow, rotate).
  • enclave network log supports row, --follow, --summary, and --json modes, with --since, --verdict, --domain, --type, --tool, --session, and --all-running filters. Terminal output is the aligned human form; --json is the machine contract, emitting the raw JSONL event stream or, with --summary, the aggregate as a single object.
  • DNS denials move out of the shell entrypoint into internal/gateway/dnsaudit, running as its own process so they are recorded even when the proxy is disabled. Written against captured dnsmasq 2.91 output, which showed the shell filter's NODATA case reported allowed domains as denied: dnsmasq returns NODATA-IPv6 for every AAAA lookup of an allowlisted host without an IPv6 record. NODATA is dropped; NXDOMAIN, SERVFAIL, and REFUSED are kept, distinguishing a policy blackhole from an upstream failure.
  • Reads are scoped by the event session field, and gateway paths are deduplicated so a shared file is read once instead of reporting every event twice.
  • Rotation copies and truncates instead of renaming, above a fixed netlog.MaxLogBytes of 32MB. The gateway bind-mounts the log as a single file, so a rename left a running session appending to network.log.1, where --follow never looks. Concurrent session starts serialize on a lock file.
  • Line splitting is bounded, so a torn write cannot grow a long-lived follower's buffer without limit. The scanner reports the offset it stopped at and --follow resumes from there, closing the window where an event appended while the backlog printed was lost or shown twice.
  • Docs: --network-log coarse records one event per TLS connection, not one per request, and successful DNS lookups are never recorded, so absence of events was easy to misread as absence of traffic. The website also claimed DNS queries were logged.

Also: normalize domains through domainpattern everywhere so the filter and the aggregate agree, and add shell completion for the filter flags.

On scope

The first version of this branch also shipped a configurable network_log_max_size and a tab-separated machine output form. Both were dropped in 6a6c15f as more machinery than the feature earned. The size option needed a size parser, a project-scope guardrail and generated option plumbing to express one number nobody had asked to change, so it is a constant now. The TSV form was a second consumer-facing column contract sitting next to --json, which is the documented one, so --plain is gone and --summary --json covers the machine-readable aggregate. That removed about 620 lines.

How to test

make build and make test pass. make lint was not run: golangci-lint is not installed in this environment. go vet ./... is clean.

Manually, in a project directory:

  1. Start a session and generate traffic, including a denied domain:
    enclave run -- claude
    # inside: curl https://example.com ; curl https://blocked.example
    
  2. enclave network log shows pass and deny rows for that project and tool, including after the session exits.
  3. enclave network log --summary aggregates per domain; --json emits the raw JSONL and --summary --json a single aggregate object.
  4. enclave network log -f in a second terminal streams new events live while the session runs.
  5. Filters: --verdict deny, --domain '*.example.com', --type dns, --since 10m, --since session.
  6. Run two sessions of the same project concurrently and check --session <container> and --all-running scope correctly and do not duplicate events.
  7. Request-level detail: run with --network-log requests and confirm per-request HTTP events appear.
  8. Rotation: grow a project's network.log past 32MB (truncate -s 33M works, only the size is checked), start a session, and confirm the previous generation lands in network.log.1 while a running session keeps appending to the same file and --follow keeps working.

Follow-ups

  • --project scope for network mutation commands is still unsupported, unchanged by this PR.
  • Coarse mode records one event per TLS connection; per-request visibility requires --network-log requests, which forces MITM for all allowlisted hosts. Documented under Coverage and granularity.
  • The rotation cap is a constant. If someone needs to tune it, adding the config option back is a contained change.

Breaking changes

  • This PR introduces breaking changes and has been coordinated with maintainers.

The gateway audit log format changed (raw dnsmasq lines are gone, events carry session identity). The log was not readable before, so no consumer should exist, but it is a format change to a file on disk.

Review checklist

@github-actions

Copy link
Copy Markdown

@planger
planger force-pushed the planger/network-log branch from a997879 to a4579f8 Compare August 14, 2026 12:06
The gateway already wrote audit events that nothing could read. Add
internal/netlog as the single source of truth for the JSONL contract, an
`enclave network log` subcommand with row, follow, summary and JSON modes,
and fix the three format defects the reader would otherwise have to
compensate for: raw dnsmasq text appended by a shell tail, no session
identity in a file shared by concurrent sessions, and unbounded growth.

DNS denials now come from internal/gateway/dnsaudit, which runs as its own
process so they are recorded even when the proxy is disabled. Written
against captured dnsmasq 2.91 output, which showed NODATA answers are not
denials: dnsmasq returns NODATA-IPv6 for every AAAA lookup of an
allowlisted host without an IPv6 record, so the shell filter's NODATA case
reported allowed domains as denied. It is dropped; NXDOMAIN, SERVFAIL and
REFUSED are kept, with the rule distinguishing a policy blackhole from an
upstream failure.
Concurrent sessions of one project and tool share a log file, which the
reader treated as one stream. --session and --since session now bound the
read by the event session field, and gateway paths are deduplicated so a
shared file is read once instead of reporting every event twice.

Rotation copies and truncates instead of renaming. The gateway bind-mounts
the log as a single file, so a rename left an already running session
appending to network.log.1, where --follow never looks. Concurrent session
starts serialize on a lock file so neither discards the generation the
other just wrote.

Line splitting moves to a shared splitter with a bound on one line, so a
torn write can no longer grow a session-long follower's buffer without
limit, and the scanner reports the offset it stopped at. --follow resumes
from there rather than re-measuring the file, which closes the window where
an event appended while the backlog printed was lost or shown twice.

Also: reject --json with --summary, normalize domains through
domainpattern everywhere so the filter and the aggregate agree, and move
ParseSize to internal/util so internal/config no longer imports the log
viewer.
The filter flags had no completion function, so shells fell back to
filename completion for --verdict, --type, --since, --domain, and
--session.
Coarse mode was documented as "pass/deny events", which reads as one
event per request. It is one event per TLS connection, and successful DNS
lookups are never recorded, so absence of events was easy to misread as
absence of traffic. The website also claimed DNS queries were logged.
@planger
planger force-pushed the planger/network-log branch from a4579f8 to 33daf73 Compare August 14, 2026 12:31
Both were more machinery than the feature earned. The configurable
network_log_max_size dragged in a size parser, a project-scope guardrail and
its generated option plumbing to express one number that nobody had asked to
change; rotation now uses a netlog.MaxLogBytes constant of 32MB. The
tab-separated machine form was a second consumer-facing column contract next to
--json, which is the documented one; --summary --json now emits the aggregate
as a single object and --plain is gone.

Also drops the redundant separator scans in the DNS translator and the
SplitHostPort allocation tweak in domainpattern, which optimized a path that
was never measured.
@planger
planger marked this pull request as ready for review August 14, 2026 15:12

@EclipseSourceAI EclipseSourceAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

Autonomous AI review.

This review was done by an AI agent and therefore may contain mistakes. Feel free to ignore any comment you disagree with. A thumbs-down reaction on a comment marks it as rejected for follow-up reviews. Noting why in a reply helps, since replies are read too.

Resolving all AI comments does not lead to an automatic approval. A maintainer still needs to review and sign off on the overall architecture and design.

To get an updated review after pushing changes, a maintainer may re-request a review from this account.

Running in Eclipse Enclave, submitted via review-guard-mcp

Adds enclave network log, a reader for the gateway audit log, plus the format fixes that make it readable: a new internal/netlog package owning the JSONL contract (event schema, append, scan, filter, aggregate, follow, rotate), a dnsaudit translator that replaces the shell tail | grep with a real process, session stamping on every event, and copy-and-truncate rotation at 32 MB. The docs work correcting what coarse mode actually records is the most valuable part of the change and is accurate.

The design holds up well. Rotation by copy-and-truncate under a lock is the right call given the bind-mount, the scanner offset handed to --follow genuinely closes the lost/duplicated-event window, and dropping NODATA from the DNS translation fixes a real false positive in the old shell filter. go build ./... and the tests for the touched packages pass here.

Points worth a maintainer's attention:

  • Aggregate drops events whose domain fails to normalize, which silently removes the proxy's domainless deny events (tls-clienthello, invalid-host) from --summary totals. --summary and --verdict deny then disagree about how much was blocked.
  • The DNS audit translator is started as root in gateway-entrypoint.sh while dnsmasq and the proxy next to it are dropped to unprivileged users.
  • --session requires Docker and a running gateway, even though the events on disk carry the session name and the default scope is explicitly readable after exit.
  • internal/util.FormatBytes duplicates the existing formatBytes in internal/app/cleanup.go.
  • internal/netlog is linked into the gateway proxy build inputs wholesale, including the terminal rendering and reader side the sidecar never uses.

Scope is otherwise tight; the only stray change is a docs/DEV.md paragraph left over from the config option that was dropped in 6a6c15f.

Comment thread internal/util/size.go
)

// FormatBytes renders a byte count for human output.
func FormatBytes(bytes int64) string {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This duplicates formatBytes in internal/app/cleanup.go (link), same units, same output for everything below 10 units. Since you are adding the shared one anyway, drop the app copy and point its callers here.

// one log cannot disagree about what counts as the same host.
domain, err := domainpattern.NormalizeHost(event.Domain)
if err != nil {
continue

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Events with no domain are dropped from the aggregate entirely, including their verdict counts. The proxy writes deny events with an empty domain (tls-clienthello and invalid-host), so --summary reports total_deny: 0 for a log where --verdict deny prints rows. Either count them under a placeholder domain or at least fold them into the totals.

Comment thread gateway-entrypoint.sh
done &
DNSMASQ_TAIL_PID="$!"
log "Starting DNS audit translator"
enclave-gateway-proxy -dns-audit "$DNSMASQ_LOG_FILE" >>"$DNS_AUDIT_LOG_FILE" 2>&1 &

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The translator runs as root while the two other long-lived children in this script are dropped to unprivileged users with su-exec (proxy, dnsmasq). It only tails a world-readable log and appends to a 666 file, so su-exec "$PROXY_USER:$PROXY_USER" should work here too.

}, nil
}

if err := checkDocker(); err != nil {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

--session needs Docker and a live gateway, so a session that has exited cannot be selected even though its events are on disk and carry the session name. The whole point of the default scope is that exited sessions stay readable. Falling back to filter.Session = sessionName against the current project's log when no gateway matches would close that gap.

Comment thread internal/netlog/filter.go
}

if pattern := strings.TrimSpace(f.Domain); pattern != "" {
domain, err := domainpattern.Normalize(pattern)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

domainpattern.Normalize enforces allowlist safety rules that make no sense for a read-only filter: --domain '*.com' is rejected with "wildcard suffix must include at least two labels" (link). Broadening a query is not a policy decision.

}
if asJSON {
writeErr = writeNetworkLogJSON(out, event)
} else if _, writeErr = out.WriteString(netlog.RenderEvent(event, render)); writeErr == nil {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A session marker in follow mode renders without the blank-line separation and without the pass/deny counts that WriteEvents gives it, so the boundary looks different depending on whether it came from the backlog or the live stream. Reusing the same separator logic for a marker here would keep one output format.

internal/git
internal/logx
internal/model
internal/netlog

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The gateway proxy only needs Event and Appender (plus Follower for dnsaudit), but this pulls in render.go, aggregate.go, filter.go and rotate.go along with internal/logx. Splitting the reader side into its own package would keep the sidecar's build inputs and the embedded asset tree to what it actually uses.

Comment thread docs/DEV.md
and use `Apply: ApplyNone` in `options_def.go` (for example:
`--force-base-image` and `--no-rebuild`).

For config-only options, omit `CLIFlags` instead (for example:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This paragraph is left over from the network_log_max_size option that 6a6c15f dropped, and has nothing to do with the network log reader. Better as its own docs commit.

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.

2 participants