Skip to content

About

Electron and CDP workbench for LLM-assisted, authorized web security testing

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

6 Commits

Folders and files

Repository files navigation

AI Pentest Workbench

AI Pentest Workbench is an observable Electron browser that runs as a local background service and is controlled through a compact JSON CLI. It preserves Chromium sessions, assigns explicit identities to windows, tabs, and frames, and gives an LLM focused primitives for inspecting pages, performing ordered actions, and collecting evidence without ingesting the browser's full noise.

This project is intended exclusively for authorized security testing. The operator is responsible for defining and respecting the target scope, test identities, side-effect limits, and program rules before interacting with an application.

The repository includes three agent skills:

  • web-audit-workbench: compact browser control and CDP evidence collection;
  • application-context: recursive application exploration and evidence-backed documentation;
  • application-context-query: low-noise queries and threat modeling over stored application context.

Requirements

  • Node.js 22 or newer (required by AI SDK 7 and supported by Electron tooling);
  • npm;
  • macOS, Linux, or Windows with Electron support;
  • Python 3 for the application-context skills.

Installation and service

git clone https://github.com/Jhounx/ai-pentest-workbench.git
cd ai-pentest-workbench
npm install
npm run build
npm run ctl -- service start --url https://example.com --profile auditor
npm run ctl -- service status
npm run ctl -- service stop

The Electron process detaches from the terminal. Its control server listens on a random 127.0.0.1 port and uses a 256-bit bearer token stored with mode 0600. In a packaged installation, replace npm run ctl -- with web-audit.

Identities and persistence

  • profileId: persistent Chromium identity containing cookies, cache, and storage;
  • windowId: stable logical window preserved across service restarts;
  • tabId: concrete page instance, regenerated when the process restarts;
  • frameId: main document or iframe within a tab;
  • ref: short-lived semantic element reference such as el_2_7.

Windows using the same profileId share authentication. Different profiles are isolated. Session cookies are persisted by the application and encrypted with Electron safeStorage. Rediscover tabId values through windows or state after restarting the service.

npm run ctl -- windows
npm run ctl -- window create --profile another-user --url https://example.com
npm run ctl -- state --window win_12345678
npm run ctl -- open https://example.com/settings --window win_12345678
npm run ctl -- navigate https://example.com/account \
  --window win_12345678 --tab tab_12345678

--window may be omitted when only one window exists. It becomes mandatory when multiple windows exist so an action cannot silently target the wrong user.

Real-time Audit Desk

The Workbench sidebar contains five live tabs, refreshed once per second:

  • Commands keeps sanitized CLI and UI history with origin, duration, and outcome. Typed text and evaluated code are represented only by their size.
  • Pages accumulates observed documents and SPA routes with tab, first-seen, and last-seen metadata.
  • JavaScript combines historical Debugger.scriptParsed events with the live inventory and groups identical hashes across pages.
  • Requests hides static assets by default and loads bounded request and response bodies only on demand.
  • Business reads an Application Context projection and presents flows, prerequisites, actions, outcomes, and related requests.

Tabs remain usable when their lists are empty. The shell renderer runs as a browser-native script without CommonJS or Node.js globals. Up to 10,000 events from the 12 newest window journals are rehydrated after restart. Historical request metadata remains visible, while bodies from a closed CDP session are marked unavailable.

npm run ctl -- audit-context set /absolute/path/application-context/run \
  --window win_12345678
npm run ctl -- audit-context show --window win_12345678
npm run ctl -- audit-panel --limit 150 --output ./audit-desk.json \
  --window win_12345678

Run audit-context clear to remove the association. After changing the canonical context model, run context.py render; the Business tab loads the updated projection on its next poll.

Controlled differential replay of an observed request

request-diff turns one request from the current CDP session into one attributable differential test. It does not accept an arbitrary URL: the request must exist in the selected tab, the final origin must equal the observed origin, and that origin and method must be present in an explicit scope manifest. The captured response is the baseline, so a state-changing baseline is not sent a second time.

{
  "schemaVersion": "web-audit.request-scope.v1",
  "scopeId": "authorized-engagement-2026-09",
  "authorizationReference": "/absolute/path/to/written-scope.pdf",
  "allowedOrigins": ["https://api.example.com"],
  "allowedMethods": ["GET", "POST", "PATCH"],
  "expiresAt": "2026-10-01T00:00:00.000Z"
}

Dry-run is the default. Exactly one mutation is accepted per invocation:

npm run ctl -- request-diff REQUEST_ID \
  --scope-file /absolute/scope.json --set-query accountId=other-id \
  --window win_12345678 --tab tab_12345678

npm run ctl -- request-diff REQUEST_ID \
  --scope-file /absolute/scope.json --set-json '/ownerId="other-id"' \
  --execute --allow-side-effects --output /absolute/evidence/diff.json \
  --window win_12345678 --tab tab_12345678

The other mutations are --remove-query NAME, --remove-json POINTER, and --set-path INDEX=VALUE, where INDEX addresses a non-empty path segment from zero. Query and JSON edits preserve every non-target byte; ambiguous duplicate JSON keys, invalid query encoding, dot segments, encoded separators, and multipart bodies fail closed.

--execute requires a non-expired expiresAt and a new mode-0600 output file. The CLI reserves that file before contacting the service; existing paths, symlinks, and invalid destinations are rejected before dispatch. Methods other than GET, HEAD, and OPTIONS additionally require --allow-side-effects. The variant is not sent until the captured baseline is terminal and has a complete response fingerprint. Chromium must also recover the exact request body and prove that the effective Authorization/cookie context has not drifted. Redirects are not followed, response capture is bounded, and results contain hashes and sizes rather than body text or header values. Cookie and hop-by-hop headers from the captured request are not copied.

Email confirmation with MailSlurp

The service loads MAILSLURP_API_KEY on demand from ~/.config/web-audit-workbench/secrets.env. The file must use mode 0600; the key is never placed in CLI arguments, responses, command events, or Audit Desk summaries.

MAILSLURP_API_KEY=...
# Create one inbox and return its inboxId and emailAddress.
npm run ctl -- email create --provider mailslurp --window win_12345678

# Wait up to 120 seconds for the newest unread message.
npm run ctl -- email wait --inbox INBOX_ID --timeout 120 \
  --window win_12345678

# List metadata without hydrating message bodies.
npm run ctl -- email messages --inbox INBOX_ID --limit 20 \
  --window win_12345678

# Select a matching verification link and open it in an identified tab.
npm run ctl -- email open-link --message MESSAGE_ID --match 'verify|confirm' \
  --window win_12345678

email wait returns metadata, a preview of at most 2,000 characters, extracted codes, and links. Add --include-read to intentionally reuse a read message. wait and messages accept --output PATH, written with mode 0600.

email open-link accepts only HTTP and HTTPS. The destination host must already exist in the selected window. Add repeatable --allow-host HOST only for separately authorized identity or confirmation hosts.

Fast decisions with Jev through TypeSafe or Vercel AI Gateway

The CLI can use TypeSafe AI's Jev evaluation model as a fast decision coprocessor. Jev does not generate browser commands or call tools. It evaluates one compact state against typed choice, score, and boolean questions; the supervising agent validates the probabilities, executes an existing Workbench command, and observes the result.

Jev can use TypeSafe directly with model jev-latest or Vercel AI Gateway with model typesafe-ai/jev. Provider auto is the default: it prefers a configured TYPESAFE_API_KEY, then uses AI_GATEWAY_API_KEY. Set either or both in the environment or in the same protected secrets file used by MailSlurp:

MAILSLURP_API_KEY=...
TYPESAFE_API_KEY=...
AI_GATEWAY_API_KEY=...

The secrets file must use mode 0600. The environment value takes precedence, followed by the Workbench secrets file. If it has no direct TypeSafe entry, the CLI also accepts exactly one APIKEY=... or TYPESAFE_API_KEY=... entry from a mode-0600 ~/.typesafe.ai file. If the Workbench file has no Gateway entry, the CLI can read exactly one APIKEY=... or AI_GATEWAY_API_KEY=... entry from the protected ~/.vercel file. Every file-backed credential must use mode 0600, and the key is never returned in CLI output. Use --provider typesafe or --provider vercel to force one transport; JEV_PROVIDER supplies the same default. Selection is deterministic and does not silently switch providers after a failed request. A Jev evaluation does not require the Electron service to be running.

Create an input such as jev-next-action.json:

{
  "state": {
    "goal": "Continue the login flow with the least noisy next action",
    "page": "/login",
    "observed": ["email field filled", "continue button ref el_4_2"]
  },
  "questions": {
    "nextAction": {
      "type": "choice",
      "instructions": "Choose one already available Workbench action.",
      "criteria": {
        "click_continue": "Click ref el_4_2 with observe and wait for requests.",
        "inspect_again": "Refresh semantic refs before changing the page.",
        "stop": "Return control to the supervising agent without a page action."
      }
    },
    "safeToContinue": {
      "type": "boolean",
      "instructions": "Is a low-risk next action supported by the supplied evidence?",
      "criteria": {
        "true": "The target and expected observation are explicit.",
        "false": "The target is ambiguous or the action would exceed scope."
      }
    }
  }
}

Evaluate it with:

npm run ctl -- jev evaluate --input ./jev-next-action.json \
  --provider auto --timeout-ms 15000 --max-retries 1

