Skip to content
Merged
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
45 changes: 45 additions & 0 deletions docs/adr/0106-full-cdp-debug-channel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# ADR 0106: Full CDP Debug Channel

Status: accepted

Date: 2026-08-16

## Context

Safe Web and Full CDP are two independent browser permission chains. Safe Web
is the restricted, network-contained operator entry (ADR 0105); Full CDP is the
highly-sensitive debug channel that can observe, capture, mutate, and replay
requests and access cookies. Full CDP must never inherit Safe Web's evidence or
authorization, must never disable browser security, and must be bound to a
short-lived, per-call capability.

## Decision

`FullCDPAuthorization` (`browser_full_cdp_authorization.v1`) is independent from
the Safe Web start authorization. It is issued only when the Run uses the
maximum-access debug permission (with operator confirmation), the process
enabled the full-debug gate and the restricted-CDP runtime, and the caller
supplies an exact per-call confirmation. It binds the Run, Workspace, accepted
executable identity, permission revision, and session scope, and expires after a
five-minute TTL. It authorizes request capture, mutation, replay, cookie access,
and arbitrary methods, but never webpage-instruction elevation
(`InstructionAuthorized` is always false) and never origin-policy or certificate
relaxation.

The `FullCDPSession` reuses the restricted CDP WebSocket client but admits an
additional highly-sensitive method set (`Network.getCookies`,
`Network.getAllCookies`, `Storage.getCookies`, `Fetch.fulfillRequest`,
`Runtime.*`, `Log.enable`) only when the client carries the Full CDP flag. Every
result is metadata-only: raw request bodies, headers, cookie values, and page
content are never returned.

## Consequences

Full CDP and Safe Web are provably non-crossing: Safe Web evidence or the
restricted permission cannot authorize Full CDP, and the maximum-access debug
permission cannot enter the restricted runtime. The debug capability is
TTL-bounded, per-call confirmed, and invalid after restart. The metadata-only
result contract removes secret/cookie/header/body leakage by construction. The
dedicated, one-shot browser process and Profile remain shared with the Safe Web
launch machinery, while the Full CDP authorization stays a separate, bounded
gate.
115 changes: 115 additions & 0 deletions internal/application/full_cdp_service.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
package application

import (
"context"
"errors"
"strings"
"time"

"cyberagent-workbench/internal/browserruntime"
"cyberagent-workbench/internal/domain"
)

// FullCDPStore supplies the durable Run, Mission, and browser CDP permission
// facts the Full CDP operator flow consumes.
type FullCDPStore interface {
GetRun(ctx context.Context, id string) (domain.Run, error)
GetMission(ctx context.Context, id string) (domain.Mission, error)
GetRunBrowserCDPPermission(ctx context.Context,
runID string) (domain.RunBrowserCDPPermissionSnapshot, error)
}

// FullCDPService opens the highly-sensitive Full CDP debug channel. It is
// independent from Safe Web: it requires the maximum-access debug permission,
// an exact per-call confirmation, and a dedicated, contained browser process,
// and it never inherits Safe Web evidence or authorization.
type FullCDPService struct {
store FullCDPStore
runtimeCapabilities browserruntime.ProductionRuntimeCapabilities
permissionCapabilities domain.BrowserCDPPermissionRuntimeCapabilities
}

func NewFullCDPService(store FullCDPStore,
runtimeCapabilities browserruntime.ProductionRuntimeCapabilities,
permissionCapabilities domain.BrowserCDPPermissionRuntimeCapabilities,
) *FullCDPService {
return &FullCDPService{store: store, runtimeCapabilities: runtimeCapabilities,
permissionCapabilities: permissionCapabilities}
}

// FullCDPOpenRequest is a bounded request to open the Full CDP debug session
// over an already launched, contained browser process.
type FullCDPOpenRequest struct {
RunID string
Target string
Identity browserruntime.BrowserExecutableIdentity
Acceptance browserruntime.BrowserAcceptanceCandidate
Ownership browserruntime.ProfileOwnershipPlan
Confirmed bool
ProfileLease browserruntime.ProfileRuntimeLease
Process *browserruntime.BrowserProcess
}

