Skip to content

v0.10: coverage over HTTP, grammar-driven exploration, k8s demo, and a standalone dashboard - #3

Merged
ianp94 merged 15 commits into
mainfrom
v0.10-coverage-over-http
Jul 20, 2026
Merged

v0.10: coverage over HTTP, grammar-driven exploration, k8s demo, and a standalone dashboard#3
ianp94 merged 15 commits into
mainfrom
v0.10-coverage-over-http

Conversation

@ianp94

@ianp94 ianp94 commented Jul 20, 2026

Copy link
Copy Markdown
Owner

Summary

Started as "coverage over HTTP" and grew into the full v0.10 milestone: the harness can now measure the app under test's own coverage, use it to guide exploration, run against unmodified third-party apps in Kubernetes, and report findings through a standalone dashboard. Reasoning for every significant choice is in docs/DESIGN-DECISIONS.md (DD-012 … DD-022).

What it does now, end to end

Pointed at an unmodified JPetStore running as a pod in kind:

Coverage of the app's own code 23.1% (1469/6368 probes), read from its JaCoCo agent over the wire
Distinct crash sites found in the app 7
Findings clustered from 937 raw → 19 unique

Coverage progression, each step a real fix rather than tuning: 4.4% (round-robin) → 8.6% (guided mutation) → 17.3% (routes as a corpus, not hardcoded) → 17.7% (structural value generation) → 22.1% (sessions) → 23.1% (multi-step transactions).

Real defects found in the app under test

NullPointerException: "accountBean" is null
  at OrderActionBean.listOrders(OrderActionBean.java:110)

listOrders, viewOrder, newAccount, editAccount, viewItem and addItemToCart dereference lookups that can legitimately return null, so an unauthenticated or not-found request returns 500 instead of a redirect or a 404. OrderActionBean also reads the session account bean under two different keys with different lifecycles (/actions/Account.action vs accountBean), neither null-checked.

Main pieces

  • Coverage over HTTP (DD-012) — JaCoCo agent in the app JVM; the driver dumps and analyses it client-side. Coverage-guided mutation keeps inputs that reach new code.
  • Exploration surface as data (DD-016/017/018) — a request grammar supplies route templates, the corpus supplies values, and ~EST-[0-9]{1,4} structural generation invents ids that parse but don't exist. @sequence blocks run ordered session-carrying transactions; placeholders bind once per execution so a transaction is coherent.
  • Sessions (DD-018) — JSESSIONID carried across steps, alternating authenticated and anonymous epochs.
  • Kubernetes demo — one command brings up JPetStore as a pod with valve + agent + JaCoCo baked in.
  • Standalone dashboard (DD-013) — drivers push; the dashboard never drives anything. Fleet view, run configuration (params + grammar), findings clustered by fingerprint with the concrete inputs that produced them, and optional Claude-backed analysis.
  • Findings are triageable (DD-014/019/020) — clustering is read-path only so the corpus stays whole; crash findings carry the app's stack, parsed from the error response, not the harness's.
  • Tests (DD-021) — 41 tests, 31 new, covering the parsing/generation logic that previously had none. Verified by mutation, not just by passing.

Review fixes (all four blockers)

  • Dashboard security (DD-022) — was all-interfaces + Access-Control-Allow-Origin: * + an unauthenticated endpoint that spends API credit. Now binds 127.0.0.1, sends no CORS headers, and requires a guard header on ingest/analyze (a cross-origin simple request can't set one, and the preflight fails). Optional shared token; analyze refuses on a non-loopback bind without one. Verified: listener is loopback-only, both endpoints refuse without the header, no Access-Control-* on any response.
  • Wrong-cluster deploy — every kubectl call pinned to --context kind-$CLUSTER.
  • Exposed JaCoCo port — Service is ClusterIP; its remote-control protocol is unauthenticated and can reset execution data.
  • Silently stale image — unique tag per build plus an explicit set image.

Also from the review: Claude requests disable thinking and report a max_tokens stop; coverage caches class bytes instead of re-parsing every class per iteration; COVERAGE_INCLUDES defaults to the app package; compose uses stable staged paths; up.sh/gradlew executable; dashboard has checked fetches, a real liveness indicator and a generation counter against racing polls; plus the smaller escaping/parsing items.

Known limits (documented, not fixed here)

  • One driver behind a Service with N replicas under-reports coverage ~1/N — JaCoCo's connection lands on one pod while requests load-balance across all. Needs a merge across replicas.
  • Sessions need sessionAffinity: ClientIP in that topology; findings don't record which replica served them.
  • The Claude path is verified only for request shape and error handling, not against a live key.
  • No POST/form-body support yet.

Docs

docs/USAGE.md now documents every v0.10 flag, task, and the grammar format; deploy/k8s/README.md covers the ClusterIP and context decisions; README describes coverage as shipped rather than upcoming.

🤖 Generated with Claude Code

ianp94 and others added 13 commits July 19, 2026 23:55
…012)

