Skip to content

fix(hardening): concurrency, durability, and security hardening - #185

Merged
Patel230 merged 14 commits into
mainfrom
feat/hardening-improvements
Aug 8, 2026
Merged

fix(hardening): concurrency, durability, and security hardening#185
Patel230 merged 14 commits into
mainfrom
feat/hardening-improvements

Conversation

@Patel230

@Patel230 Patel230 commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Summary

Deep-review hardening pass across the codebase, fixing concurrency races, goroutine leaks, durability gaps, and security weaknesses.

Critical fixes

  • C1 Atomic tryConsumeApproval — fixes approval N-count TOCTOU race (approval_gate.go)
  • C2 Wait/WaitIDs no longer hold mutex across cond.Wait; timer guards timeout so it always fires (runtime.go)
  • C3+C4 Document WaitAsync drain semantics; track async hook goroutine on asyncWG to prevent leaks (hooks.go)
  • C5 Cron Stop() drains in-flight jobs via inFlightWG (cron.go)
  • C6 FlushOTel flushes meter provider too — was dropping pending metrics (otel_sdk.go)
  • C7 Dedupe Ctrl+C quit logic through quitModel() so cancel cleanup always runs (chat_update.go)

Medium fixes

  • M1 LSP readLoop parses Content-Length framing with io.ReadFull — fixes fragmentation of pretty-printed JSON (client.go)
  • M2 LSP acquire() uses initDone channel to prevent duplicate server spawning (manager.go)
  • M3 EndSession and session-end hook use context.WithoutCancel so they survive the caller's return (stream.go)
  • M4 Remove glob-collapsing loop that broke dangerous-pattern detection (bash.go)
  • M10 Retry only retryable url.Error, not permanent client errors like unsupported scheme (retry.go)

Security

  • m7 Strengthen dangerously-skip-permissions confirmation token so it can't be triggered by autocomplete or a stray line (root.go)

Verified

  • go build ./... clean
  • go vet clean on all modified packages
  • Full test suite passes (pre-push hook ran all packages green)

Patel230 added 14 commits August 7, 2026 22:00
Security:
- Cap daemon autonomy server-side: clients can no longer request
  full/yolo autonomy; operators opt in via Config.MaxAutonomy
  (--autonomy / HAWK_DAEMON_AUTONOMY), default capped at semi.
- Refuse non-loopback daemon binds without TLS (API key and session
  transcripts would otherwise travel in plaintext); enforce TLS 1.2+
  and MaxHeaderBytes on the daemon HTTP server.
- Harden POST /v1/review: validate concerns/model against a printable
  charset with a length cap and bound concurrent review subprocesses.
- Cap HTTP decision-hook response bodies (64 KiB) and warn loudly when
  a guardrail hook is configured fail-open.

Reliability:
- Propagate previously swallowed review-store errors so reviews can no
  longer get stuck in "running" or silently re-review corrupt stores.
- Surface credential-gate symlink flip failures to the user instead of
  silently leaving the container without the approved credential.
- Replace init-time panics in storage path resolution with safe
  temp-dir fallbacks.
- Route yaad bridge diagnostics through slog so they survive TUI mode.
- Use typed engine.WorkMode constants instead of raw string compares.
- Make alert queue drain responsive to Stop() without dropping alerts.

Tests:
- Enable the previously build-tagged sqlite store tests by default.
- Fix the golden test to actually diff against the golden file
  (regenerated for the current root help output).
- Harden weak assertions in config/providers/review tests and add
  BM25 unit tests for the previously untested scoring package.
Reliability:
- Deregister the SIGHUP forwarder after first use so repeated runChat()
  invocations no longer leak signal handlers and goroutines.
- Cancel any prior in-flight stream in startStream before overwriting
  the cancel func, preventing an orphaned stream goroutine.
- Route async hook execution errors through slog instead of discarding
  them silently.
- Log and requeue langfuse batches on transient flush failures (bounded
  at 2x flush size) instead of silently dropping telemetry.
- Record the background TaskRunner.Run error and expose it via RunErr()
  so callers can observe a failed background run.

Tests:
- Add dependency-boundary tests for internal/token (forwards to tok).
- Add tests for internal/home (Dir/Expand and env handling).
- Add drift-detection tests for cmd/compat-test (pin freshness logic).