// Open authorizes and dials the Full CDP session. It fails closed unless the
// Run uses the maximum-access debug permission, the caller supplies an exact
// per-call confirmation, and the dedicated browser process is live.
func (s *FullCDPService) Open(ctx context.Context,
request FullCDPOpenRequest,
) (*browserruntime.FullCDPSession, error) {
if s == nil || s.store == nil {
return nil, errors.New("full CDP service is not fully configured")
}
if err := s.runtimeCapabilities.Validate(); err != nil {
return nil, err
}
if err := s.permissionCapabilities.Validate(); err != nil {
return nil, err
}
if err := browserruntime.ValidateBrowserExecutableIdentity(request.Identity); err != nil {
return nil, err
}
if err := browserruntime.ValidateBrowserAcceptanceCandidate(
request.Acceptance, request.Identity); err != nil {
return nil, err
}
request.RunID = strings.TrimSpace(request.RunID)
if request.RunID == "" || !domain.ValidAgentID(request.RunID) {
return nil, errors.New("full CDP Run id is invalid")
}
run, err := s.store.GetRun(ctx, request.RunID)
if err != nil {
return nil, err
}
if !domain.CanChangeRunBrowserCDPPermission(run.Status) {
return nil, errors.New("full CDP requires a non-terminal Run")
}
mission, err := s.store.GetMission(ctx, run.MissionID)
if err != nil {
return nil, err
}
permission, err := s.store.GetRunBrowserCDPPermission(ctx, request.RunID)
if err != nil {
return nil, err
}
now := time.Now().UTC()
session, err := browserruntime.BuildSessionPlan(browserruntime.NewSessionPlanRequest{
SessionID: run.SessionID, RunID: run.ID, WorkspaceID: mission.WorkspaceID,
ProfileID: browserruntime.ProfileCTFLab, Targets: []string{request.Target},
Features: browserruntime.FeatureRequest{
InterceptRequests: true, ModifyRequests: true,
ReplayRequests: true, EditCookies: true,
},
})
if err != nil {
return nil, err
}
authorization, err := browserruntime.AuthorizeFullCDP(session, request.Identity,
request.Acceptance, permission, s.runtimeCapabilities,
s.permissionCapabilities, request.Confirmed, now)
if err != nil {
return nil, err
}
return browserruntime.OpenFullCDPSession(ctx, authorization, session,
request.Identity, request.Acceptance, request.Ownership,
permission, request.ProfileLease, request.Process)
}
143 changes: 143 additions & 0 deletions internal/application/full_cdp_service_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
package application

import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"path/filepath"
"runtime"
"strings"
"testing"
"time"

"cyberagent-workbench/internal/browserruntime"
"cyberagent-workbench/internal/domain"
)

type fakeFullCDPStore struct {
run domain.Run
mission domain.Mission
permission domain.RunBrowserCDPPermissionSnapshot
}

func (f *fakeFullCDPStore) GetRun(context.Context, string) (domain.Run, error) {
return f.run, nil
}

func (f *fakeFullCDPStore) GetMission(context.Context, string) (domain.Mission, error) {
return f.mission, nil
}

func (f *fakeFullCDPStore) GetRunBrowserCDPPermission(context.Context,
string,
) (domain.RunBrowserCDPPermissionSnapshot, error) {
return f.permission, nil
}

