diff --git a/packages/cyberstrike/src/server/routes/session.ts b/packages/cyberstrike/src/server/routes/session.ts index f1e03e750d..b9d6eba320 100644 --- a/packages/cyberstrike/src/server/routes/session.ts +++ b/packages/cyberstrike/src/server/routes/session.ts @@ -123,12 +123,14 @@ export interface AccessContextInput { // so the orchestrator and subagents see the same structured signals. export function renderAccessContextLines(accessContext: AccessContextInput): string[] { const ac = accessContext - const hasData = ac.triggerElement || ac.pageUrl || ac.uiContext + const hasData = ac.triggerElement || ac.pageUrl || ac.uiContext || ac.pageVisitedBy?.length if (!hasData) return [] const lines: string[] = ["", "## Access Context"] if (ac.pageUrl) { const visitedBy = ac.pageVisitedBy?.length ? ` (visited by: ${ac.pageVisitedBy.join(", ")})` : "" lines.push(`Page: ${ac.pageUrl}${visitedBy}`) + } else if (ac.pageVisitedBy?.length) { + lines.push(`Visited by: ${ac.pageVisitedBy.join(", ")}`) } if (ac.triggerElement) { const visibleTo = ac.elementRoles?.length ? ` (visible to: ${ac.elementRoles.join(", ")})` : "" @@ -168,6 +170,32 @@ export function renderAccessContextLines(accessContext: AccessContextInput): str return lines } +// Derive visited_by from observed credential attribution (request_observation), merging +// with any capture-time label. This is the Firefox fix: Firefox traffic has no +// page_visited_by at capture time, but request_observation carries per-credential +// reachability for both paths. Merged, distinct, label-resolved. +export function getVisitedByForKeyHash( + sessionID: string, + keyHash: string, + captureVisitedBy?: string[], +): string[] { + const obs = Observation.listByKeyHash(sessionID, keyHash) + const ids = [...new Set(obs.map((o) => o.credential_id).filter((id): id is string => !!id))] + const labels = ids.map((id) => WebCredential.getById(id)?.label ?? id) + const merged = new Set([...(captureVisitedBy ?? []), ...labels]) + return [...merged] +} + +export function resolveVisitedBy(input: { + sessionID: string + keyHash?: string + captureVisitedBy?: string[] +}): string[] | undefined { + if (!input.keyHash) return input.captureVisitedBy + const derived = getVisitedByForKeyHash(input.sessionID, input.keyHash, input.captureVisitedBy) + return derived.length ? derived : input.captureVisitedBy +} + // Renders the `## Observed Values` block: the concrete input values each credential was // observed using on THIS endpoint shape (deterministic, redaction-aware). RAW FACTS ONLY — // no interpretation. What the values mean (access-control/IDOR/etc.) is the orchestrator's @@ -1247,6 +1275,11 @@ export const SessionRoutes = lazy(() => // orchestrator skips re-dispatching deployment-wide testers (JWT/TLS/headers). // Rendered at dequeue, so it reflects coverage that accrued while queued. const coverage = CoverageNote.wideBlock(sessionID, normalized.origin) + const visitedBy = resolveVisitedBy({ + sessionID, + keyHash: normalized.keyHash, + captureVisitedBy: body.page_visited_by, + }) const base = buildPromptWithCredentialContext( truncatedRawRequest, credentialID, @@ -1255,7 +1288,7 @@ export const SessionRoutes = lazy(() => triggerElement: body.trigger_element, elementRoles: body.element_roles, pageUrl: body.page_url, - pageVisitedBy: body.page_visited_by, + pageVisitedBy: visitedBy, uiContext: body.ui_context as Record | undefined, }, normalized.protocol && normalized.operation diff --git a/packages/cyberstrike/src/tool/task.ts b/packages/cyberstrike/src/tool/task.ts index 931857c858..7c5dfdd62e 100644 --- a/packages/cyberstrike/src/tool/task.ts +++ b/packages/cyberstrike/src/tool/task.ts @@ -13,7 +13,8 @@ import { PermissionNext } from "@/permission/next" import { Request } from "../session/request" import { CoverageNote } from "../session/coverage-note" import { WebCredential } from "../session/web/web-credential" -import { renderAccessContextLines } from "../server/routes/session" +import { Observation } from "../session/observation" +import { renderAccessContextLines, resolveVisitedBy } from "../server/routes/session" import { Truncate } from "./truncation" import { dispatchScopeViolation, dispatchOffLaneMessage } from "./vuln-scope" @@ -219,15 +220,19 @@ export const TaskTool = Tool.define("task", async (ctx) => { lines.push("UNAUTHENTICATED (no credential associated with this request)") } - // Access Context — present only when source is hackbrowser with - // UI crawling enrichment. Firefox extension data has all these - // fields null, so renderAccessContextLines returns []. + // Access Context — derive visited_by from observed values so Firefox + // traffic gains the legitimacy signal and hackbrowser gains full multi-cred set. + const visitedBy = resolveVisitedBy({ + sessionID: Session.root(ctx.sessionID), + keyHash: current.key_hash, + captureVisitedBy: current.page_visited_by, + }) lines.push( ...renderAccessContextLines({ triggerElement: current.trigger_element, elementRoles: current.element_roles, pageUrl: current.page_url, - pageVisitedBy: current.page_visited_by, + pageVisitedBy: visitedBy, uiContext: current.ui_context, }), ) diff --git a/packages/cyberstrike/src/tool/web-get-request-detail.ts b/packages/cyberstrike/src/tool/web-get-request-detail.ts index 3ddcf1faff..538796201e 100644 --- a/packages/cyberstrike/src/tool/web-get-request-detail.ts +++ b/packages/cyberstrike/src/tool/web-get-request-detail.ts @@ -3,6 +3,7 @@ import { Tool } from "./tool" import { Request } from "../session/request" import { Observation } from "../session/observation" import { Session } from "../session" +import { resolveVisitedBy } from "../server/routes/session" const description = `Get detailed information for a specific HTTP request by ID. @@ -54,10 +55,18 @@ export const WebGetRequestDetailTool = Tool.define("web_get_request_detail", { } // Access context — always include if available (small, useful for all agents) + // Derive visited_by from observed values so Firefox traffic gains legitimacy signal. + const visitedBy = request.key_hash + ? resolveVisitedBy({ + sessionID, + keyHash: request.key_hash, + captureVisitedBy: request.page_visited_by, + }) + : request.page_visited_by if (request.trigger_element) detail.trigger_element = request.trigger_element if (request.element_roles) detail.element_roles = request.element_roles if (request.page_url) detail.page_url = request.page_url - if (request.page_visited_by) detail.page_visited_by = request.page_visited_by + if (visitedBy?.length) detail.page_visited_by = visitedBy // Protocol/operation — present only for body-dispatched endpoints (GraphQL/JSON-RPC). if (request.protocol) detail.protocol = request.protocol