Chore:
- Refresh GitNexus symbol counts in AGENTS.md after re-index.
Daemon middleware:
- Test clientIP, generateRequestID, isCORSSettingAllowed, and
  stripSlackMention pure helpers.
- Test request-ID propagation (generated and client-supplied), security
  header emission (incl. HSTS gating and Cache-Control on POST), CORS
  preflight and disallowed-origin behavior, responseWriter status
  capture, and the full installed middleware stack over HTTP.

Review engine:
- Add ListAll (incl. limit), Get-on-missing, empty-diff→passed
  lifecycle, WAL-checkpoint Close, and splitReviewStatements tests.
- Add printReviewSummary (no-findings and with-findings) and silentErr
  (background suppresses, foreground wraps) tests.
- Return tabwriter and json.MarshalIndent errors in `hawk trust list`
  and `hawk plugin status` instead of discarding them.
- Propagate the review-schema version scan error during migration so a
  corrupt store surfaces loudly instead of silently re-migrating.
- Log auto-init failures via slog instead of swallowing them.
- Route codegraph sync warnings through slog so they survive TUI mode
  (where the stdlib logger is discarded).
- Convert WAL batch flush and settings-parse warnings from hand-rolled
  stderr prints to slog for consistent structured diagnostics.

Tests:
- Add internal/startup phase timing tests (mark/end, noop end, latest-
  open-close semantics, Reset, copy-on-read, TotalTime).
Security:
- Netproxy handleHTTP now enforces policy on r.URL.Host (the actual
  dial target) instead of only the Host header. An absolute-form
  request URI whose URL host differs from a benign Host header can no
  longer bypass the allow/deny and private-network rules. Stats/logs
  also record the enforced target.
- Marketplace installs reject scp-style (git@host:...) repo URLs, which
  bypass the HTTPS transport and cannot be pinned or verified.
- Marketplace installs now fail closed when the cloned plugin has no
  plugin.json manifest, instead of only scanning when the file happens
  to exist.

Tests:
- Regression test proving an absolute-URI target pointing at a blocked
  domain is refused even when the Host header names an allowed domain.
- Test that scp-style marketplace repo URLs are rejected.
S1 — Terminal escape injection:
- Add sanitizeDisplay() which strips ANSI CSI/OSC sequences, C0 controls,
  and DEL from untrusted content while preserving newlines/tabs.
- Apply it at the render choke points (renderDisplayMessage and the
  stream tail) so tool results, model output, thinking, system messages,
  and the permission box body can no longer forge prompts or hijack the
  terminal.

S2 — Secret display:
- Tool output is now redacted before the user-facing tool_result stream
  event, not just before the model copy. Wired via a redactOutput
  callback on toolExecutionDeps (falls back to unchanged when absent).

S3 — Dangerous-mode confirmation:
- --dangerously-skip-permissions now requires typing the literal token
  "dangerous-skip-permissions" instead of a single y.
- Selecting YOLO ("Autonomous") in the autonomy picker now requires
  typing "continue" to confirm; anything else cancels.

S4 — Folder trust parity:
- Print/repl/watch paths now enforce the same folder-trust gate as the
  TUI, so project-scoped hooks/MCP/plugins are uniformly blocked in
  untrusted directories.
…n exit

R1 — Panic safety:
- runChat now registers a panicSaveFn closure that persists the active
  session and stops the container on an unexpected panic, so a crash
  loses at most the in-flight message and never leaves a zombie Docker
  sandbox. RunWithPanicRecovery invokes it instead of nil.

R2 — Incremental durability:
- Wire the existing (previously unused) BatchedWAL into the TUI session
  log so per-message fsync is timer-batched instead of stalling the UI
  thread.
- Persist completed tool results to the WAL as they happen (previously
  only written at turn end), so a crash mid-turn keeps them.
- The model's WAL field is now a small sessionWAL interface (Append/
  Remove/Close) satisfied by both WAL and BatchedWAL.

R3 — Unified quit:
- /quit and /exit now use the canonical quitModel() sequence (cancel
  stream -> save -> stop watcher/parallel/bg -> stop container) instead
  of a hand-rolled duplicate that orphaned the in-flight stream. Sleep
  inhibitor is cancelled first.