Add --output PATH to write the bounded result with mode 0600. The input is limited to 1 MiB and 1–32 questions. Choice accepts 1–255 options and Score accepts 2–10 ordered levels; these limits are checked before a provider call. Question and choice IDs start with a letter and contain at most 64 letters, digits, underscores, or hyphens. The response contains answers, optional TypeSafe confidence metadata, token usage, sanitized warnings, and elapsed time; it never echoes the input state.

Use Jev only to choose among explicit, in-scope candidates. Do not send secrets, session material, unredacted bodies, or broad source dumps. A selected option is not authorization: the supervising agent must reject ambiguous/low-probability answers, preserve the normal safety boundary, execute the chosen CLI action, and inspect its monitored outcome before asking Jev for another decision.

One-command live SPA capture and review

jev live-review connects the live browser evidence path to the SPA review in one bounded command. It captures a fresh audit snapshot, brackets an explicit GET navigation (or reload of the current URL) with the JavaScript monitor and V8 execution coverage, inventories the resulting scripts, retrieves an exact allowlist of executed same-origin sources, and runs jev spa-review. Obvious telemetry, vendor, runtime, and polyfill files are excluded before source acquisition. Source maps are deliberately disabled on this path because the current browser fetch cannot enforce a byte limit before downloading a map.

npm run ctl -- jev live-review \
  --window WINDOW_ID \
  --tab TAB_ID \
  --route /websites \
  --provider typesafe \
  --max-source-scripts 64 \
  --max-source-bytes 33554432 \
  --max-unknown-source-units 64 \
  --max-source-unit-bytes 8388608 \
  --max-provider-endpoint-literal-groups 512 \
  --max-provider-api-type-groups 256 \
  --catalog-decision-cache /absolute/hostinger-capture/jev-catalog-cache.json \
  --output-dir /absolute/evidence/hostinger-live-review

The route must remain on the active origin unless an additional authorized host is named with repeatable --allow-host. The command never clicks a page control, types a value, submits a form, or enables Jev side effects. Output directories are mode 0700; every JSON artifact is mode 0600; existing output files and symlink output directories are rejected. The JavaScript monitor is stopped in a finally path even when capture fails.

Source selection requires non-zero execution in the observation window and an exact same-origin script URL. Defaults are 24 scripts and 8 MiB; hard limits are 64 scripts and 32 MiB. The report records selected/omitted scripts, retained bytes, monitor truncation, phase timings, AST coverage, and execution coverage. Every live review also produces the mandatory apiInventory and frontendRouteInventory inside spa-review.json. Candidate discovery is deterministic; AST-confirmed request boundaries are combined with a literal-first pass over JavaScript string and template nodes. The latter finds endpoint-shaped paths in maps/configuration even when no request callsite is present; comments, regular expressions, module specifiers, obvious static assets, and opaque payload strings are excluded locally. For each remaining literal, one Jev Choice is route-only and tri-state: api_route, concrete not_api, or unknown_insufficient_context. Every accepted API then enters a separate type pass. The types organize resource-oriented HTTP (http_resource), command/RPC HTTP (http_action), GraphQL (graphql), WebSocket/SSE/streaming (realtime), one-way telemetry/log/event collection (event_ingest), and confirmed HTTP whose narrower role is not established (http_unspecified). The deduplicated type pass covers accepted literals and accepted callsites. The available choices are constrained by proven transport: GraphQL, realtime, and beacon candidates can only become graphql, realtime, and event_ingest, respectively. Accepted static routes are grouped under endpoints, accepted calls whose target cannot be resolved stay under unresolvedCallsites, explicit rejections remain in rejectedCandidates, and failed/omitted/insufficient decisions remain in unclassifiedCandidates. No candidate silently disappears and a dynamic expression is never turned into a guessed or probed endpoint. Request hints are value-free: method and normalized path, path/query parameter names, body field paths, header names, and body kind. They retain local chunk, source-unit, fact, and source-location provenance.

frontendRouteInventory is additive and does not remove or reinterpret any apiInventory record. Its acceptedRoutes are deduplicated normalized paths proved by local AST consumers: explicit registry descriptors with path plus componentPath, resolved router navigation, or browser-history navigation. Path and hash navigation modes remain distinct, and value-free query parameter names are retained. An endpoint-shaped literal is accepted or rejected only when an exact value-free reference (sourceUnitId plus AST offsets) links it to a frontend or proven HTTP consumer. Equal path strings in a React route registry and an API registry therefore remain separate. Provider frontend_route_catalog labels alone never create accepted frontend routes; missing or conflicting consumer evidence remains in unclassifiedCandidates, known HTTP consumers remain in rejectedCandidates, and coverage records the gap. The nested schema is web-audit.spa-frontend-route-inventory.v1.

Cross-source Webpack resolution partitions exact content-addressed chunks by their strongest debugger/capture scope. Repeated document generations inside one scope are additive. Multiple capture cohorts are resolved independently: a duplicate module provider fails closed only for its own cohort and cannot erase the same fact already resolved by another cohort. Two incompatible resolved facts for the same source location still fail closed.

Passing a complete web-audit.spa-review.v2 JSON file to application-context/scripts/context.py import-evidence activates an additive frontend-route adapter. Semantic import requires an explicit --base-url whose origin is in allowed_origins and an explicit --role declared in scope.roles; neither is inferred from a seed URL or role ordering. The adapter validates extractor version 12, the mandatory API inventory, the deterministic frontend producer, partition counts, classifiers, witness provenance, and disjoint candidate IDs before changing SQLite. Only acceptedRoutes become canonical routes with evidence-backed HYPOTHESIS assertions. Concrete static routes and safely tokenless optional-parameter routes enter the selected role's frontier as DISCOVERED, without invented query values. Routes with required or dynamic parameters receive only a BLOCKED frontier item until a concrete runtime instance is known. Rejected and unclassified candidates never become routes. Incomplete coverage and accepted routes whose redacted authority cannot be matched to one allowed origin are recorded as open gaps. An older or unsupported SPA report is still registered through the generic immutable evidence path and creates an explicit unsupported-semantic-import gap.

API inventory materialization is an independent opt-in segment. Supply --api-base-url separately from the frontend --base-url; both origins and the role must be present in the frozen scope. Only accepted inventory endpoints with an accepted callsite and a proven HTTP method become canonical static HYPOTHESIS endpoints. Unknown methods are not replaced with GET, and literal-only, unresolved, rejected, or unclassified records remain immutable evidence plus gap/statistic counts. Proven query names are retained without values; request bodies and headers are never guessed. Omitting --api-base-url preserves the old frontend import behavior and records a SPA_API_BINDING_REQUIRED gap instead of binding API paths to the page host.

Reviewed web-audit.*.p0-api-callsites.v1 offline artifacts can deepen the segment: an AST-proven method is imported as CORROBORATED only when the callsite is explicitly correlated to an accepted SPA API endpoint in the sibling file named by scope.reviewArtifact. The importer validates the companion report and correlation, then registers both evidence files. Each P0 callsite inherits the accepted endpoint's authority proof: --api-base-url is used only for application-relative endpoints, while an absolute redacted authority hash must resolve to exactly one origin in the frozen scope. An unresolved authority stays evidence-only and opens a gap rather than being silently rebound to the primary API. Uncorrelated and unresolved entries stay evidence-only. Re-importing either artifact is idempotent.

python3 skills/application-context/scripts/context.py import-evidence \
  --context ./application-context/target --input ./spa-review.json \
  --kind derived-analysis --base-url https://app.example/ \
  --api-base-url https://api.example/ --role member

python3 skills/application-context/scripts/context.py import-evidence \
  --context ./application-context/target --input ./p0-api-callsites.json \
  --kind derived-analysis --api-base-url https://api.example/ --role member

apiInventory.codexReviewQueue contains one pending review item for every accepted endpoint and unresolved route callsite. Rejected and unclassified candidates do not enter contract deepening. It is the worklist for the supervising Codex agent to inspect the referenced local evidence and deepen the method, path, query, body, and header contract. Jev receives only a redacted path/call shape for route adjudication, never body, header, query-name, API-parameter, or value details. Raw/full literal text, raw authority, query/body/header values, and parameter names never enter the literal/type classification payload; Jev sees only a conservative redacted path shape and structural enums. For live review, the locally selected AST units also enter the deterministic client source-to-sink engine for XSS and open-redirect candidates, followed by bounded Jev prioritization. Unknown/mixed units remain marked contains_unresolved; Jev cannot promote their authorship. The combined report stores the paths and adapter/analysis/triage/provider timing under sourceToSink. Aggregate Jev calls, elapsed provider time, input/output/total tokens, and incomplete usage records are stored in providerMetrics and copied to the live summary. providerMetrics.byStage always contains the fixed applicationProfile, pages, ownership, chunks, endpointLiterals, entrypoints, apiTypes, and sourceToSink buckets with the same six numeric fields. Summing any field across those buckets exactly equals its top-level value. A call is one provider-evaluator batch invocation; SDK retries remain folded into that invocation's elapsed time and reported usage, and a retry-exhausted invocation is one incomplete failed call in its originating stage. Missing provider usage is never inferred beyond the existing finite input/output fallback for totalTokens. This is automatic for the observed route, not a claim that the entire remote application was recovered: unloaded lazy chunks, unattached workers, server code, over-budget sources, and unavailable source maps remain explicit gaps.

