Skip to content

Post-v0.0.15 audit remediation (2 High, 2 Medium, Lows)#22

Merged
ackness merged 11 commits into
mainfrom
v0.0.16-dev
Jul 16, 2026
Merged

Post-v0.0.15 audit remediation (2 High, 2 Medium, Lows)#22
ackness merged 11 commits into
mainfrom
v0.0.16-dev

Conversation

@ackness

@ackness ackness commented Jul 16, 2026

Copy link
Copy Markdown
Owner

What changed

Full-repository audit of PR #21 (v0.0.15) found defects the initial audit missed; this branch fixes the ones that are cleanly fixable and reverts three reliability patches that turned out to need infrastructure.

High (kept)

  • fix(server): enforce the session-owner guard on DELETE /api/sessions/:id/suspensions/:suspensionId — it previously only checked sessionId consistency, so on hosted tiers anyone knowing the ids could delete another session's suspension state.
  • fix(ai-provider): the plugin ctx.http/fetchWithRetry helper followed redirects with only the initial URL SSRF-checked, so a 302 Location: http://169.254.169.254/… reached cloud metadata. Force redirect: "manual" and fail closed on any 3xx.

Low (kept)

  • fix(server): strip metadata.ownerTokenHash from session responses; gate POST /events/emit on the session-owner check.
  • fix(desktop): add isTrustedSender to the remaining IPC channels.
  • fix(web): resolve live streaming text on export; clear the live buffer on any story-runtime completion; remove the scroll listener via a stored handler ref.
  • fix(context): file the context.compacted trace under the turn's traceId.

Reverted to backlog after review (see commit revert:)

  • L-6 shutdown drain — awaited the wrong signal (activeTurns is released before the proposal/snapshot commit; plugin-rpc/resume turns never register), so it didn't protect the commit window.
  • M-2 periodic job sweep — a regression: background jobs write updatedAt only at enqueue/completion, so a queued or long-running (>15min) job was force-failed mid-flight, giving clients a spurious failed state. Reverted to boot-only sweep.
  • M-1 transport heartbeat — the sender loop iterates the replay-session LRU (cap 256), so a high-fanout pod evicts sender state and skips heartbeats — weakest exactly when tail-loss is most likely.

Why

The v0.0.15 hardening was buildable and its own audit closed the tracked Critical/High items, but a fresh 7-reviewer pass found two new High gaps (a cross-tenant destructive write and a plugin-reachable SSRF) plus hygiene issues. Three reliability items looked fixable but a review of the remediation showed each needs real infrastructure (a commit-pipeline/session-lock drain, a job lease, LRU-independent transport high-water state); shipping the partial versions would give false confidence or regress, so they are documented and deferred.

How it was validated

  • 7 parallel reviewers verified each fix against actual file:line.
  • Adversarial review of the remediation diff caught a High race in the first M-1 attempt; a further PR review flagged the L-6/M-2/M-1 unsoundness above, each verified against code before reverting.
  • New/kept tests: resume owner-guard, plugin redirect SSRF, compactor traceId.
  • Full gate green: pnpm lint 21/21 · pnpm test 40/40.

Known risks / follow-up

  • Three deferred reliability items tracked with precise fix requirements (in audits/2026-07-16-pr21-full-audit/REPORT.md): commit-window drain, job lease, LRU-independent heartbeat state.
  • Deferred Lows (rationale in the report): L-1 (/api/llm-config non-secret metadata the UI needs), L-5 (by-design guard window), L-7/L-9/L-13/L-14 (observability/cosmetic).
  • H-2 side effect: a plugin wire that legitimately depended on a 3xx redirect now fails closed (intended).