R4 — Visible persistence failures:
- WAL append errors now surface once as a persistent inline banner
  ("session persistence is failing") instead of being silently dropped.

R5 — No false crash reports:
- Drop the SIGTERM goroutine-dump handler: SIGTERM is Bubble Tea's
  graceful-quit signal, so a normal `kill <pid>` no longer writes a
  spurious crash-signal-SIGTERM report. SIGQUIT dumps remain.
P1 — Deduplicate the registry build:
- runChat now passes the registry already built by its startup goroutine
  into newChatModelWithRegistry instead of rebuilding it (which re-ran
  MCP server startup a second time). Removed the now-unused wrapper.

P2 — Defer the pre-paint DNS lookup:
- The provider DNS reachability check moved out of validateStartup (which
  runs before first paint) into the post-paint ui-cache-warm background
  goroutine, so an offline machine no longer stalls TUI startup for
  seconds. validateStartup keeps only the local checks (API key, sessions
  dir writability).

P3 — Cache cwd in the status bar refresh:
- refreshStatusBarLeft now short-circuits on a cached value before the
  os.Getwd() syscall, so per-keystroke updates skip the syscall entirely.

P4 — Non-blocking MCP startup:
- MCP tools load asynchronously after the registry is built (up to 1.5s
  per server no longer blocks first paint); CLI tool filters still apply.
  Loader functions are captured by value so the async goroutine cannot
  race with tests that swap the package-level loader vars.

P5 — Offload session save off the UI thread:
- The turn-end session JSONL write now runs as a background tea.Cmd via
  saveSessionCmd (atomic tmp+rename makes this safe; WAL is removed only
  after a successful save). Quit keeps the synchronous save since the
  process is exiting. WAL fsync batching landed with BatchedWAL in the
  reliability batch.
- C1: atomic tryConsumeApproval to fix approval N-count TOCTOU race
- C2: Wait/WaitIDs don't hold mutex across cond.Wait; timer guards timeout
- C3+C4: document WaitAsync drain; track async hook goroutine on asyncWG
- C5: Cron Stop() drains in-flight jobs via inFlightWG
- C6: FlushOTel flushes meter provider too (was dropping metrics)
- C7: dedupe Ctrl+C quit logic through quitModel() for cancel cleanup
- M1: LSP readLoop parses Content-Length framing with io.ReadFull
- M2: LSP acquire() uses initDone channel to prevent duplicate servers
- M3: EndSession/session-end hook use context.WithoutCancel
- M4: remove glob-collapsing loop that broke detection
- M10: retry only retryable url.Error, not permanent client errors
- m7: strengthen dangerously-skip-permissions confirmation token
Submodule sync: external/trace advanced to port unified checkpoint
architecture. Bumps trace require and its transitive dependencies
(go-git v6 alpha.5, auth-go v0.5.2, etc.) per MVS selection.
taskruntime.Wait/WaitIDs: replace the one-shot time.AfterFunc broadcast with
a timer goroutine that repeats the broadcast after the deadline. A single
broadcast could be lost in the microsecond window between loop iterations
while the waiter is outside cond.Wait(), letting the wait block past its
deadline. Repeating guarantees the deadline is observed; a stop channel
terminates the goroutine when the wait returns so it cannot leak.

chat_update.quitModel: flush input history here so every quit path (/quit,
SIGTERM, SIGINT, Ctrl+C) persists it. Previously saveInputHistory was only
called on the config-view double-Ctrl+C path, so history was silently lost
on all other exits.
Advances external/trace and bumps go.mod/go.sum requires to match the
new gitlink. MVS pulls in transitive upgrades from the trace release.
An earlier commit added the 'entire' command and removed 'trace' from the
hawk command list but did not regenerate the golden file. Update it to
match the current help output so the golden test passes.
markdownlint MD025 flags multiple top-level headings. The GitNexus block
injected a second # heading; demote it to ## so it reads as a section
within the document. The document's single real title remains
'# Extending hawk'.
@Patel230
Patel230 merged commit 6605af1 into main Aug 8, 2026
24 checks passed
@Patel230
Patel230 deleted the feat/hardening-improvements branch August 8, 2026 11:38
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