Offline SPA route, chunk, and entrypoint review

jev spa-review classifies previously captured SPA evidence without starting Electron, connecting to the Workbench service, or contacting the target. It reads only explicitly named local JSON files. Its only network dependency is the selected Jev provider (auto, typesafe, or vercel). At least one input artifact and --output are required; the report is written with mode 0600. All seven artifact flags are repeatable: --audit-panel, --javascript, --scripts, --actions, --coverage, --manifest, and --artifact.

For example, a generic Hostinger capture can be reviewed after collection, without including credentials or replaying browser actions:

npm run ctl -- jev spa-review \
  --audit-panel /absolute/hostinger-capture/audit-panel.json \
  --javascript /absolute/hostinger-capture/javascript-page-1.json \
  --javascript /absolute/hostinger-capture/javascript-page-2.json \
  --scripts /absolute/hostinger-capture/selected-scripts.json \
  --actions /absolute/hostinger-capture/actions.json \
  --coverage /absolute/hostinger-capture/profile-route-coverage.json \
  --provider auto \
  --max-provider-ownership-candidates 64 \
  --max-provider-entrypoint-candidates 96 \
  --max-provider-endpoint-literal-groups 512 \
  --max-provider-api-type-groups 256 \
  --max-unknown-source-units 8 \
  --max-source-unit-bytes 2097152 \
  --max-total-source-bytes 8388608 \
  --max-ast-nodes 400000 \
  --max-ast-facts 10000 \
  --source-to-sink \
  --output /absolute/hostinger-capture/spa-review.json

Before any provider request, a mandatory deterministic prepass selects the bounded local source units, runs grep/AST extraction, and fixes the API candidate set. Provider output cannot add, remove, or reorder the source units or API candidates in that prepass; it can only adjudicate an existing candidate. The provider-facing pipeline has seven separately metered core stages: classify the application profile; select the relevant route catalogs and classify pages; preserve deterministic, evidence-backed ownership while asking Jev for hypotheses about unresolved chunks; classify roles for eligible application chunks; classify endpoint literals; classify the remaining URL, navigation, API, messaging, storage, input, worker, and module-loading entrypoints; then classify accepted APIs by type. Optional source-to-sink Jev triage is the eighth metrics bucket. Grep and AST extraction happen locally. AST-confirmed entrypoints are classified locally by default. Jev sees only bounded structured evidence; non-API ast_ambiguous/grep_only candidates may carry a normalized redacted window, while API candidates carry only apiEvidence with a pattern, redacted path shape bound to the exact grep match, method/resolution flags, lexical context (code, comment, string, or regex), and generic call shape. Generated bundle units use grep only as a fallback when their local AST pass is not fully parsed. Raw JavaScript source is never sent to Jev. Pathnames are converted locally to structural shapes. Safe semantic route segments remain visible, while named placeholders, resource IDs, UUIDs, numeric codes, emails, and credential-like values are replaced locally; raw authorities, queries, and full URLs never cross the provider boundary. Page batches shrink by page count first. If one provisional page still exceeds the 24 KiB state-plus-question limit, the pipeline retains every route option and stable code while dropping only optional signal/exclusion prose from those option definitions.

Endpoint-catalog decisions, every direct endpoint-literal batch (including member refinements), and accepted-API type batches can optionally be reused with --catalog-decision-cache PATH. This cache is disabled by default and requires an explicit typesafe or vercel provider; auto always bypasses reuse because credential resolution can change providers between runs. A hit requires the exact stage/pass, bounded state, complete question/choice schema, extractor/taxonomy version, requested provider/model, and a valid untampered output. --refresh-catalog-decision-cache bypasses reads and atomically replaces entries; --no-catalog-decision-cache explicitly disables it. Cache files are local-only and mode 0600 inside an owned directory that is not group/world-writable; symlinked parents are rejected. Entries expire after six hours. The TypeSafe jev-latest request may reuse only an official resolved jev-X.Y.Z model, while fixed models and Vercel require exact equality. Concurrent writers lock, merge, and compare-and-swap each decision so they do not lose unrelated entries or undo a newer refresh; capacity eviction removes the oldest decisions first. Invalid/corrupt entries are ignored and the provider is called. These flags work for both spa-review and live-review. Thresholds are unchanged, including the API-type confidence gate. catalogDecisionCache reports hits, misses, bypasses, writes, invalid entries, questions served, and provider calls avoided. Cache hits carry decision fingerprints and contribute no calls, time, or tokens to providerMetrics.

Extractor version 11 also removes the upstream Jev chunkRole from endpoint-literal provider IR while retaining it for general entrypoints. Thus a stochastic chunk-role answer cannot invalidate otherwise identical literal cache inputs; the version bump deliberately invalidates older cache entries.

The extractor also resolves unambiguous Webpack producers and consumers across separate source units without concatenating source text. Duplicate module IDs fail closed, facts retain the consumer source and offsets, and configured clients whose initialization cannot be proven remain explicit HTTP candidates. A statically named member such as .get or .put still supplies its syntactically known verb. When the exact exported instance has one direct, static defaults.baseURL assignment, the candidate also retains only the redacted authority and value-free shape of explicit call arguments; it still records initialization and interceptor gaps and is not runtime proof. An immutable axios.create({ baseURL: ... }) export is a proven client link. Unknown receiver names, conditional/dynamic/duplicate configuration, mutated bindings, ambiguous module IDs, and unproven configuration inheritance fail closed.

An HTTP URL wrapped by one opaque but immutable helper boundary can retain a statically resolved first path argument as resolution: "wrapper". A safe plain-object second argument contributes value-free query-key names; an identifier or otherwise safe dynamic query argument retains the path but adds an explicit unresolved-query gap. This is candidate evidence, not proof of the helper's runtime return. Nested helpers, unresolved first arguments, side-effectful arguments, dynamic computed keys, and locally visible helper bodies whose transformation cannot be proven still fail closed. Spreads and duplicate query keys never contribute names.

Extractor version 12 fixes the API candidate set locally before provider use, then overlays local/Jev adjudication into apiInventory. Its schema is web-audit.spa-api-inventory.v3 and its producer is deterministic_local_extraction_with_jev_adjudication; jevUsed is true when Jev actually classified either an API candidate or an API type. Version 12 invalidates older catalog decisions because explicit unresolved-client verbs and one-hop local route helpers now contribute different deterministic evidence. An accepted resolvable target becomes an endpoints record with evidenceState: "referenced_static"; this is static evidence, not proof that the endpoint was reached at runtime. A static literal captured by the exact grep callsite may supply its redacted path/method; the Jev answer never supplies or invents either. Accepted routes with no resolved path remain separate in unresolvedCallsites, including their reason codes and provenance. The extractor does not synthesize a path from a function name, try likely routes, browse to it, or probe it.

Endpoint request-contract hints contain no values or raw expressions. They include normalized method/path resolution, path and query parameter names, body field paths, header names, body kinds, and explicit partial/unknown reason codes. Absolute authorities are represented only by a hash and a redacted scope. The provider-facing entrypoint IR uses only bounded apiEvidence for API adjudication and omits API query keys, argument-object key summaries, token windows, and the complete request shape; body/header/API-parameter details stay local for Codex review. The local AST pass also traces immutable object-map members and one-hop local route helpers through stable aliases into static .get/.put/.patch/... callsites. It records the explicit verb as methodResolution: "known" and the resolved static/templated path, but keeps the candidate ambiguous when the receiver is not a proven HTTP client. In that case authority, body, params, headers, and the client contract remain unresolved. Mutated helpers, multi-hop helper calls, object spreads, duplicate keys, dynamic computed keys, and ambiguous helper returns fail closed. An accepted http_custom_client_candidate that still carries http_client_contract_unresolved is materialized with authorityScope: "unresolved", never as application-relative. Unknown authorities remain one endpoint per callsite; a configured absolute authority still wins, and deterministic direct relative HTTP calls remain application-relative. For every literal, the route-only Jev record remains in the entrypoint's decision; for every accepted API, the separate type record is apiTypeDecision. Candidate-level apiType and apiTypeClassifiedBy feed the inventory, while each endpoint's apiTypes is a sorted, deduplicated union across its contributing callsites. Each endpoint also exposes primaryApiType and apiTypeResolution; the primary value is a stable index key, while the union preserves every contributing classification. Endpoint transport, authority, method, path identity, and endpoint ID are derived before and independently of apiType; provider typing can neither merge nor split endpoint identities. apiTypeResolution: "partial" means at least one accepted callsite for that endpoint still lacks a type. The single primary type uses an explicit precedence for overlapping evidence: realtime > graphql > event_ingest > http_action > http_resource > http_unspecified. This keeps the pass to one fast Choice per deduplicated route group while making hybrid cases deterministic. apiTypeIndex.byType contains all six fixed type buckets with sorted endpoint, unresolved-callsite, and callsite IDs. Typed callsite IDs partition accepted assessments exactly once; a mixed endpoint may occur in more than one bucket. apiTypeIndex.acceptedWithoutType is reserved for accepted APIs whose type pass was absent, omitted, uncertain, invalid, or failed. It is distinct from both unclassifiedCandidates (API status unknown) and rejectedCandidates (concrete non-API). For every accepted endpoint and unresolved callsite, codexReviewQueue supplies subject, candidate, chunk, source-unit, and fact IDs plus the fields that Codex must review. An accepted API left without a type adds api_type and the precise provider/budget reason to that queue. Follow those locators into the local source evidence rather than asking Jev to reconstruct the contract.