func fullCDPIdentityAcceptance(t *testing.T) (browserruntime.BrowserExecutableIdentity,
browserruntime.BrowserAcceptanceCandidate,
) {
t.Helper()
relative := filepath.ToSlash(filepath.Join("Google", "Chrome", "Application", "chrome.exe"))
identity := browserruntime.BrowserExecutableIdentity{
ProtocolVersion: browserruntime.BrowserExecutableIdentityProtocolVersion,
Product: browserruntime.BrowserProductChrome, Channel: browserruntime.BrowserChannelStable,
Vendor: "Google", RootID: browserruntime.DiscoveryRootProgramFiles,
CanonicalPath: filepath.Join(t.TempDir(), filepath.FromSlash(relative)),
RelativePath: relative, HostGOOS: runtime.GOOS, HostGOARCH: runtime.GOARCH,
TargetGOARCH: "amd64", ExecutableBytes: 1024,
ExecutableSHA256: strings.Repeat("a", 64),
VersionSource: browserruntime.VersionSourceUnavailable,
PEFormatVerified: true, RegularFileVerified: true, SymlinkRejected: true,
MetadataOnly: true,
}
identity.Fingerprint = fullCDPFingerprint(t, identity)
if err := browserruntime.ValidateBrowserExecutableIdentity(identity); err != nil {
t.Fatal(err)
}
acceptance := browserruntime.BrowserAcceptanceCandidate{
ProtocolVersion: browserruntime.BrowserAcceptanceProtocolVersion,
ExecutableIdentityFingerprint: identity.Fingerprint,
Product: identity.Product, Channel: identity.Channel, RootID: identity.RootID,
ExecutableSHA256: identity.ExecutableSHA256, ExecutableBytes: identity.ExecutableBytes,
TargetGOARCH: identity.TargetGOARCH,
Decision: browserruntime.BrowserAcceptanceAccepted,
ReasonCode: browserruntime.BrowserAcceptanceReasonPublisherVerified,
Evidence: browserruntime.AuthenticodeEvidence{
Source: browserruntime.AuthenticodeSourceWindows, Publisher: "Google LLC",
CertificateSHA256: strings.Repeat("b", 64), SignatureVerified: true,
SameOpenHandleVerified: true, CacheOnlyVerification: true,
PublisherPolicyMatched: true,
PublisherPolicyVersion: browserruntime.BrowserPublisherPolicyVersion,
PublisherEvidenceComplete: true,
},
SameHandleBytesRevalidated: true, SameFilePathRevalidated: true,
PERevalidated: true, ReviewEligible: true, StartBlocked: true, MetadataOnly: true,
}
acceptance.Fingerprint = fullCDPFingerprint(t, acceptance)
if err := browserruntime.ValidateBrowserAcceptanceCandidate(acceptance, identity); err != nil {
t.Fatal(err)
}
return identity, acceptance
}

func fullCDPFingerprint(t *testing.T, value any) string {
t.Helper()
switch typed := value.(type) {
case browserruntime.BrowserExecutableIdentity:
typed.Fingerprint = ""
value = typed
case browserruntime.BrowserAcceptanceCandidate:
typed.Fingerprint = ""
value = typed
default:
t.Fatalf("unsupported full CDP fixture fingerprint type %T", value)
}
raw, err := json.Marshal(value)
if err != nil {
t.Fatal(err)
}
digest := sha256.Sum256(raw)
return hex.EncodeToString(digest[:])
}

func TestFullCDPServiceRefusesWithoutConfirmation(t *testing.T) {
now := time.Now().UTC().Round(time.Millisecond)
identity, acceptance := fullCDPIdentityAcceptance(t)
permission, err := domain.NewInitialRunBrowserCDPPermissionSnapshot(
"browser-cdp-full-service", domain.Run{ID: "run-full-service",
MissionID: "mission-full-service", Status: domain.RunCreated, CreatedAt: now},
domain.Mission{ID: "mission-full-service", CreatedAt: now},
"runtime-operator", now)
if err != nil {
t.Fatal(err)
}
full, err := permission.Next("browser-cdp-full-debug",
domain.RunBrowserCDPPermissionFullDebug, true, "runtime-operator",
"full debug", now)
if err != nil {
t.Fatal(err)
}
store := &fakeFullCDPStore{
run: domain.Run{ID: "run-full-service", MissionID: "mission-full-service",
SessionID: "session-full-service", Status: domain.RunCreated, CreatedAt: now},
mission: domain.Mission{ID: "mission-full-service",
WorkspaceID: "workspace-full-service", CreatedAt: now},
permission: full,
}
service := NewFullCDPService(store,
browserruntime.ProductionRuntimeCapabilities{
SafeWebStartEnabled: true, DisposableProfileEnabled: true,
NetworkContainmentEnabled: true, RestrictedCDPEnabled: true,
},
domain.BrowserCDPPermissionRuntimeCapabilities{ControlEnabled: true,
FullDebugEnabled: true})
_, err = service.Open(t.Context(), FullCDPOpenRequest{
RunID: "run-full-service", Target: "http://127.0.0.1:18080",
Identity: identity, Acceptance: acceptance,
})
if err == nil {
t.Fatal("unconfirmed full CDP unexpectedly opened a session")
}
}
Loading