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.
- 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.
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 stopThe 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.
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 asel_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.
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.scriptParsedevents 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_12345678Run 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.
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_12345678The 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.
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_12345678email 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.
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 1Add --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.
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-reviewThe 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 memberapiInventory.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.
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.jsonBefore 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.
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.jsonThe 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.jsonFor 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.jsonMission 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.cjsThis 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.
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_12345678inspect 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.
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_12345678query 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.
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_12345678The 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].
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_1234567890Filtering 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_12345678The 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: newDebugger.scriptParsedevents;runtimeInventory: scripts currently known to the runtime;pageInventory: a freshdocument.scriptsand 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.
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.
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_12345678When 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.
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_12345678frames 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.
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_12345678source 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.
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_12345678observe 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.
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_12345678Snapshot 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.”
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_12345678storage 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.
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_12345678Rules 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.
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_12345678The 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.
- The audited page is top-level in a
WebContentsView, not an iframe inside the shell.X-Frame-OptionsandCSP frame-ancestorsare 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.postMessagebreakpoint 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.
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/targetSQLite 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.htmlnpm run typecheck
npm testRuntime 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.