External entrypoint classification is bounded to 12 candidates per source unit, 24 per chunk, 24 per entrypoint family, 96 globally, and at most eight batches. Lower the global ceiling with --max-provider-entrypoint-candidates 0..96. Candidates omitted by these budgets remain explicit unknown/acquire_evidence records, and the report's entrypointProviderBudget explains each omission.

Endpoint-literal adjudication has an independent group budget so a large map of frontend paths cannot consume the generic entrypoint allowance. It defaults to 512 groups (the hard maximum), uses batches of at most 12, and accepts --max-provider-endpoint-literal-groups 0..512. API-looking prefixes and endpoint-related bindings are prioritized locally. Overflow remains unclassified with entrypoint:endpoint_literal_group_limit; group/candidate counts, batches, limit, hard maximum, and the reason are recorded in endpointLiteralProviderBudget. Dense object catalogs are split deterministically into structurally coherent, complete partitions of at most 24 members. Each partition is classified once. A concrete partition decision propagates only with full partition evidence, compatible local transport structure, selected probability at least 0.45, runner-up margin at least 0.15, and normalized confidence at least 0.30. Names such as api or routes remain provider signals and never prove a catalog class locally. After every partition of an authored catalog has been classified, complete-catalog API/frontend consensus requires at least two complete partitions, at least 80% provider-safe semantic property names, a strict majority, one decisive supporter, no decisive conflict, aggregate margin at least 0.15, and confidence at least 0.25. Its probability floor is 0.45 for a majority or 0.40 for unanimity; a provider-only bundler consensus never propagates. One narrow API exception may override exactly one decisive frontend partition: API must retain a strict partition majority with at least two supporting partitions, and those partitions must contain at least 80% of all catalog members. At least three distinct catalog-member paths must exactly match AST-confirmed local HTTP request/custom-client candidates in the same chunk and with compatible authority scope (both application-relative or the same hashed absolute authority), and no exact-path AST-confirmed local route/navigation candidate may contradict them. Duplicate callsites, AST-ambiguous candidates, and grep-only evidence do not count; mixed, unknown, bundler, or multiple dissent still fail closed, and all existing completeness, property-coverage, and aggregate gates still apply. Consensus provenance and the catalog-partition summary retain explicit supporting member/partition counts and ratios, the exception reason, minority count, corroboration counts, and bounded evidence IDs. Consensus provenance retains at most 32 deterministic witnesses and 32 evidence fact IDs (at most 8 per witness), with explicit overflow counts when truncated. Weak, mixed, and explicit-unknown partitions refine only their own members with the bounded partition hypothesis marked as prior evidence, never as proof. endpointLiteralProviderBudget.catalogPartitions records original catalogs, partitions, decisions, propagation, consensus propagation, refinement, and omissions; each member retains its original catalog and partition provenance.

The separate accepted-API type pass defaults to 256 deduplicated route groups, has a hard maximum of 256, uses batches of at most 12, and accepts --max-provider-api-type-groups 0..256. Overflow does not remove or reject an accepted route: it leaves apiType absent, sets apiTypeClassifiedBy to provider_budget, records api_type:provider_group_limit, and makes API coverage incomplete. Its group/candidate/batch metrics are in apiTypeProviderBudget. These two budgets are independent of entrypointProviderBudget. For a multi-choice HTTP result, a concrete type is retained only when selected probability is at least 0.45, the runner-up margin is at least 0.15, and normalized confidence is at least 0.30. A weaker result remains attached as the raw apiTypeDecision, but leaves apiType absent with apiTypeClassifiedBy: "provider_uncertain" and apiTypeOmissionReason: "api_type:provider_low_confidence". Proven GraphQL, realtime, and beacon transports expose a single compatible choice and are not erased by provider confidence.

Chunk ownership is conservative. A same-origin URL records a first-party delivery relationship but does not prove application authorship. Repeatable --application-script-id ID is an explicit, security-sensitive operator override that marks the chunk containing that captured Debugger ID as application-owned. Unknown IDs fail before provider evaluation. Use it only after verifying the ID against the supplied artifacts. Because Debugger IDs are session-scoped, each override must resolve to exactly one captured session/capture discriminator and one chunk; missing or reused scopes fail closed. Additional source-map, delivery, and package evidence can be supplied with repeatable --application-root, --application-origin, and --package-name; an application origin still describes delivery, not authorship.

A source map is excluded as library code only when every relevant unit is positively identified as vendor, telemetry, or internal. If a bundle mixes a known library with an unresolved unit, the chunk stays mixed/ acquire_evidence; the unresolved unit remains eligible for local AST review. Confirmed application units are analyzed separately and do not consume --max-unknown-source-units.

Only chunks whose deterministic ownership is still unknown enter the Jev ownership pass. Its labels are application_route, application_shared, framework_runtime, bundled_vendor, telemetry, mixed_bundle, and unknown. The bounded IR describes which scripts were loaded in each capture, safe locator tokens, byte/source-map summaries, route differentials, and the captured transition kind; it never contains raw source, raw URLs or queries, source-map paths, credentials, or secrets. A script newly observed after a full document navigation is relevant to that page, but that timing is not evidence of authorship. A delta caused by a same-document SPA navigation or interaction is stronger prioritization evidence, but remains a hypothesis.

The ownership hypothesis cannot promote a chunk to application-owned, mark it as a library, exclude it, override deterministic telemetry/internal/vendor evidence, or change which sources enter local AST/API extraction. It only annotates the later review priority of still-unknown chunks. The provider pass considers 64 chunks by default, can be disabled with --max-provider-ownership-candidates 0, and has a hard maximum of 96. It uses batches of 12 and caps selection at 16 per route, 12 per path family, and 32 per capture context; omissions remain explicit in ownershipProviderBudget.

Local parsing defaults to at most eight unknown source units, 2 MiB per source unit, and 8 MiB total. Adjust those limits with --max-unknown-source-units, --max-source-unit-bytes, and --max-total-source-bytes, --max-ast-nodes, and --max-ast-facts. Units omitted by an ownership or byte budget remain explicit unknown/acquire-evidence results rather than being silently promoted.

The report schema is web-audit.spa-review.v2 with extractor version 12. It includes the application, pages, chunk ownership/roles, entrypoint candidates/ classifications, mandatory apiInventory and frontendRouteInventory, evidence index, exclusions, unresolved evidence, a deterministic queue, an astCoverage ledger, an endpointLiteralDiscovery coverage/omission summary, and a directed execution graph. It also records endpointLiteralProviderBudget and apiTypeProviderBudget. Queue lanes are review_now, review_later, acquire_evidence, and excluded. They are review priorities derived from evidence, not vulnerability findings.

If a Jev evaluation fails, the full SPA runner makes no repeated classification attempts, marks affected API candidates unclassified, and still returns the deterministically discovered candidate set. providerFailures records bounded reason codes; the optional source-to-sink triage follows the same rule and retains its local paths. An externally requested abort is still propagated.

Always report apiInventory.summary.candidateCount, apiRouteCandidateCount, notApiRouteCandidateCount, unclassifiedCandidateCount, callsiteCount, distinctEndpointCount, unresolvedCallsiteCount, the query/body/header endpoint counts, methodUnknownCount, parameterUnknownOrPartialCount, apiTypeCounts, endpointApiTypeCounts, unclassifiedEndpointCount, apiTypeUnclassifiedCandidateCount, jevApiTypeClassifiedCandidateCount, providerBudgetApiTypeCandidateCount, providerUncertainApiTypeCandidateCount, and codexReviewCount. Also report apiInventory.coverage.complete and every gapReasonCode. API coverage is complete only when bounded AST coverage is complete and there is no unresolved route callsite, unclassified candidate, or accepted API missing a type; it does not cover unloaded lazy chunks, omitted sources, unattached workers, or server code. apiTypeCounts counts typed accepted candidate/callsite assessments, not deduplicated endpoint records and not merely the number of distinct type labels. endpointApiTypeCounts instead counts each resolved endpoint once by primaryApiType. Inspect apiTypeIndex when the concrete endpoint/callsite membership matters. The compact live-review.json exposes the same two count maps plus per-bucket endpoint, unresolved-callsite, and callsite counts without duplicating the full records. Also report every field in endpointLiteralDiscovery: sourceUnitCount, extractedCandidateCount, admittedCandidateCount, duplicateCallsiteCount, omittedCandidateCount, skippedSourceUnitCount, chunksWithoutSourceUnitsCount, statusCounts, gapReasonCodes, and complete. An incomplete literal-discovery ledger also makes API coverage incomplete. Literal extraction omissions/source gaps, entrypoint:endpoint_literal_group_limit, api:type_classification_incomplete, and api_type:provider_group_limit remain explicit rather than being treated as negative results.

Also report frontendRouteInventory.summary.candidateCount, acceptedCandidateCount, rejectedCandidateCount, unclassifiedCandidateCount, and distinctRouteCount, plus frontendRouteInventory.coverage.complete and every gap reason. Frontend-route coverage is incomplete when bounded AST/literal coverage is incomplete or any candidate lacks decisive local consumer evidence. The compact live review mirrors these five counts and the completeness flag.

