Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 35 additions & 2 deletions packages/cyberstrike/src/server/routes/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(", ")})` : ""
Expand Down Expand Up @@ -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<string>([...(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
Expand Down Expand Up @@ -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,
Expand All @@ -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<string, unknown> | undefined,
},
normalized.protocol && normalized.operation
Expand Down
15 changes: 10 additions & 5 deletions packages/cyberstrike/src/tool/task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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,
}),
)
Expand Down
11 changes: 10 additions & 1 deletion packages/cyberstrike/src/tool/web-get-request-detail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand Down
Loading