The app-under-test's coverage now feeds the exploration panel's coverage %.
A JaCoCo agent runs in the app JVM in tcpserver mode; a client-side
JacocoCoverageProvider (scoped 'coverage' source set, org.jacoco.core) connects,
dumps accumulating execution data, and analyzes it against the app's class files
to compute covered/total instruction probes. CoverageDriver polls that on a
background thread into StatusReporter.recordCoverage, then runs GenericRunner to
drive HTTP routes.

Verified end to end against JPetStore: a real coverage % of org.mybatis.jpetstore.*
(~4.4% from catalog-route traffic) shows live in the panel — coverage from the
code under test, over the wire, no in-harness instrumentation.

Adds: build.gradle jacocoAgent config + coverage source set + copyJacocoAgent and
runHttpDriveCoverage tasks; docker-compose.coverage.yml (JPetStore + valve +
JaCoCo). Using coverage as a guidance signal (mutate inputs toward new edges) is
the next step; this establishes the measurement.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018kqCb2ZpmgPcLhjLo5ZbNx
…fied in-cluster

deploy/k8s/: a one-command kind (Kubernetes-in-Docker) demo. A self-contained
image (Dockerfile.jpetstore) bakes JPetStore + the ClosureJVM agent + valve +
JaCoCo agent into Tomcat 9; jpetstore.yaml runs it as a pod behind a NodePort
Service exposing the app (8080) and the JaCoCo coverage tcpserver (6300); up.sh
builds the agents, bakes the image, creates the cluster, loads the image, and
applies the manifests.