--source-to-sink additionally converts the same bounded AST-selected client units into the generic code-flow engine. It records XSS/open-redirect paths, runtime-capture provenance, authorship uncertainty, coverage gaps, and phase/ provider timing under sourceToSink. Add --no-source-to-sink-jev to skip only this path-triage call; the other SPA classification passes still use the selected Jev provider.

Runtime SPA graph construction remains isolated per source unit for budget containment. An additive phase may analyze at most 64 direct two-unit cohorts when a consumer calls a literal Webpack module ID with one unique captured provider; it adds an inter-unit edge only when that provider exposes one exact function export used by the consumer. Such paths carry cross_module_import_export, webpack_module_id_exact, webpack_export_name_exact, and deterministicStatic.interUnitLinks provenance. Equal names without that module/export identity never link. Dynamic module IDs, non-function exports, unresolved re-exports, duplicate providers, and multi-hop cohorts remain explicit under spa_sharded_inter_unit_flow_partial:direct_webpack_function_exports_only and more specific analyzer reasons. Jev can rank the resulting path but cannot create or repair an inter-unit edge.

astCoverage accounts for every observed source unit instead of implying that all Next/React source was recovered. It distinguishes parsed, recoverably parsed, missing, unsupported/data, excluded, over-budget, and unselected sources. Production source maps without sourcesContent, unloaded lazy chunks, React Server Components/server code, and unattached workers cannot produce the original client AST. When a loaded Next/Webpack bundle is available, its compiled bundle can still be parsed as a bounded fallback; that is not the same as recovering every original module. Each unit is capped at 400,000 AST nodes, depth 512, and 10,000 emitted facts; --max-ast-nodes and --max-ast-facts can select lower limits. parsed_truncated and ast_limit_exceeded expose these limits instead of claiming completeness.

execution is a route/action-to-chunk directed graph, not a JavaScript call tree. Each coverage window joins by captured debugger scope, script ID, and generation; ambiguous or missing scope stays execution_unknown. Nodes distinguish executed, not_observed_executed, and execution_unknown, plus whether a chunk was shared or unique among the measured routes. not_observed_executed means zero execution only in that window, never “this script never executes.” Coverage means “executed during the observation window”; concurrent timers may also run, so it is not proof that the action caused every byte.

Production Next.js source-to-sink review

jev client-taint analyzes an explicit local Next.js production .next directory and emits web-audit.client-taint.v3. It does not read the project's app/ or src/ tree. Client/server application units must be recoverable from the production JavaScript source maps and their embedded sourcesContent. The loader validates base64-VLQ mappings and accepts an embedded source only when a mapping segment references its source index; stale/unmapped application entries remain explicit unmapped_source gaps. Symlinked artifacts, path escapes, remote source maps, invalid manifests, and inputs other than a .next directory fail closed. Artifacts declared by Next in build-manifest.json#polyfillFiles are the only manifest-level client exclusion; the report retains their path, byte hash, and exclusion reason. A mapless rootMainFiles or ordinary shared chunk remains an explicit coverage gap. Registry coverage is scoped to manifest_referenced_artifacts and has globalComplete: false: referenced server chunks are followed transitively, but lazy chunks absent from manifests and worker artifacts are not enumerated.

npm run ctl -- jev client-taint \
  --next-build /absolute/project/.next \
  --provider typesafe \
  --output /absolute/evidence/client-taint.json

The local workbench-code-flow engine builds bindings, parameter/return edges, import/export links, Promise/callback edges, React state edges, and bounded HTTP and persistence bridges. It currently recognizes HTML XSS sinks including innerHTML, outerHTML, insertAdjacentHTML, React dangerouslySetInnerHTML, and iframe srcdoc, plus browser/router navigation sinks for open-redirect candidates. DOM property/method sinks require resolved DOM receiver provenance, React HTML props require a lowercase intrinsic element, and DOM input sources require an intrinsic form control. URL and router facts require an unshadowed browser global or a recognized framework import/hook; persistence requires node:fs/fs import or receiver provenance. Ambiguous rewritten bindings are omitted with an explicit incompleteness reason rather than being included in a complete path. The report contains an ordered path whose nodes cite the exact production build unit and artifact. Edge properties retain the evidence facts from which the flow property union is derived, including postMessage sender-guard state. A stored candidate is reported only when one path contains a write request, server persistence write and read, response request, and client sink.

When the bounded input contains an unshadowed native fetch but not its server producer, the engine keeps the observed response-to-sink path as exposure: "response" instead of discarding it. It follows json()/text(), nested properties of that opaque response, unshadowed String(...), resolved higher-order callbacks and renderer registries, and DOM receivers passed through matched calls. Call/return traversal is context-matched and prefers a path that does not pass through a helper named like an HTML escape/sanitizer, with a normal fallback when that is the only path. This preference selects evidence; it does not prove that a sanitizer is effective: unresolved named helpers are retained as bounded effectiveness: "unknown" assessments. Known local object literals remain field-sensitive, and an innerHTML-named property on a plain object is not a DOM sink. DOM receiver contexts are invocation-specific (at most 64 per binding and depth 32), and reassignment invalidates stale direct provenance. Stable literal registry keys select one target; multi-target dispatch remains a candidate with dynamic_dispatch_target_unresolved, incomplete flow, and unknown execution reachability. Only unshadowed native JSON, Object, and Promise helpers receive preserving semantics, and fulfilled and rejected Promise channels are kept separate. Proven non-callable handlers pass their channel through; unknown callability or settlement stays explicitly incomplete. Known returned native promises are assimilated, and executor settlement is tied to parameter binding identity rather than a shadowable name.

Such a response candidate has source.attackerControl: "unknown", flow.attackerControlled: false, flow.complete: false, and explicit attacker_control_unknown plus either server_response_producer_unavailable or server_response_input_path_unresolved. source.serverProducer distinguishes an absent producer from an exact matched handler whose input path is unresolved. A bounded closed-constant proof (4,096 AST nodes, depth 32) suppresses the fallback only when the matched producer cannot depend on outside input. The request method and endpoint evidence come only from the actual fetch expression when statically available; unresolved RequestInit is method UNKNOWN and cannot exact-match a GET/POST handler. A stable alias is evaluated in its declaration scope; member mutation or unresolved alias escape degrades it to UNKNOWN with request_init_mutation_unresolved. The analyzer never guesses, probes, or fuzzes an endpoint. Supplying the matching server/persistence units is still required before the result can become exposure: "stored"; dynamic persistence keys are omitted with an explicit bounded gap rather than joined by parameter name.

deterministicStatic.pathFound: true means that the local analyzer found the reported ordered AST/data-flow path. executionReachability is independent: established means the modeled control/call graph reaches that path, while unknown means reachability was not established and never means unreachable or safe. flow.complete describes structural resolution of the selected path, not runtime execution or exploitability. candidate_ready means ready for deeper review and controlled testing; none of these fields confirms a vulnerability. A safe observation under one runtime state does not negate an incomplete candidate. Keep it pending with its exact control, producer, coverage, and reachability gaps until those edges are acquired or disproved.

Each candidate also has flow.predicateAnalysis, a bounded local analysis of the branch conditions required to reach its sink. It records the required branch, structural assessment, evidence codes, and explicit blockers. It also walks a unique local caller and loop tests; alternative callers or unsupported control remain explicit blockers. The pass is capped at 32 predicates, control depth 32, and 4,096 control steps per candidate. unknown or blocked requires acquiring more evidence; neither status means unreachable or safe.

The top-level controlConditions catalog is independent of candidate discovery. It records bounded decision groups whose branches return a literal or contain supported HTML-sink syntax, so relevant application gates remain visible even when no complete source-to-sink candidate exists. Sink syntax is not DOM-receiver proof. It expands only exact synchronous, unshadowed local helpers; unsupported predicates and helper shapes remain gaps. It is capped at 100,000 AST nodes per source unit and, report-wide, at 256 groups and 512 gaps; each group is capped at 32 predicates and helper depth 4.

Jev receives only a compact structural mini-graph for each already-derived candidate and selects review_now, review_later, or acquire_evidence. It never receives raw JavaScript and cannot add an edge, remove a candidate, or replace the deterministic path evidence. The compact input includes pathFound, executionReachability, structural-path completeness, attacker- control status/reason, bounded unresolved-edge codes, and only predicate status, counts, and blocker codes; source and raw predicate expressions are excluded. Conditional analysis is deterministic and adds no provider call. It does not invent endpoints, browse, or fuzz. Use --no-jev for a completely local run. Triage is capped at 128 candidates in at most 16 calls of eight summaries; missing or malformed Jev answers and provider errors fail the command rather than becoming a ranked or safe result.

flow.attackerControlled: true records that the selected data-flow originates at an attacker-controlled source; it does not claim unrestricted payload freedom. false does not mean trusted: inspect flow.attackerControl and unresolvedEdges, because a response-only path may simply lack its server producer. Literal constraints are retained as unresolved predicate evidence instead of silently converting the candidate to safe.

