v0.10: coverage over HTTP, grammar-driven exploration, k8s demo, and a standalone dashboard - #3
Conversation
…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 /, 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
left a comment
There was a problem hiding this comment.
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
…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
e9278b5 to
cd32d13
Compare
… 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
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 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
listOrders,viewOrder,newAccount,editAccount,viewItemandaddItemToCartdereference lookups that can legitimately return null, so an unauthenticated or not-found request returns 500 instead of a redirect or a 404.OrderActionBeanalso reads the session account bean under two different keys with different lifecycles (/actions/Account.actionvsaccountBean), neither null-checked.Main pieces
~EST-[0-9]{1,4}structural generation invents ids that parse but don't exist.@sequenceblocks run ordered session-carrying transactions; placeholders bind once per execution so a transaction is coherent.Review fixes (all four blockers)
Access-Control-Allow-Origin: *+ an unauthenticated endpoint that spends API credit. Now binds127.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, noAccess-Control-*on any response.kubectlcall pinned to--context kind-$CLUSTER.ClusterIP; its remote-control protocol is unauthenticated and can reset execution data.set image.Also from the review: Claude requests disable thinking and report a
max_tokensstop; coverage caches class bytes instead of re-parsing every class per iteration;COVERAGE_INCLUDESdefaults to the app package; compose uses stable staged paths;up.sh/gradlewexecutable; 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)
sessionAffinity: ClientIPin that topology; findings don't record which replica served them.Docs
docs/USAGE.mdnow documents every v0.10 flag, task, and the grammar format;deploy/k8s/README.mdcovers the ClusterIP and context decisions; README describes coverage as shipped rather than upcoming.🤖 Generated with Claude Code