ackness added 11 commits July 16, 2026 15:29
The DELETE /:id/suspensions/:suspensionId route only checked sessionId
consistency, not ownership — on hosted tiers anyone knowing the session
and suspension ids could delete another session's suspension state. Run
resolveSessionParam first, matching its sibling resume routes (H-1).
fetchWithRetry validated only the initial URL and followed redirects, so
a plugin hitting an endpoint that returns 302 Location: http://169.254.169.254/…
was dialed straight into cloud metadata (SSRF). Force redirect: "manual"
(after ...rest so a caller can't override) and throw on any 3xx, matching
the core provider HTTP path (H-2).
A cross-pod receiver only detected a seq gap when a later frame arrived
out of order. A lost *tail* frame (last NOTIFY dropped, no successor) left
the receiver silently stale until the next event or reconnect. Each origin
now publishes a periodic heartbeat carrying its high-water transportSeq
through the same per-session outbox as real frames, so the receiver treats
senderSeq >= expected as a hole and resets. seq/epoch are captured at
enqueue time so a concurrent emit cannot make the heartbeat overtake a real
frame and cause a false reset (M-1).
The orphaned-job sweep ran only once at boot and skipped jobs younger than
the staleness threshold, so a job pending <15min before a crash hung forever.
Re-run it on an unref'd interval (drained on shutdown) and anchor freshness on
max(startedAt, updatedAt) so a job that writes progress is never force-failed
by the now-periodic sweep (M-2).
Session create/get/patch/list echoed metadata.ownerTokenHash back to the
caller; strip it via sanitizeSessionForResponse (L-2). POST /events/emit was
gated only on NODE_ENV, so a non-production hosted boot accepted arbitrary
cross-session events; add a session-owner check (no-op on self) (L-3).
retry-startup, open-{logs,config,data}-dir, restart-server, pick-data-dir and
import:pick-{plugin,world} handled IPC without the isTrustedSender check the
secret channels use. Add it as defense-in-depth behind nav-pinning so an
untrusted frame can't drive restart / dir / import actions (L-4).
Resolve getStreamingText(id) at export time so a mid-stream export keeps the
partial assistant text instead of a blank row (L-10). Clear the live buffer on
any story-runtime completion, including empty content, so stale partial text
can't linger (L-11). Remove the scroll listener via a stored handler ref
instead of the no-op onscroll=null (L-12).
Graceful shutdown closed the DataStore without waiting for an in-flight turn,
so a turn mid-commit could have the store torn out from under it. Add a
time-boxed drain phase (awaitActiveTurnsDrained) before store close; the
existing 2s drainPhase timeout guarantees a stuck turn can't block shutdown (L-6).
The compactor tagged its trace event with the summaryId, so context.compacted
never showed under the turn's traceId in /api/traces. Thread the turn traceId
through CompactorRunner.run via the existing options bag and use it for the
trace event, falling back to summaryId when absent (L-8).
updatedAt starts equal to startedAt and only moves forward, so the sweep's
freshness is just Date.parse(row.updatedAt) — drop the redundant max() over
startedAt. Also drop the test-only activeTurnCount export; the drain test uses
hasActiveTurn instead. (ponytail-review shrink, no behavior change.)
…ctive)

PR #22 review found all three reliability patches unsound; each needs
infrastructure beyond a Low/Medium remediation and is reverted to backlog:

- L-6 (turn drain): awaited the wrong signal. actions.ts releases the W4
  turn-control registry in executeTurn's finally, BEFORE proposal/snapshot
  commit; plugin-rpc/resume turns never register. So the store could still
  close mid-commit — false confidence. A correct fix drains the commit
  pipeline / held session locks, not activeTurns.
- M-2 (periodic job sweep): a regression. background-jobs writes updatedAt
  only at enqueue and completion, never during a run, so a queued/long
  (>15min) job is force-failed mid-flight and later overwritten to done —
  clients observe a spurious failed state. Closing the boot blind spot
  safely needs a real job lease. Reverted to boot-only sweep.
- M-1 (transport heartbeat): the sender loop iterates the replay-session
  LRU (cap 256), so a high-fanout pod evicts sender state and skips
  heartbeats — weakest exactly when tail-loss is most likely. Needs
  LRU-independent high-water state; deferred to backlog per the domain review.

Keeps the sound fixes (2 High + L-2/L-3/L-4/L-8/L-10/L-11/L-12).
@ackness
ackness marked this pull request as ready for review July 16, 2026 12:41
@ackness
ackness merged commit 8710c8c into main Jul 16, 2026
2 checks passed
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