The command is hard-bounded to 4,096 files, 64 MiB of source maps, 64 MiB of embedded source, 32 MiB per compiled artifact, and 2,048 source units; the corresponding --max-build-* options may only lower those limits. Output is mandatory and is written mode 0600. A clean report does not prove the application safe when a source map, lazy chunk, worker, server unit, or dynamic call is absent; such missing coverage must remain explicit evidence work. The graph defaults additionally cap AST work at 400,000 nodes/depth 512, flow at 250,000 nodes/1,000,000 edges, paths at 256 edges, source traversals at 4,096, aggregate graph traversal at 2,000,000 steps, source/sink evaluations at 250,000, and emitted candidates at 1,024. Limits and discarded ambiguous paths are reported as incompleteness.

This path was verified against a real Next.js 16 production build containing four controlled XSS chains: direct URL-to-DOM, helper/React, cross-module postMessage/async/state, and form-to-POST-to-persistence-to-GET-to-srcdoc. All four were recovered from build artifacts, and the same report passed with direct TypeSafe Jev triage enabled.

For a fast bounded browser loop, the CLI can build those candidates directly from the current semantic page and execute the validated selection:

npm run ctl -- jev loop \
  --goal "Fill the city, choose the plan, attach PAGE and finish" \
  --url https://app.example.test/ \
  --allow-host example.test \
  --values ./protected-values.env \
  --upload PAGE=/absolute/path/index.html \
  --execution-coverage \
  --provider auto --max-steps 20 --max-seconds 180 \
  --output ./jev-loop.json

For an ordered workflow, provide a versioned mission instead of one broad goal:

{
  "version": 1,
  "goal": "Open profile settings and enable product updates",
  "subgoals": [
    {
      "id": "open_profile",
      "instruction": "Open profile settings",
      "successEvidence": [
        { "kind": "url_path", "path": "/settings/profile" }
      ]
    },
    {
      "id": "enable_updates",
      "instruction": "Enable product updates and save",
      "successEvidence": [
        { "kind": "control", "role": "checkbox", "name": "Product updates", "state": "checked" },
        { "kind": "visible_text", "text": "Preferences saved" }
      ]
    }
  ]
}
npm run ctl -- jev loop \
  --mission /absolute/path/profile-mission.json \
  --context-policy ranked --provider auto \
  --output ./jev-mission-result.json

Mission files are strict JSON, limited to 32 KiB, and contain 1–8 ordered subgoals with 1–4 observable completion rules each. Rules can match current visible text, an exact URL pathname, or an exact visible control role/name with optional checked, selected, expanded, or filled state. Files must be regular rather than symbolic links. They are instructions, not a place for credentials. When both --goal and --mission are supplied, their goals must match after trimming.

Each mission cycle still uses one semantic inspection and one Jev request. The request adds an independent subgoal_done watcher. The loop advances only when Jev chooses DONE, that watcher is at least 0.85, and every deterministic rule matches locally; the last subgoal also requires global goal_done consensus. Advancing performs no browser action and always starts with a fresh inspection. A disagreement stops before another mutation. Results contain the current/completed/pending subgoal IDs and evidence booleans, while steps record their subgoalId. The supervisor authors the mission once; Jev chooses bounded page actions but does not generate or rewrite the plan.

Each cycle performs one semantic snapshot and one Jev request. The same request contains an operation head, independent goal_done and stuck boolean watchers, plus speculative target/source heads for the currently possible actions. The operations are CLICK, TYPE_TEXT, SELECT, UPLOAD_FILE, SCROLL_UP, SCROLL_DOWN, BACK, WAIT, DONE, and BLOCKED. DONE is accepted only when goal_done is at least 0.85; disagreement stops safely or uses the next executable operation instead of claiming success. A stuck watcher can stop repeated non-progress. Only the heads matching the executed operation are accepted. The CLI validates complete probability distributions, performs exactly one bounded action through observe-action, and inspects again. A stale ref is rejected by the normal Workbench generation guard; Jev never supplies selectors, coordinates, paths, text, or JavaScript.

Loop results include providerMetrics for Jev decisions and a separate textGenerationMetrics total for the optional field-text helper. Both report calls, provider elapsedMs, input/output/total tokens, and usageIncompleteCalls. calls counts physical provider attempts, including SDK retries. Token totals contain the usage exposed by completed responses; a retry whose response does not expose usage increments usageIncompleteCalls. A missing provider usage field never aborts the loop: known token counts are retained, totalTokens falls back to the known input and output sum, and the call is marked incomplete. These aggregates contain no prompts, generated field text, credentials, or page content; the top-level elapsedMs remains end-to-end loop wall time.

TYPE_TEXT replaces the complete live value: the Workbench focuses and selects the resolved DOM field and replaces it atomically in that element, so retries do not append, focus handlers cannot redirect the value to another field, and page input masks still receive input behavior. It revalidates connection, focus, disabled/read-only state, and editability after focus/beforeinput and immediately before mutation. Text remains length-only in action evidence. CLICK scrolls the resolved node into view, hit-tests the current box-model center, and dispatches exactly one CDP mouse press/release there. The target, its descendant, or an associated native label is accepted; an unrelated overlay fails closed. This works for inspected refs whose resolved visual node or descendant is mounted in a portal/listbox, while retaining the ordinary document-generation guard. The result reports delivery: "cdp-mouse", userEquivalent: true, and modifiedEvidence: false. It never falls back to HTMLElement.click() or replays a failed coordinate attempt; --click-dom is the only explicit programmatic fallback. The controlled Electron fixture is available through npm run test:electron-actions.

Each speculative target/source question states its assumed operation explicitly: questions in one evaluation do not see each other's answers. Only the answer needed for the selected operation is consumed.

Opt into local context ranking for ordinary browser workflows with --context-policy ranked --max-candidates 24. The default is legacy. --max-elements still controls how many elements are observed; ranking selects from that snapshot and cannot recover elements omitted by the inspection limit. The compiler prioritizes goal matches in labels and nearby context, retains visible and relevant off-screen controls plus different roles/contexts, and penalizes already ineffective targets. Existing disabled/side-effect filters apply before ranking. --max-candidates accepts 4–255 and caps each target head independently (default 24 in ranked mode); byte budgets may reduce it further.

Ranked mode caps the serialized evaluation at 48 KiB and state plus the longest question at 24 KiB. It removes whole low-priority entries and keeps at least one candidate for every offered operation. Mandatory context that cannot fit stops before the evaluation. These are local byte budgets, not token measurements. Goal, authorization, aliases and completion watchers are retained. Retained outline entries keep document order, and reduced snapshots are marked truncated.

Each executed step includes context metrics: policy, serialized byte counts, candidate counts, omitted controls, budget reduction and compilation time. These metrics contain no page text or field values. For an offline comparison:

npm run build
node scripts/bench-jev-context.cjs

This benchmark measures compilation of a synthetic 250-control snapshot. It does not measure provider latency, browser latency or task success. The CLI integration test uses the installed TypeSafe SDK with a local fixture server, not a live API.

The snapshot compiler automatically relates every actionable ref to nearby headings, labels, card/form/dialog text, document order, and its ordinal among otherwise identical controls. Four buttons named Selecionar, for example, are offered as four distinct refs with their own card context. Jev also receives a bounded ordered document outline plus text that actually intersects the current viewport. Visible controls are offered before off-screen controls while retaining their original document order, so the operator supplies the goal once rather than rebuilding a decision JSON after every page. The accessibility snapshot is supplemented with native input[type=file] elements discovered in the current document, including inputs hidden behind a custom upload surface. It also admits a bounded set of visible custom DOM click surfaces only when their effective onclick property is a function. Cursor styling alone is never treated as evidence. These controls are deduplicated by backend node; when nested custom handlers are visible, only the leaf-most surfaces are retained so a page-sized wrapper cannot shadow its specific cards. They are exposed as synthetic button refs with bounded accessible text, and still use the ordinary generation guard and click hit-test; selectors and local paths are never sent to Jev. The DOM scan is capped at 5,000 elements and 128 candidates, and the snapshot exposes domClickableDiscoveryTruncated when that discovery bound is reached.

--values FILE accepts a flat JSON object or NAME=value entries and requires mode 0600. Jev sees only alias names. Sensitive fields such as passwords, login identifiers, OTPs, and payment data require one of those stored aliases; the loop refuses to generate their contents and rejects mismatched alias classes (for example, PASS in an OTP field). Ordinary text can be drafted by a separate lightweight model through the same Vercel AI Gateway (default google/gemini-2.5-flash-lite, override with --text-model). Repeatable --upload NAME=/absolute/file entries expose only the alias and basename to Jev; file bytes and paths remain local.

The loop sends bounded semantic labels, structural context, a document outline, and visible text, not rendered HTML, selectors, coordinates, field contents, cookies, or request bodies. Form values are reduced to empty/filled, emails and secret-shaped strings are redacted, URLs retain query keys but not values and redact secret-shaped path segments; the output trace contains compact counts rather than captured request payloads. For long workflows, each decision also receives aggregate operation/alias-use counts, up to 12 recently visited safe URLs, and the latest 24 redacted actions. The current page host is the default scope; repeatable --allow-host entries permit an exact host or its subdomains. Selected links are checked before execution, and the loop stops if the resulting page leaves that scope. Controls whose labels indicate creation, publication, purchase, payment, transfer, deletion, or similarly meaningful effects are excluded by default; use --allow-side-effects only when the goal explicitly authorizes them.