Verified end to end against the pod (via the node's NodePort, no port-forward):
- valve wraps requests in-cluster (X-ClosureJVM-Invariant-* headers)
- 96 server-side invariant finds harvested through the valve (heap + latency)
- live coverage % of the pod's own code from its JaCoCo agent: 281/6368 edges
- exploration UI shows it all together

So every feature — invariants, valve, coverage-over-HTTP, exploration UI — runs
against the app in Kubernetes. Demo docs/demo-k8s.svg; deploy/k8s/README.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018kqCb2ZpmgPcLhjLo5ZbNx
CoverageGuidedRun mutates HTTP request inputs (a small route+param grammar over
JPetStore's categories/products/items/keywords/cart) and samples the app's JaCoCo
coverage after each request, keeping inputs that reach new code — the AFL/Zest
feedback loop with the coverage signal coming from the app under test over the
wire (DD-012), the piece that makes the coverage % actually climb.

Verified against the kind JPetStore pod: coverage climbed 4.4% -> 8.6% (281 -> 549
edges of org.mybatis.jpetstore.*) vs the flat 4.4% of the round-robin driver, then
plateaued (GET-only exploration can't reach checkout/order/account without
sessions — realistic). Task: runCoverageGuided.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018kqCb2ZpmgPcLhjLo5ZbNx
StatusServer (JDK com.sun.net.httpserver, no new deps) serves a single-page
dashboard plus a small API, started from any run with -Dclosurejvm.dashboard
(port -Dclosurejvm.dashboard.port, default 7070):
- /api/status   live metrics from StatusReporter (StatusReporter.snapshotJson):
                 iterations, rate, crashes, leaks, invariants by kind, latency,
                 corpus, and the coverage % of the app under test
- /api/findings  the saved triage bundles from the results dir (classification,
                 timestamp, detail text)
- /              a dark dashboard: metric cards, a coverage progress bar, and a
                 findings table, auto-refreshing every 1.5s

Wired into GenericRunner and CoverageGuidedRun. Verified live during a guided
run against the kind JPetStore pod: /api/status returned coverage 8.6% (549/6368)
and 131 iters, /api/findings returned 47 findings, the page served 200.

This is the single-run foundation for the k8s dashboard (aggregating across pods)
and the optional Claude-API-backed finding analysis.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018kqCb2ZpmgPcLhjLo5ZbNx
README web-dashboard section; TODO marks the single-run dashboard done (metric
cards, coverage bar, findings table with route/detail) and keeps the cluster-wide
aggregation + Claude-API analysis as the next dashboard steps.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018kqCb2ZpmgPcLhjLo5ZbNx
Replace the embedded per-driver StatusServer with two independent processes:
DashboardServer (standalone aggregator, its own port, no driving logic) and
DashboardClient (runs inside GenericRunner/CoverageGuidedRun, pushes status +
findings to the aggregator, never opens a listening port itself).

DashboardServer stores raw JSON per campaign id (POST /ingest/status,
/ingest/findings) and relays it verbatim to the browser (GET /api/campaign/{id}/
status|findings), plus a fleet summary (GET /api/campaigns) via a narrow
best-effort regex scrape of a few display numbers -- no JSON parsing dependency,
no schema coupling to the harness. The page now has a fleet view (campaign
cards, alive/stale by last-seen) with drill-down into one campaign's metrics,
coverage bar, and findings.

Campaign id defaults to the HOSTNAME env var, which is a pod's name in
Kubernetes by default -- so a driver reports under natural per-pod identity
with zero config. This is deliberately the aggregation point an auto-injection
operator would point every instrumented pod at (each pod pushes; one dashboard
watches the fleet).

Verified live: DashboardServer and a CoverageGuidedRun driver as two separate
processes, the driver hitting the kind JPetStore pod and pushing to the
dashboard. Fleet view showed the live campaign with real numbers (iterations,
crashes, coverage 8.6%); campaign detail and findings endpoints round-tripped
correctly.

New task: runDashboard. README/TODO updated for the two-process flow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018kqCb2ZpmgPcLhjLo5ZbNx
Captures concrete pain points from building deploy/k8s/ (per-app Dockerfile
baking, manual WEB-INF/classes extraction for coverage, manual NodePort/
port-forward, no Helm chart) as a real backlog item rather than a vague
someday. Deliberately left undecided pending the auto-injection operator
direction, which will likely reshape the deploy story anyway.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018kqCb2ZpmgPcLhjLo5ZbNx
Noise reduction: soft-mode invariants are deliberately generous, so one systemic
behavior produced 125 near-identical heapDelta findings on a single route shape.
FindingsClusterer groups findings at READ time by fingerprint (classification +
invariant kind + route shape with param values stripped, or crash + exception
class), reporting count / distinct routes / magnitude range / last seen. Nothing
is dropped at save time -- the corpus stays whole for exploration (DD-006), and
clustering stays a pure, recomputable function of saved data. Deterministic, not
LLM-based, so results are reproducible run to run.

New /api/campaign/{id}/clusters endpoint; the dashboard findings table now shows
clusters ('19 unique / 937 total') instead of a flat wall of rows.

Optional Claude analysis: POST /api/analyze/{id} builds a prompt from the already
clustered summary and calls the Messages API (no SDK -- one HttpURLConnection
POST). Key is read only by the standalone dashboard process, never passed to a
driver or the app under test; triggered by an explicit button, never on the 1.5s
auto-refresh. Strictly advisory: it explains clusters, it cannot create, suppress
or reclassify a finding.

Verified against 937 real findings from this project's runs (937 -> 19 clusters).
Testing at that scale caught two real bugs unit-sized data would have hidden:
- A '(?:[^"\\]|\\.)*' regex for escaped JSON strings overflows the stack:
  Java's Pattern recurses one frame per character, so a few-thousand-char
  findings text throws StackOverflowError. Replaced with an iterative scanner
  (JsonScan); the same bug was latent in ClaudeAnalyzer's response parsing, where
  prose-length replies would have hit it.
- Two saved-finding formats exist: HTTP-driven writes 'detail=kind: ...', local/
  JQF writes the bare 'kind: ...' line. Keying only on 'detail=' left every local
  finding's kind unresolved as '?'.

Claude path is NOT yet exercised against a live key -- only that the request is
well-formed and the API rejects a bad key cleanly (401). Tracked in TODO.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018kqCb2ZpmgPcLhjLo5ZbNx
…oard

Three gaps found by reviewing real results, all of which capped what the fuzzer
could reach or show:

1. Exploration surface was hardcoded (DD-016). CoverageGuidedRun baked JPetStore's
   routes into Java arrays and reached 7 of the app's 21 Stripes handlers -- all of
   AccountActionBean (7) and OrderActionBean (4) were unreachable, not because they
   need sessions (my earlier explanation, which was wrong) but because nothing ever
   generated their URLs. Added examples/corpus/jpetstore/ covering all 21 handlers;
   the runner now loads seeds and no longer invents endpoints. Coverage 8.6% -> 17.3%
   (549 -> 1103 of 6368 edges), and it immediately surfaced 4 genuine crash clusters
   where the run previously reported zero:
     NullPointerException: accountBean is null
       at OrderActionBean.listOrders(OrderActionBean.java:110)
   listOrders/viewOrder/newAccount/editAccount dereference the session account bean
   with no null check, so an unauthenticated request 500s instead of redirecting to
   sign-on. Real defects in the app under test.

2. Parameter values were still hardcoded (DD-017). Added RequestGrammar: a file
   supplies route templates AND their value space, mixing literal dictionaries with
   generators (<int> boundary-biased, <string> short/metachar, <long>, <empty>).
   Mutation re-expands a template so mutants stay well-formed.
   examples/grammar/jpetstore.grammar covers all 21 handlers. Result: 17.7% coverage
   but distinct crash sites 4 -> 6. The new one is only reachable via generators:
     NullPointerException at CatalogService.isItemInStock(CatalogService.java:88)
       from CartActionBean.addItemToCart(CartActionBean.java:81)
   i.e. a workingItemId that is not a real item. No dictionary of valid ids finds
   that. Grammar buys bug-finding depth at a given coverage level, not raw coverage.

3. The dashboard never showed the input behind a finding, though FuzzIO had been
   saving each one as a .bin all along. DashboardClient now reads it (text if >=90%
   printable, else hex, 2KB cap), the clusterer keeps a bounded per-cluster sample,
   and cluster rows expand to show them -- selectable for copy-paste replay. Crash
   findings have no route= line, so their route now falls back to the saved input;
   previously every crash collapsed into one cluster keyed only by exception class,
   which is why the dashboard read route=(none).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018kqCb2ZpmgPcLhjLo5ZbNx
Input viewer: cluster rows now expand to a full record per sample -- the input
itself (copyable, plus a curl button), the exception and message, the invariant
detail, the stack trace, and the raw record on demand. Previously the UI kept the
full text in Sample.text but rendered only the input line, so the error and stack
were discarded at display time. The 800-char push cap also truncated stacks.
Crash findings have no route= line, so their route falls back to the saved input;
otherwise every crash collapsed into one cluster keyed by exception class alone
(this is what made the dashboard read 'route=(none)').

The dashboard page moved from a concatenated Java string literal to
resources/dashboard.html -- it had outgrown being maintainable that way and the
escaping had already caused bugs. Served from the classpath, with a fallback page
if the resource is missing.

Corpus/structure split (DD-018), per review feedback that these are different
concerns: the CORPUS supplies values (@../corpus/jpetstore/values/itemId.txt, one
per line) and the GRAMMAR supplies structure (~EST-[0-9]{1,4}, a mini generator
over literals, char classes and {n,m} repetition). Real values reach happy paths
and deep code; structurally-valid-but-nonexistent ones get past parsing into the
lookup/deref code where the failures are; random junk mostly gets rejected early.

Sessions: the real remaining ceiling was state, not surface. The driver now keeps
a JSESSIONID and alternates session epochs (sign on for N iterations, then go
anonymous, repeat) so both authenticated and unauthenticated paths keep getting
probed. -Dclosurejvm.session.epoch, -Dclosurejvm.session=false.

Verified on the JPetStore pod: coverage 17.7% -> 22.1% (1126 -> 1409 of 6368),
still climbing at the end. Session mechanism confirmed with a control rather than
assumed -- listOrders returns 200 with a session and 500 without. Distinct app
crash sites 6 -> 7; the new one (CatalogActionBean.viewItem:181) is reachable
only via structurally generated ids.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018kqCb2ZpmgPcLhjLo5ZbNx
Every crash finding showed 'at runner.coverage.CoverageGuidedRun.request:228' --
the driver's own stack -- so a finding recorded that a 500 happened but nothing
about why. The real NPE only existed in the container log and had to be read by
hand, which makes crash findings nearly untriageable in the dashboard.

On a 5xx the driver now captures the response body instead of draining it, parses
the container error page for the server-side exception and frames, and installs
them via setStackTrace so FuzzIO persists the app's exception/message/stack into
the finding. Works for any servlet app with no agent or app cooperation; the
valve can't do this because Stripes catches the exception itself and returns 500,
so nothing propagates out to catch.

Testing against the live app found three bugs review would not have:
- Tomcat emits TWO <pre> blocks (framework wrapper, then 'Root Cause' with the
  app's frames); the first cut took the wrapper, i.e. the useless one.
- Tomcat renders frames WITHOUT the 'at ' prefix, so the frame parser would have
  extracted zero frames on every finding.
- '/' is escaped as &#47;, mangling class paths; numeric entities now decoded.

Findings now read e.g. DataIntegrityViolationException with the MyBatis/Spring
frames, which also confirms the newAccount 500s are duplicate-key violations from
fuzzed usernames (a validation smell) rather than the null-deref defect class.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018kqCb2ZpmgPcLhjLo5ZbNx
…dashboard

Sequences (DD-020): grammar gains @sequence blocks whose indented steps run in
order against one session. Placeholders bind ONCE per execution, so ${itemId} is
the same item across viewItem/addItemToCart/removeItemFromCart -- re-randomising
per step would produce an incoherent transaction that never reaches the code a
real checkout does. Order placement needs a POPULATED CART, not just a login,
which is why no amount of single-request fuzzing got there. Coverage 22.1% ->
23.1% (1409 -> 1469 of 6368). Four JPetStore sequences added.

Run configuration is part of the result: the driver pushes its parameters and the
grammar source once per run, and the dashboard shows them per campaign. A
coverage number is uninterpretable without knowing which grammar and thresholds
produced it. Credential-looking property names are redacted -- system properties
aren't a secret store, but this payload renders verbatim in a browser.

Cross-target clustering: fingerprints are instance-independent by construction, so
/api/clusters merges the same defect found on several targets into one row with
campaign attribution, instead of N unrelated clusters in N campaigns.

Fixed a real display bug reported from the dashboard: the invariants card read 0
while the findings table was full of heapDelta. Invariants arrive from two places
-- the harness measures latency/heap/thread in its own JVM, while the app's agent
reports its own via response headers (Invariant-Remote findings). The card only
counted the former. Now both are shown, separately labelled.

Documented (DD-020 + TODO) the limits of the one-driver-behind-a-Service topology
that clustering does NOT fix: coverage under-reports ~1/N because JaCoCo's
connection lands on one pod while requests spread across all; JSESSIONID pins to
one pod so sequences break without session affinity; and findings don't record
which replica served them. Also recorded that making the dashboard launch runs or
reject corpus entries reverses DD-013's read-only design and needs a trust model
decided first.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018kqCb2ZpmgPcLhjLo5ZbNx
v0.10 shipped ~1,800 lines of pure logic -- grammar parsing/generation, findings
clustering, JSON scanning -- with zero tests. CI was green only because nothing
exercised any of it. Four real bugs were found in that code during development,
all by hand; agents.md already says a feature is done when it has a minimal test.
A tool whose job is finding other people's bugs shouldn't ship untested parsers.

Adds 31 tests across JsonScan, FindingsClusterer and RequestGrammar, weighted
toward regressions for bugs that actually occurred rather than breadth:
- JsonScan: a 20k-frame value guards the stack-overflow regression (Java's regex
  recursed per character, so length alone was enough to break it), plus escape
  handling and sequential offset scanning.
- FindingsClusterer: both saved-finding formats (detail= vs a bare 'kind:' line),
  the crash route-fallback that stops every crash collapsing into one cluster,
  route-shape grouping, bounded samples, and cross-campaign merging.
- RequestGrammar: structural generation shape, @file corpus values, and the
  sequence invariant that a placeholder binds ONCE per execution -- if it
  re-randomised per step a transaction would add one cart item and remove a
  different one, which still looks fine in a dashboard while never reaching
  checkout code.

Each test says why it exists so it isn't later deleted as redundant.

Verified by mutation rather than by passing: breaking sequence binding
(computeIfAbsent -> bind per step) failed exactly
placeholdersBindOncePerSequenceExecution and nothing else. A test that passes but
wouldn't fail on the bug it names is worse than no test.

Also wires sourceSets.coverage.output onto the test classpath, without which the
grammar logic was untestable purely because of where it lives.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018kqCb2ZpmgPcLhjLo5ZbNx

@ianp94 ianp94 left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Review — build is green, but several deployment/security issues should be fixed before this merges

Verified locally

Built and tested the branch at d4a457a in an isolated worktree: gradle build test passes, 41 tests across 11 suites, 0 failures (up from 10 tests on main). The DD-021 test work is real — RequestGrammarTest (14), FindingsClustererTest (10), and JsonScanTest (7) cover the parsing/generation logic that previously had none.

I also confirmed the JsonScan stack-overflow rationale is sound, and empirically checked that ClaudeAnalyzer.extractText still returns the right value when the response contains thinking blocks ahead of the text block (it does — the "type":"text" match happens to land on the following "text": key).

Not verified: the kind/JPetStore in-cluster claims, the 96 invariant finds, and the 281/6368 coverage figure — no cluster available here.

Findings, most severe first

1. The dashboard server is unauthenticated, binds all interfaces, and exposes an endpoint that spends money. DashboardServer.main binds new InetSocketAddress(port) (all interfaces) with no auth, and respond() sets Access-Control-Allow-Origin: * on every response. POST /api/analyze/{id} calls the Claude API using the operator's key. A plain cross-origin POST with no custom headers isn't preflighted, so any page the operator visits while the dashboard is running can trigger billed API calls in a loop — and anyone on the same network can do so directly. At minimum: bind to loopback by default, drop the wildcard CORS header, and require a shared token on /api/analyze/.

2. deploy/k8s/up.sh never targets the kind cluster. kind create cluster is what sets kubectl's current-context, and line 32 skips it when the cluster already exists. Lines 38–39 then kubectl apply and kubectl rollout status against whatever context is currently active. A developer whose current-context points at a shared or staging cluster, who has previously created the kind cluster, gets a Deployment plus a NodePort Service in the remote cluster on re-run. kind load on line 35 is correctly scoped with --name, which makes the gap easy to miss. The same applies to the printed port-forward hint and the README's teardown command. Fix: --context "kind-$CLUSTER" on every kubectl invocation.

3. The JaCoCo tcpserver is published as a NodePort. jpetstore.yaml:41-42 exposes port 6300 as nodePort: 30063, and Dockerfile.jpetstore:13 binds it to 0.0.0.0. JaCoCo's remote-control protocol has no authentication and lets any client both dump and reset execution data. The file's own header comment and the README both say access is via kubectl port-forward, which only needs ClusterIP — so the NodePort isn't buying anything. Switch the Service to ClusterIP and drop both nodePort entries.

4. Re-running up.sh silently keeps the old image while reporting success. The tag is fixed at closurejvm/jpetstore-demo:latest and the manifest is byte-identical between runs, so kubectl apply is a no-op, no new ReplicaSet is created, and rollout status returns "successfully rolled out" immediately. With imagePullPolicy: IfNotPresent, the pod keeps the previously loaded image. Edit the agent, re-run, and you measure the old build while being told it deployed. Needs kubectl rollout restart deploy/jpetstore or a unique tag per build.

5. ClaudeAnalyzer will truncate its own output on the model it defaults to. It sends max_tokens: 800 with no thinking parameter, and the default model is claude-sonnet-5 — which runs adaptive thinking when thinking is omitted. Thinking tokens count against max_tokens, so an 800-token ceiling can be largely or entirely consumed by thinking, leaving a truncated analysis or none at all. There's also no stop_reason check, so a max_tokens stop is indistinguishable from a complete answer. Either set "thinking":{"type":"disabled"} for this summarization task, or raise max_tokens substantially and check stop_reason. (Model ID, endpoint, x-api-key / anthropic-version: 2023-06-01 headers, and request shape are all correct.)

6. Coverage sampling re-reads and re-parses every class file from disk on every iteration. JacocoCoverageProvider.sample() walks classFiles and calls Files.newInputStream(...) + analyzer.analyzeClass(...) for each one, and CoverageGuidedRun calls sample() once per iteration. The class files never change during a run, so this is O(classes) disk I/O and bytecode parsing per HTTP request — for JPetStore that's hundreds of files re-read hundreds of times. Caching the class bytes in memory at construction time would make the sampling cost a socket round-trip plus in-memory analysis.

7. deploy/k8s/up.sh is committed non-executable (mode 100644 — the only .sh in the repo). Both the README and the script's own header document JPETSTORE_WAR=... deploy/k8s/up.sh, which fails with Permission denied. gradlew is also 100644 (pre-existing on main), so even bash deploy/k8s/up.sh dies at the first build step. Needs git update-index --chmod=+x on both. Related: the CRLF line endings on gradlew made sh gradlew fail outright in a fresh worktree — I had to use standalone Gradle.

8. COVERAGE_INCLUDES default contradicts the comment above it. docker-compose.coverage.yml:24 has includes=${COVERAGE_INCLUDES:-*} directly under a comment saying "Scope JaCoCo's instrumentation to the app package … so we measure the app, not libs." Nothing sets the variable and there's no .env, so the documented invocation instruments Tomcat, MyBatis, and the whole servlet stack. That inflates the coverage denominator (making the dashboard's "% explored" wrong) and adds enough overhead to produce false positives against the -Dclosurejvm.invariant.latency.maxMs=25 threshold on line 13. Dockerfile.jpetstore gets this right with a hardcoded org.mybatis.jpetstore.* — the compose default should match.

9. The dashboard silently swallows every fetch failure while showing a green "live" indicator. All API calls are await (await fetch(x)).json() with no res.ok check, wrapped in empty catch(e){} blocks. Separately, the header dot #hdot is never touched by any code — the .dot.dead style exists and is applied to per-campaign pods, but the header dot is permanently green. If the server dies or an endpoint 500s, the page shows "live" with frozen counters indefinitely and the operator reads stale numbers as current.

10. Overlapping polls can overwrite fresh data with stale. setInterval(..., 1500) plus direct tick() calls from three click handlers, where tick() awaits two sequential fetches. When the server is slow — plausible while /api/analyze is running, since it shares the same HttpServer — several tick()s are in flight and last-to-resolve wins. The pod-click handler also races an in-flight tickFleet() that will clobber the subtitle back to fleet view. A generation counter or AbortController would fix both.

Smaller items

c.classification is the one server field interpolated unescaped, and it goes into a class attribute; esc() only handles & < >, not quotes, so it wouldn't be sufficient there anyway. Not currently exploitable — the values are harness literals — but it's read back unvalidated from on-disk meta files. Worth noting the genuinely target-controlled data (kind, from the app's response header) is correctly escaped, so the real XSS path is closed.

DashboardServer.queryParam reads getRawQuery() without URL-decoding, while campaignDetail splits getRequestURI().getPath(), which is decoded — so a campaign id needing any encoding is stored under one key and looked up under another. Pod names are DNS-safe, so this won't bite in practice today.

CoverageGuidedRun parses closurejvm.coverage.jacoco with substring(0, indexOf(':')), which throws a confusing StringIndexOutOfBoundsException if the value has no colon. total is only assigned on the single-request path, so a run consisting entirely of sequences prints coverage=N/0. And ClaudeAnalyzer carries a dead private unescape() method (lines 109–127) duplicating the one in JsonScan.

Hardcoded artifact versions in docker-compose.coverage.yml:27-28 (closurejvm-0.2.0.jar, closurejvm-valve-0.1.0.jar) match today's build files, but on a version bump Docker creates the bind-mount source as an empty directory, so Tomcat boots with -javaagent: pointing at a directory and aborts with an unrelated-looking error. The copyJacocoAgent task already shows the better pattern — a stable, version-independent output path.

Finally: the PR description covers coverage-over-HTTP and the kind demo, but the branch has grown to 13 commits including the web dashboard, grammar-driven fuzzing, findings clustering, the Claude analyzer, multi-step transactions, and the new test suite. Worth refreshing the body before merge so reviewers know what they're looking at.

Overall

The engineering is strong and the design-decision log makes the reasoning easy to follow. Two things I specifically tried to disprove and couldn't: the rename '.*', 'jacocoagent.jar' copy task does not double-apply, and testImplementation sourceSets.coverage.output is safe despite not pulling org.jacoco.core. The JsonScan extraction is also genuinely correct against realistic Claude response shapes, including with thinking blocks present.

My merge blockers are #1#4 — the unauthenticated paid endpoint, the wrong-cluster deploy, the exposed JaCoCo port, and the silently-stale image. The rest can follow up.

🤖 Generated with Claude Code

https://claude.ai/code/session_01UH8Wf39y1RFfcTKqf3d7uF

ianp94 added a commit that referenced this pull request Jul 20, 2026
…tness fixes

Blockers from review of PR #3.

1. Dashboard was network-exposed, CORS-open, and had an unauthenticated endpoint
   that spends API credit (DD-022). Now binds 127.0.0.1 by default, sends no CORS
   headers at all, and requires an X-ClosureJVM-Dashboard header on /ingest/* and
   /api/analyze/* -- a cross-origin simple request can't set a custom header
   without a preflight, and with no CORS headers the preflight fails, which closes
   the drive-by CSRF path without distributing a token. Optional shared token for
   deliberate non-loopback binds; /api/analyze refuses on a non-loopback bind with
   no token. Verified: listener is 127.0.0.1 only, both endpoints refuse without
   the header and accept with it, no Access-Control-* on any response.

2. deploy/k8s/up.sh deployed into whatever kubectl context was current, because
    (which sets it) is skipped when the cluster exists. Every
   kubectl call is now pinned to --context kind-.

3. The JaCoCo tcpserver was published as a NodePort; its remote-control protocol
   is unauthenticated and can RESET execution data. Service is now ClusterIP,
   which is what the docs already described (port-forward).

4. A fixed :latest tag made re-runs a no-op that reported success while the pod
   kept the previously loaded image -- measuring a stale build. Unique tag per
   build plus an explicit .

Also: Claude requests disable thinking and raise max_tokens (adaptive thinking on
the default model counts against max_tokens and could consume an 800-token
ceiling entirely), and report a max_tokens stop instead of returning a silently
truncated analysis. JacocoCoverageProvider caches class bytes at construction
rather than re-reading and re-parsing every class file on every iteration.
COVERAGE_INCLUDES defaults to the app package, matching its own comment and the
Dockerfile. Compose bind-mounts use stable staged paths (new stageAgents task) so
a version bump can't turn the mount source into an empty directory. up.sh and
gradlew are executable. Dashboard: checked fetches with a real liveness indicator
(a dead server no longer looks like a healthy idle run), a generation counter so
overlapping polls can't paint stale over fresh, classification constrained before
going into a class attribute, and esc() now covers quotes. jacoco host:port is
validated with a clear message, and a sequence-only run no longer prints
coverage=N/0. Removed ClaudeAnalyzer's dead unescape duplicate.

41 tests still pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018kqCb2ZpmgPcLhjLo5ZbNx
…tness fixes

Blockers from review of PR #3.

1. Dashboard was network-exposed, CORS-open, and had an unauthenticated endpoint
   that spends API credit (DD-022). Now binds 127.0.0.1 by default, sends no CORS
   headers at all, and requires an X-ClosureJVM-Dashboard header on /ingest/* and
   /api/analyze/* -- a cross-origin simple request cannot set a custom header
   without a preflight, and with no CORS headers the preflight fails, which closes
   the drive-by CSRF path without distributing a token. Optional shared token for
   deliberate non-loopback binds; /api/analyze refuses on a non-loopback bind with
   no token. Verified: listener is 127.0.0.1 only, both endpoints refuse without
   the header and accept with it, no Access-Control-* on any response.

2. deploy/k8s/up.sh deployed into whatever kubectl context was current, because
   "kind create cluster" (which sets it) is skipped when the cluster exists. Every
   kubectl call is now pinned to --context kind-$CLUSTER.

3. The JaCoCo tcpserver was published as a NodePort; its remote-control protocol
   is unauthenticated and can RESET execution data. Service is now ClusterIP,
   which is what the docs already described (port-forward).

4. A fixed :latest tag made re-runs a no-op that reported success while the pod
   kept the previously loaded image -- measuring a stale build. Unique tag per
   build plus an explicit "kubectl set image".

Also: Claude requests disable thinking and raise max_tokens (adaptive thinking on
the default model counts against max_tokens and could consume an 800-token
ceiling entirely), and report a max_tokens stop instead of returning a silently
truncated analysis. JacocoCoverageProvider caches class bytes at construction
rather than re-reading and re-parsing every class file on every iteration.
COVERAGE_INCLUDES defaults to the app package, matching its own comment and the
Dockerfile. Compose bind-mounts use stable staged paths (new stageAgents task) so
a version bump cannot turn the mount source into an empty directory. up.sh and
gradlew are executable. Dashboard: checked fetches with a real liveness indicator
(a dead server no longer looks like a healthy idle run), a generation counter so
overlapping polls cannot paint stale over fresh, classification constrained
before going into a class attribute, and esc() now covers quotes. The jacoco
host:port value is validated with a clear message, and a sequence-only run no
longer prints coverage=N/0. Removed ClaudeAnalyzer's dead unescape duplicate.

41 tests still pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018kqCb2ZpmgPcLhjLo5ZbNx
@ianp94
ianp94 force-pushed the v0.10-coverage-over-http branch from e9278b5 to cd32d13 Compare July 20, 2026 11:38
… deploy changes

The usage reference had fallen a whole milestone behind: it documented none of
the v0.10 flags or tasks, and the grammar format existed only inside the
.grammar file and the decision log. Anyone following the docs could not have
used coverage, grammars, sequences, or the dashboard.

docs/USAGE.md gains:
- flag tables for the exploration surface (grammar, corpusDir, sequencePercent,
  session, session.epoch), coverage (jacoco, classes, intervalMs), and the
  dashboard (push, id, server.port, bind, token, Claude key/model/maxTokens),
  including that the dashboard binds 127.0.0.1 by default and why
- a coverage-guided quickstart and the missing tasks (runDashboard,
  runCoverageGuided, runHttpDriveCoverage, stageAgents)
- a grammar reference: the corpus-supplies-values / grammar-supplies-structure
  split, the @file, ~pattern and <generator> forms, and why using only one of
  them finds less; plus @sequence and the bind-once-per-execution rule

README: coverage-over-HTTP was still described as upcoming when it shipped, so
that section now describes what actually exists and links the grammar reference;
the feature list had drifted a milestone behind; added the dashboard's loopback
default and a link to deploy/k8s.

deploy/k8s/README.md: documents the ClusterIP choice and why JaCoCo's port is
never published, and pins every kubectl example to --context kind-closurejvm so
following the docs cannot deploy into an unintended cluster. TODO.md's
description of the demo said NodePort, which is no longer true.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018kqCb2ZpmgPcLhjLo5ZbNx
@ianp94 ianp94 changed the title v0.10 (in progress): coverage over HTTP + kind Kubernetes demo v0.10: coverage over HTTP, grammar-driven exploration, k8s demo, and a standalone dashboard Jul 20, 2026
@ianp94
ianp94 merged commit d4cf872 into main Jul 20, 2026
4 checks passed
@ianp94
ianp94 deleted the v0.10-coverage-over-http branch July 20, 2026 12:14
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