The loop handles native controls and one selected frame. If a click opens a Workbench popup tab, the loop follows the new active tabId, waits within its wall-clock budget while an about:blank/loading document becomes ready, checks the host allowlist as soon as a concrete URL appears, and requires a stable ready URL/generation before another Jev decision. A permitted action that starts business requests or navigation also receives one bounded network-idle settle; the action is never replayed. If continuous requests prevent network idle, that timeout falls back to bounded semantic inspections. A changed URL/generation must stabilize as a complete destination. At the same URL/generation, a material semantic state must differ from the pre-action snapshot and remain stable across two complete observations. It covers control refs, roles, names, filled/disabled/checked/selected state, and structural outline entries; hash, visible-text, or polling-paragraph churn alone is not progress. A page still semantically unchanged after the settle stops, while unrelated control/service failures still surface. An unready popup still stops through the bounded transition/wall-clock limits. JavaScript alert and beforeunload dialogs are recorded and accepted automatically; confirm and prompt are recorded and dismissed so page-controlled choices cannot grant authorization. observe-action returns the dialog count/summaries and active tab transition. Custom widgets without an effective DOM onclick, drag-and-drop, and cross-frame workflows still require the normal supervisor. When a selected target has no visible effect, the loop uses the next-most-probable target, then the next executable operation when available. The default budgets are 20 actions and 180 wall-clock seconds; --max-steps permits up to 200 and --max-seconds up to 3,600. Three identical semantic observations also stop the run. Goal consensus is stronger than a single DONE choice, but consequential outcomes still require independent supervisor verification.

Recommended LLM workflow

Start with the smallest result that can answer the current question:

# 1. Discover interactive controls and stable refs.
npm run ctl -- inspect --window win_12345678 --tab tab_12345678

# 2. Read one element without guessing a selector.
npm run ctl -- query --ref el_1_3 \
  --window win_12345678 --tab tab_12345678

# 3. Group interaction, waiting, per-step requests, and final body.
npm run ctl -- sequence --ref el_1_3 --click --sleep 1 \
  --monitor-requests --window win_12345678 --tab tab_12345678

inspect uses the accessibility tree and returns only interactive elements by default. Each element can include its nearest useful structural context and documentOrder; --include-page-context adds a bounded pageContext outline. The Jev loop enables that outline automatically. Native file inputs omitted by accessibility are recovered from the current DOM. --all adds non-interactive semantic nodes. --include-visible-text adds a whitespace-normalized, email/secret-redacted rendered-text snapshot of text intersecting the current viewport, bounded to 16,000 characters. It also prioritizes viewport controls while preserving their original documentOrder. Use --limit and --offset to page through large trees. Refs remain valid until navigation, document replacement, or a new inspection generation.

Inside sequence, --click-dom is an explicit programmatic fallback when physical input has no effect. Its result is marked modifiedEvidence: true.

Querying, interaction, and waits

npm run ctl -- query '#account-panel' --window win_12345678
npm run ctl -- query 'form input' --all --frame FRAME_ID \
  --window win_12345678
npm run ctl -- eval '({title:document.title,url:location.href})' \
  --frame FRAME_ID
npm run ctl -- click '#continue' --window win_12345678
npm run ctl -- type --ref el_1_5 'auditor@example.com' \
  --window win_12345678

query returns bounded attributes, text, HTML, rectangle, and visibility for at most 200 elements. eval awaits promises and returns one serializable value. click uses one hit-tested CDP mouse press/release at the box-model center; type replaces the current value through CDP input and keyboard events.

npm run ctl -- wait --selector '#ready' --timeout-ms 15000
npm run ctl -- wait --text 'Completed' --frame FRAME_ID
npm run ctl -- wait --url '/dashboard'
npm run ctl -- wait --request 'graphql'
npm run ctl -- wait --network-idle --idle-ms 750
npm run ctl -- wait --js 'window.appReady === true'

Provide exactly one wait condition. --timeout-ms accepts up to 300,000 ms.

Ordered sequences and rendered body

sequence evaluates flags from left to right and sends the full operation to the service in one call:

npm run ctl -- sequence \
  --element '#email' --focus --type 'email@example.com' --sleep 0.5 \
  --element '#password' --focus --type 'example-password' \
  --element 'button[type=submit]' --click --sleep 2 \
  --wait-network-idle --idle-ms 750 --timeout-ms 15000 \
  --monitor-requests --monitor-types navigation,script,exception \
  --window win_12345678 --tab tab_12345678

--element SELECTOR, --ref REF, and --frame FRAME_ID change the target for subsequent steps. Actions include --focus, --click, --type TEXT, --eval JS, and --navigate URL. Wait steps include --wait-selector, --wait-text, --wait-url, --wait-request, and --wait-network-idle.

--sleep SECONDS belongs to the immediately preceding action, keeping asynchronous requests correlated with the action that triggered them. Monitoring is opt-in. Use --monitor-requests, --monitor-types, --monitor-match, --full-events, and --event-limit as needed. The default result ends with the current rendered body; use --no-body to omit it or --output to persist a large result.

npm run ctl -- rendered --window win_12345678 --tab tab_12345678
npm run ctl -- rendered --frame FRAME_ID --output ./body.html \
  --window win_12345678 --tab tab_12345678

The snapshot serializes document.body.outerHTML and materializes current input, textarea, checkbox, and select state. Password, hidden, and secret/token/session/cookie/credential-shaped fields are redacted. Email addresses in cloned DOM text and attributes become [redacted-email].

Filtered events and monitors

The Workbench normalizes actions, commands, navigation, requests, responses, scripts, console output, exceptions, WebSockets, downloads, targets, and postMessage into a monotonic timeline.

# Bounded point query; data, headers, and stacks are omitted by default.
npm run ctl -- events --since 240 --types request,exception \
  --match 'api|TypeError' --limit 25 --window win_12345678

# Causal window: start, act, then poll or stop by monitor ID.
npm run ctl -- monitor start --types request,response,script \
  --match 'checkout|graphql' --tab tab_12345678 --window win_12345678
npm run ctl -- click --ref el_3_8 --window win_12345678
npm run ctl -- monitor poll mon_1234567890 --limit 50 \
  --output ./events.json
npm run ctl -- monitor stop mon_1234567890

Filtering happens in the service. --types is comma-separated, --match is a regular expression, and --tab restricts the source. Structured filters include --url-match, --exclude-url, --page-match, --exclude-page, --host, and --exclude-host. Without --full, results contain metadata, summary, and source location only.

Specialized monitors keep low-noise cursors alive in the service:

npm run ctl -- monitor-requests start \
  --window win_12345678 --tab tab_12345678 \
  --host api.example.test --exclude-host ads.example.test

npm run ctl -- monitor-requests poll mon_1234567890 \
  --assets summary --telemetry summary \
  --output ./login-network.json --window win_12345678

# Persist bodies only for a selected, authorized business window.
npm run ctl -- monitor-requests poll mon_1234567890 \
  --assets full --include-bodies --include-sensitive \
  --max-body-bytes 1048576 \
  --output ./login-network-with-bodies.json --window win_12345678

npm run ctl -- monitor-javascript start \
  --window win_12345678 --tab tab_12345678
npm run ctl -- monitor-javascript poll mon_0987654321 \
  --scripts app --telemetry summary \
  --output ./route-javascript.json --window win_12345678

The monitor discovers the current page; the page is not a required input. start returns currentPage, network items retain pageUrl, and polls list observed pages. --page-match is an optional output filter.

monitor-requests pairs request and response by requestId and separates application requests, static assets, and telemetry. Assets and telemetry use summary by default; choose omit for silence or full when the raw inventory is required. Parameters are emitted as name, location, type, length, and correlation hash. Sensitive values are never hashed or returned.

Body capture is opt-in and requires --include-bodies, --include-sensitive, and --output. The default limit is 1 MiB and the maximum is 10 MiB per direction. Output files use mode 0600.

monitor-javascript groups four distinct evidence classes by observed pageUrl:

  • networkScriptRequests: HTTP downloads classified as JavaScript;
  • runtimeParsedDelta: new Debugger.scriptParsed events;
  • runtimeInventory: scripts currently known to the runtime;
  • pageInventory: a fresh document.scripts and Resource Timing view.

Telemetry scripts are separated by default. --scripts app summarizes Electron-internal scripts; use --scripts all only when those details matter.

Available event types include action, command, console, dialog, download, eventsource, exception, log, navigation, postmessage-send, postmessage-receive, request, response, script, websocket, and target.

HTTP request attribution

Request events preserve the Network.requestWillBeSent.initiator stack. When Chromium exposes a JavaScript stack, source identifies the file, line, column, function, and scriptId that initiated the request. CDP line and column values are zero-based.

postMessage attribution

Receive events are observed by a listener installed before application code. It records the receiving document, frame, origin, event.source relationship, payload type, and a bounded redacted copy.

npm run ctl -- monitor start \
  --types postmessage-send,postmessage-receive --full \
  --window win_12345678 --tab tab_12345678

When postmessage-send is requested, the Workbench installs a CDP breakpoint on the native function and immediately resumes after capturing its call stack. It does not replace window.postMessage, preserving event.source between frames. Local wrappers observe MessagePort, Worker, and BroadcastChannel.

Frames, targets, and screenshots

npm run ctl -- frames --window win_12345678 --tab tab_12345678
npm run ctl -- targets --window win_12345678 --tab tab_12345678
npm run ctl -- inspect --frame FRAME_ID --window win_12345678
npm run ctl -- screenshot --ref el_2_4 --output ./element.png \
  --window win_12345678
npm run ctl -- screenshot --full-page --output ./page.png \
  --window win_12345678

frames relates URL, parent, and execution context. targets enumerates pages, workers, and service workers. DOM and script instrumentation covers the page target and its frames; workers are enumerated but do not yet receive their own page commands.

Live DOM and loaded JavaScript

npm run ctl -- source --wait-ms 5000 --output ./page.html \
  --window win_12345678 --tab tab_12345678

npm run ctl -- scripts --wait-ms 2000 --external-only \
  --window win_12345678 --tab tab_12345678
npm run ctl -- scripts --wait-ms 0 --match '_next/static/chunks/' \
  --include-source --include-source-map --output ./next-scripts.json \
  --window win_12345678
npm run ctl -- scripts --script-id 706 --script-id 1229 \
  --include-source --output ./selected-scripts.json --window win_12345678

source serializes the live DOM after the requested wait, not the original HTML response. scripts is driven by Debugger.scriptParsed, so it includes cached, inline, and dynamically loaded chunks. Each item relates script, document, frame, execution context, hash, source map, size, module status, and language.

--external-only reduces noise. --match filters before retrieving bytes. Repeatable --script-id ID is an exact allowlist for the current tab. When it is combined with --match or --external-only, every filter must match; an unknown or stale ID fails the command instead of broadening the result. --include-source uses Debugger.getScriptSource. Source maps are fetched and decoded only when --include-source-map is also supplied; selecting IDs or requesting source does not imply that flag. Combine byte-heavy options with a narrow filter and --output.

A full navigation resets the inventory. SPA route changes retain old scripts; use observe to identify only the chunks added by one action.

Atomic observation and replay

npm run ctl -- observe click --ref el_2_4 --wait-ms 5000 \
  --execution-coverage --output ./observation.json \
  --window win_12345678 --tab tab_12345678
npm run ctl -- observe type '#search' 'security' --wait-ms 1000
npm run ctl -- observe eval 'refreshDashboard()' --wait-ms 3000
npm run ctl -- observe navigate https://example.com/account --wait-ms 5000

npm run ctl -- actions --limit 20 --window win_12345678
npm run ctl -- replay act_1234567890 \
  --window win_12345678 --tab tab_12345678

observe installs collectors before the action and returns exactly the new requests and scripts seen in its interval. --execution-coverage (alias --coverage) additionally brackets that one action with precise V8 coverage and embeds a bounded coverage artifact. Actions are stored for replay. Typed text and evaluated expressions are hidden from the default action listing.

Snapshots, diffs, and coverage

npm run ctl -- snapshot create --name before-save --window win_12345678
# Perform the action.
npm run ctl -- snapshot diff snap_1234567890 --output ./diff.json \
  --window win_12345678

npm run ctl -- coverage start --window win_12345678
# Exercise the flow.
npm run ctl -- coverage stop --output ./coverage.json \
  --window win_12345678

Snapshot diff compares URL, title, DOM hash, semantic elements, external scripts, and subsequent events. Coverage uses precise JavaScript coverage and CSS rule usage. Root-page coverage declares unattached workers/OOPIF targets as explicit gaps; not_observed_executed is never presented as “never executed.”

Bodies, storage, sessions, and HAR

Sensitive values and bodies are always opt-in:

npm run ctl -- body REQUEST_ID --include-sensitive --output ./body.json
npm run ctl -- storage --window win_12345678
npm run ctl -- storage --include-sensitive --window win_12345678

npm run ctl -- session checkpoint --include-sensitive \
  --output ./session.json --window win_12345678
npm run ctl -- session restore --input ./session.json \
  --window win_87654321

npm run ctl -- har --output ./trace.har --window win_12345678
npm run ctl -- har --output ./trace-sensitive.har --include-sensitive \
  --window win_12345678

storage masks values by default. A checkpoint contains profile cookies and the current origin's local and session storage. It is written with mode 0600 and must be treated as a credential. HAR output masks cookies, Authorization, and post data unless explicitly requested.

Explicit request interception

npm run ctl -- intercept set --match 'api.example.com/' \
  --method POST --set-header 'X-Audit-Trace: enabled' \
  --remove-header 'X-Legacy' --window win_12345678
npm run ctl -- intercept list --window win_12345678
npm run ctl -- intercept clear RULE_ID --window win_12345678
npm run ctl -- intercept clear --window win_12345678

Rules modify request headers during Fetch.requestPaused. Evidence produced under interception is marked modified and does not represent the target's natural response. Response mutation is not currently supported.

Persistent regex capture

npm run ctl -- rules set 'api/' 'graphql$' --window win_12345678
npm run ctl -- rules set --file ./capture-rules.txt --window win_12345678
npm run ctl -- capture on --window win_12345678
npm run ctl -- capture new --window win_12345678
npm run ctl -- requests --limit 20 --window win_12345678
npm run ctl -- export ./requests.json --window win_12345678

The JSONL record contains window, profile, tab, page, frame, URL, method, type, initiator, and matching rules. Prefer events or monitor for new automation because they support cursors, richer correlation, and more event types.

Security boundary

  • The audited page is top-level in a WebContentsView, not an iframe inside the shell. X-Frame-Options and CSP frame-ancestors are not removed.
  • Navigation is restricted to HTTP and HTTPS. External protocols are blocked.
  • Target pages receive no Node.js access. Sandbox, context isolation, and web security remain enabled.
  • Camera, microphone, location, and other permissions are denied.
  • HTTP and HTTPS popups become identified Workbench tabs.
  • eval, source retrieval, body capture, interception, and session restore are powerful capabilities and must remain inside the authorized scope.
  • Page instrumentation can be detected or altered by the target. Request stacks and the window.postMessage breakpoint come from CDP; received payloads and alternate channels depend on injected instrumentation.
  • Cross-origin or unattached targets may not expose an exact source file to the current CDP target. The Workbench preserves origin, frame, and relationship and leaves the source unknown instead of guessing.

Deep application context

The application-context skill builds a resumable evidence graph containing:

  • routes, UI states, and roles;
  • observed controls, business actions, and explicit prerequisites;
  • happy, error, and permission paths for business flows;
  • requests, responses, initiators, and normalized endpoints by action;
  • canonical scripts grouped by SHA-256, with functions, inputs, outputs, source locations, HTTP operations, and messaging operations;
  • public product, API, engineering, trust, and infrastructure documentation;
  • remaining frontier, exclusions, limitations, and confidence.
python3 skills/application-context/scripts/context.py example-scope \
  > /tmp/application-context-scope.json
python3 skills/application-context/scripts/context.py init \
  --scope /tmp/application-context-scope.json \
  --output ./application-context/target
python3 skills/application-context/scripts/capture_route.py \
  --context ./application-context/target \
  --url http://127.0.0.1:3000/ \
  --role anonymous --profile target-anonymous
python3 skills/application-context/scripts/context.py render \
  --context ./application-context/target

SQLite is canonical. Markdown, HTML, and indexes are generated projections. JavaScript-only endpoints remain REFERENCED_ONLY or HYPOTHESIS until runtime evidence or independent documentation corroborates them. Telemetry and static assets remain separate from business coverage. When exact source review refutes an extracted endpoint, import-model records it as disposition: FALSE_POSITIVE with a required reason and immutable model evidence. The candidate remains visible in projections but leaves the active unclassified-endpoint queue; confidence and disposition are never conflated. If runtime traffic later confirms that endpoint, the stronger observation reactivates it and clears the stale refutation; observed endpoints cannot be marked FALSE_POSITIVE by a later model import. The SPA report uses the equivalent apiInventory.endpoints[*].evidenceState value referenced_static. Keep unresolvedCallsites separate rather than inventing an endpoint during import, and use codexReviewQueue plus its local provenance to review request bodies and parameters before claiming semantic closure.

The application-context-query skill opens canonical SQLite in read-only mode and returns bounded context packs without loading the full report or evidence tree:

python3 skills/application-context-query/scripts/context_query.py overview \
  --context ./application-context/target
python3 skills/application-context-query/scripts/context_query.py search \
  --context ./application-context/target --query 'account verification'
python3 skills/application-context-query/scripts/context_query.py explain-request \
  --context ./application-context/target --request-id REQUEST_ID
python3 skills/application-context-query/scripts/context_query.py pack \
  --context ./application-context/target \
  --question 'How does verification establish an authenticated session?'

Telemetry and assets are hidden by default. Request bodies are returned as redacted schemas, and body previews require explicit selection.

Render the human-readable context report with:

node scripts/render-context-html.mjs \
  --context ./application-context/target \
  --output ./application-context/target/report.html

Development

npm run typecheck
npm test

Runtime captures, target reports, Chromium profiles, sessions, databases, logs, HAR files, screenshots, and temporary outputs are deliberately excluded from the repository. Versioned tests must use controlled fixtures only.

When runtime or CLI behavior changes, update this README and the affected skill or reference in the same commit. Validate Electron and CDP changes against a controlled fixture.

About

Electron and CDP workbench for LLM-assisted, authorized web security testing

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages