diff --git a/README.md b/README.md index 6067d90d..419c757e 100644 --- a/README.md +++ b/README.md @@ -320,6 +320,7 @@ open build/desktop/Prayu.app | v100 | 算子价格快照与 Run 金额预算账本(预留/结算/释放) | operator price snapshots and the run monetary budget ledger (reserve/settle/release) | | v101 | 结构化 Agent 依赖等待与唯一唤醒收据 | structured agent dependency waiting and unique wake receipts | | v102 | 模型提议的有界 child 任务调度(core/readonly fan-out 分面、去重与准入) | model-proposed bounded child task scheduling (core/read-only fan-out surfaces, dedup, and admission) | +| v103 | 持久浏览器网络隔离证据与操作者 review | durable browser network containment evidence and operator review | diff --git a/docs/adr/0105-safe-web-readiness-receipt.md b/docs/adr/0105-safe-web-readiness-receipt.md new file mode 100644 index 00000000..3a094f38 --- /dev/null +++ b/docs/adr/0105-safe-web-readiness-receipt.md @@ -0,0 +1,37 @@ +# ADR 0105: Safe Web Readiness Receipt + +Status: accepted + +Date: 2026-08-16 + +## Context + +The restricted Safe Web operator entry must stay disabled unless the Windows WFP +production evidence for the exact accepted browser is still valid. The evidence, +its review, and the launch plan are already immutable and fingerprint-bound, but +nothing yet collapses "is Safe Web ready for this browser right now?" into one +bounded, explainable, fail-closed judgment that the CLI, HTTP, and Desktop entry +can share. + +## Decision + +`BrowserSafeWebReadiness` records a short-lived, fail-closed judgment over one +`BrowserNetworkContainmentEvidence` + `BrowserNetworkContainmentReview` pair. It +binds the evidence, review, executable identity, acceptance, adapter, policy +version, and platform, and carries a single precise blocking reason whenever it +is not ready. A missing, expired, version-mismatched, hash-mismatched, +policy-mismatched, or rejected pair yields a `Ready=false` receipt rather than a +hard error, so the Desktop entry can display the exact blocking cause. The +readiness never authorizes a launch by itself; it only feeds the existing +`AuthorizeSafeWebStart` process-local gate. + +## Consequences + +The Safe Web entry now has one deterministic readiness predicate with a stable +blocking-reason taxonomy (`evidence_missing`, `evidence_version_mismatch`, +`executable_identity_mismatch`, `acceptance_mismatch`, `policy_version_mismatch`, +`adapter_mismatch`, `platform_mismatch`, `evidence_not_passed`, +`review_missing`, `review_binding_mismatch`, `review_not_accepted`, +`evidence_expired`). Operator-flow wiring (target selection, scope preview, +approval, and CLI/HTTP/Desktop surfaces) consumes this receipt; the receipt +itself changes no launch authority and keeps the product entry fail-closed. diff --git a/docs/openapi.json b/docs/openapi.json index 66101119..7cc4e0b0 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -211,6 +211,82 @@ "x-cyberagent-read-only": true } }, + "/api/v1/browser/safe-web-readiness": { + "get": { + "operationId": "getBrowserSafeWebReadiness", + "summary": "Inspect Safe Web readiness", + "description": "Returns a fail-closed readiness receipt over the latest production containment evidence and operator review for one accepted browser. It never starts a browser and never grants authority.", + "tags": [ + "Browser" + ], + "parameters": [ + { + "name": "product", + "in": "query", + "description": "Accepted browser product", + "schema": { + "enum": [ + "chrome", + "edge", + "chromium" + ], + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful read", + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "data": { + "$ref": "#/components/schemas/BrowserSafeWebReadiness" + }, + "request_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "version": { + "const": "api.v1", + "type": "string" + } + }, + "required": [ + "version", + "request_id", + "data" + ], + "type": "object" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "414": { + "$ref": "#/components/responses/RequestTooLarge" + }, + "429": { + "$ref": "#/components/responses/ResourceExhausted" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + }, + "x-cyberagent-read-only": true + } + }, "/api/v1/capabilities": { "get": { "operationId": "getRuntimeCapabilities", @@ -11495,6 +11571,102 @@ ], "type": "object" }, + "BrowserSafeWebReadiness": { + "additionalProperties": false, + "properties": { + "acceptance_fingerprint": { + "type": "string" + }, + "adapter": { + "enum": [ + "windows_wfp_dynamic.v1" + ], + "type": "string" + }, + "architecture": { + "type": "string" + }, + "blocking_reason": { + "enum": [ + "evidence_missing", + "evidence_version_mismatch", + "executable_identity_mismatch", + "acceptance_mismatch", + "policy_version_mismatch", + "adapter_mismatch", + "platform_mismatch", + "evidence_not_passed", + "review_missing", + "review_binding_mismatch", + "review_not_accepted", + "evidence_expired" + ], + "type": "string" + }, + "collector_identity": { + "type": "string" + }, + "evidence_fingerprint": { + "type": "string" + }, + "executable_identity_fingerprint": { + "type": "string" + }, + "expires_at": { + "format": "date-time", + "type": "string" + }, + "fingerprint": { + "type": "string" + }, + "issued_at": { + "format": "date-time", + "type": "string" + }, + "operating_system": { + "type": "string" + }, + "policy_version": { + "enum": [ + "browser_network_containment_policy.v2" + ], + "type": "string" + }, + "protocol_version": { + "enum": [ + "browser_safe_web_readiness.v1" + ], + "type": "string" + }, + "ready": { + "type": "boolean" + }, + "review_fingerprint": { + "type": "string" + }, + "reviewer_identity": { + "type": "string" + } + }, + "required": [ + "protocol_version", + "evidence_fingerprint", + "review_fingerprint", + "executable_identity_fingerprint", + "acceptance_fingerprint", + "adapter", + "policy_version", + "operating_system", + "architecture", + "collector_identity", + "reviewer_identity", + "ready", + "issued_at", + "expires_at", + "fingerprint" + ], + "type": "object" + }, "BudgetView": { "additionalProperties": false, "properties": { @@ -19553,7 +19725,8 @@ "approval", "file_change", "plan", - "dependency" + "dependency", + "browser" ], "type": "string" }, diff --git a/internal/app/app.go b/internal/app/app.go index cee09cfa..504a815b 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -242,6 +242,7 @@ func (a *App) printHelp() { fmt.Fprintln(a.out, "Usage:") fmt.Fprintln(a.out, " cyberagent version") fmt.Fprintln(a.out, " cyberagent doctor portable [--json]") + fmt.Fprintln(a.out, " cyberagent doctor browser-readiness --product [--json]") fmt.Fprintln(a.out, " cyberagent workspace init|list|show|tree|read") fmt.Fprintln(a.out, " cyberagent script new|run") fmt.Fprintln(a.out, " cyberagent ctf init|analyze|writeup") diff --git a/internal/app/doctor_command.go b/internal/app/doctor_command.go index c7d58506..6e7a1383 100644 --- a/internal/app/doctor_command.go +++ b/internal/app/doctor_command.go @@ -8,21 +8,26 @@ import ( "strings" "time" + "cyberagent-workbench/internal/application" "cyberagent-workbench/internal/browserruntime" "cyberagent-workbench/internal/buildinfo" ) func (a *App) doctorCommand(ctx context.Context, args []string) error { if len(args) == 0 { - return errors.New("usage: cyberagent doctor ") + return errors.New("usage: cyberagent doctor ") } switch args[0] { case "portable": return a.doctorPortableCommand(args[1:]) case "browser-network-probe": return a.doctorBrowserNetworkProbeCommand(ctx, args[1:]) + case "browser-network-review": + return a.doctorBrowserNetworkReviewCommand(ctx, args[1:]) + case "browser-readiness": + return a.doctorBrowserReadinessCommand(ctx, args[1:]) default: - return errors.New("usage: cyberagent doctor ") + return errors.New("usage: cyberagent doctor ") } } @@ -90,6 +95,12 @@ func (a *App) doctorBrowserNetworkProbeCommand(ctx context.Context, if err != nil { return err } + if err := a.ensureStore(); err != nil { + return err + } + if _, err := a.store.RecordBrowserNetworkEvidence(ctx, evidence, evidence.ID); err != nil { + return err + } if *jsonOutput { encoder := json.NewEncoder(a.out) encoder.SetIndent("", " ") @@ -114,6 +125,93 @@ func (a *App) doctorBrowserNetworkProbeCommand(ctx context.Context, return nil } +func (a *App) doctorBrowserNetworkReviewCommand(ctx context.Context, args []string) error { + flags := newFlagSet("doctor browser-network-review", a.errOut) + product := flags.String("product", string(browserruntime.BrowserProductEdge), + "accepted browser product: edge, chrome, or chromium") + reviewer := flags.String("reviewer", "", "independent reviewer identity") + operationKey := flags.String("operation-key", "", "stable review operation key") + accept := flags.Bool("accept", false, "accept the latest production evidence") + reject := flags.Bool("reject", false, "reject the latest production evidence") + if err := flags.Parse(args); err != nil { + return err + } + if flags.NArg() != 0 || *accept == *reject || + strings.TrimSpace(*reviewer) == "" || strings.TrimSpace(*operationKey) == "" { + return errors.New("usage: cyberagent doctor browser-network-review --product --reviewer --operation-key (--accept|--reject)") + } + selectedProduct, err := parseBrowserNetworkProbeProduct(*product) + if err != nil { + return err + } + identity, acceptance, err := acceptedBrowserNetworkProbeCandidate(selectedProduct) + if err != nil { + return err + } + if err := a.ensureStore(); err != nil { + return err + } + evidence, err := a.store.LoadLatestBrowserNetworkEvidence(ctx, identity.Fingerprint) + if err != nil { + return err + } + if !evidence.Passed { + return fmt.Errorf("latest browser network evidence did not pass: %s", evidence.FailureCode) + } + now := time.Now().UTC() + review, err := browserruntime.BuildBrowserNetworkContainmentReview(evidence, identity, + acceptance, "browser-network-review-"+fmt.Sprintf("%d", now.UnixNano()), + strings.TrimSpace(*reviewer), *accept, now) + if err != nil { + return err + } + record, err := a.store.RecordBrowserNetworkReview(ctx, review, *operationKey) + if err != nil { + return err + } + fmt.Fprintf(a.out, "evidence: %s\naccepted: %t\nreason: %s\nreplayed: %t\nreview_fingerprint: %s\n", + evidence.ID, review.Accepted, review.ReasonCode, record.Replayed, review.Fingerprint) + return nil +} + +func (a *App) doctorBrowserReadinessCommand(ctx context.Context, args []string) error { + flags := newFlagSet("doctor browser-readiness", a.errOut) + product := flags.String("product", string(browserruntime.BrowserProductEdge), + "accepted browser product: edge, chrome, or chromium") + jsonOutput := flags.Bool("json", false, "print the readiness receipt as JSON") + if err := flags.Parse(args); err != nil { + return err + } + if flags.NArg() != 0 { + return errors.New("usage: cyberagent doctor browser-readiness --product [--json]") + } + selectedProduct, err := parseBrowserNetworkProbeProduct(*product) + if err != nil { + return err + } + identity, acceptance, err := acceptedBrowserNetworkProbeCandidate(selectedProduct) + if err != nil { + return err + } + if err := a.ensureStore(); err != nil { + return err + } + service := application.NewBrowserSafeWebRuntimeService(a.store) + readiness, err := service.Readiness(ctx, identity, acceptance) + if err != nil { + return err + } + if *jsonOutput { + encoder := json.NewEncoder(a.out) + encoder.SetIndent("", " ") + return encoder.Encode(readiness) + } + fmt.Fprintf(a.out, "protocol: %s\nready: %t\nblocking_reason: %s\nadapter: %s\npolicy: %s\nexpires_at: %s\n", + readiness.ProtocolVersion, readiness.Ready, valueOrUnknown(readiness.BlockingReason), + readiness.Adapter, readiness.PolicyVersion, readiness.ExpiresAt.Format(time.RFC3339)) + return nil +} + func parseBrowserNetworkProbeProduct(raw string) (browserruntime.BrowserProduct, error) { product := browserruntime.BrowserProduct(strings.ToLower(strings.TrimSpace(raw))) switch product { diff --git a/internal/app/doctor_command_test.go b/internal/app/doctor_command_test.go index 69ce3562..1ac388cb 100644 --- a/internal/app/doctor_command_test.go +++ b/internal/app/doctor_command_test.go @@ -58,3 +58,13 @@ func TestDoctorBrowserNetworkProbeFailsBeforeDiscoveryWithoutExactConfirmation(t t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String()) } } + +func TestDoctorBrowserReadinessRejectsUnknownProduct(t *testing.T) { + var stdout, stderr bytes.Buffer + code := Execute([]string{"doctor", "browser-readiness", "--product", "firefox"}, + &stdout, &stderr) + if code == 0 || stdout.Len() != 0 || + !strings.Contains(stderr.String(), "edge, chrome, or chromium") { + t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String()) + } +} diff --git a/internal/application/browser_runtime.go b/internal/application/browser_runtime.go new file mode 100644 index 00000000..4a5d089e --- /dev/null +++ b/internal/application/browser_runtime.go @@ -0,0 +1,224 @@ +package application + +import ( + "context" + "errors" + "strings" + "time" + + "cyberagent-workbench/internal/browserruntime" + "cyberagent-workbench/internal/domain" + "cyberagent-workbench/internal/idgen" +) + +// BrowserRuntimeStore supplies the durable browser launch preparation, review, +// readiness facts, and runtime lifecycle the operator flow consumes. +type BrowserRuntimeStore 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) + LoadLatestBrowserNetworkEvidence(ctx context.Context, + executableIdentityFingerprint string) (browserruntime.BrowserNetworkContainmentEvidence, error) + LoadBrowserNetworkReview(ctx context.Context, + evidenceFingerprint string) (browserruntime.BrowserNetworkContainmentReview, error) + PrepareBrowserLaunch(ctx context.Context, session browserruntime.SessionPlan, + identity browserruntime.BrowserExecutableIdentity, + acceptance browserruntime.BrowserAcceptanceCandidate, + ownership browserruntime.ProfileOwnershipPlan, operationKey string, + leaseOwnerIdentity string) (browserruntime.BrowserLaunchAttempt, + browserruntime.BrowserLaunchLease, bool, error) + RecordBrowserLaunchReview(ctx context.Context, session browserruntime.SessionPlan, + identity browserruntime.BrowserExecutableIdentity, + acceptance browserruntime.BrowserAcceptanceCandidate, + ownership browserruntime.ProfileOwnershipPlan, + attempt browserruntime.BrowserLaunchAttempt, lease browserruntime.BrowserLaunchLease, + decision browserruntime.BrowserLaunchReviewDecision, operationKey string, + reviewerIdentity string) (browserruntime.BrowserLaunchReview, bool, error) + RecordBrowserRuntimeCheckpoint(context.Context, browserruntime.BrowserRuntimeCheckpoint) error + RecordBrowserRuntimeReceipt(context.Context, browserruntime.BrowserRuntimeReceipt) error +} + +// BrowserRuntimeLaunchRequest is a bounded operator request to start one +// contained Safe Web browser session against an exact loopback target. +type BrowserRuntimeLaunchRequest struct { + RunID string + Target string + Identity browserruntime.BrowserExecutableIdentity + Acceptance browserruntime.BrowserAcceptanceCandidate + ProfileRoot string + OperationKey string + LeaseOwnerIdentity string + ReviewerIdentity string + ReviewOperationKey string +} + +// BrowserRuntimeHandle is the in-memory handle for one running contained +// browser session. Its coordinator owns the bounded close/cleanup flow. +type BrowserRuntimeHandle struct { + RuntimeID string + Coordinator *browserruntime.BrowserRuntimeLifecycleCoordinator +} + +// BrowserRuntimeService orchestrates the Safe Web operator flow +// (launch → observe → close) over the existing browser runtime library. It is +// the only application-layer authority that starts a browser; the readiness +// gate, durable review, disposable Profile, and network containment all remain +// mandatory and fail closed. +type BrowserRuntimeService struct { + store BrowserRuntimeStore + controller *browserruntime.BrowserProcessController + runtimeCapabilities browserruntime.ProductionRuntimeCapabilities + permissionCapabilities domain.BrowserCDPPermissionRuntimeCapabilities +} + +func NewBrowserRuntimeService(store BrowserRuntimeStore, + controller *browserruntime.BrowserProcessController, + runtimeCapabilities browserruntime.ProductionRuntimeCapabilities, + permissionCapabilities domain.BrowserCDPPermissionRuntimeCapabilities, +) *BrowserRuntimeService { + return &BrowserRuntimeService{store: store, controller: controller, + runtimeCapabilities: runtimeCapabilities, + permissionCapabilities: permissionCapabilities} +} + +// Launch runs the full fail-closed operator flow up to process start: durable +// preparation and review, network-containment plan, authorization, disposable +// Profile materialization, and contained process launch. It returns a handle +// whose coordinator performs the bounded close/cleanup. It never returns a +// handle whose process is not yet bound to the restricted Job. +func (s *BrowserRuntimeService) Launch(ctx context.Context, + request BrowserRuntimeLaunchRequest, +) (*BrowserRuntimeHandle, error) { + if s == nil || s.store == nil || s.controller == nil { + return nil, errors.New("browser runtime 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("browser runtime 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("browser runtime launch 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 + } + if permission.Mode != domain.RunBrowserCDPPermissionRestricted { + return nil, errors.New("browser runtime launch requires the restricted CDP permission") + } + now := time.Now().UTC() + session, err := browserruntime.BuildSessionPlan(browserruntime.NewSessionPlanRequest{ + SessionID: run.SessionID, RunID: run.ID, WorkspaceID: mission.WorkspaceID, + ProfileID: browserruntime.ProfileSafeWeb, Targets: []string{request.Target}, + }) + if err != nil { + return nil, err + } + evidence, err := s.store.LoadLatestBrowserNetworkEvidence(ctx, request.Identity.Fingerprint) + if err != nil { + return nil, err + } + review := browserruntime.BrowserNetworkContainmentReview{} + if evidence.Fingerprint != "" { + review, err = s.store.LoadBrowserNetworkReview(ctx, evidence.Fingerprint) + if err != nil { + return nil, err + } + } + readiness, err := browserruntime.BuildBrowserSafeWebReadiness(evidence, review, + request.Identity, request.Acceptance, now) + if err != nil { + return nil, err + } + if !readiness.Ready { + return nil, errors.New("browser runtime launch refused: safe web is not ready (" + + readiness.BlockingReason + ")") + } + ownership, err := browserruntime.BuildProfileOwnershipPlan(session, request.Identity, + request.ProfileRoot) + if err != nil { + return nil, err + } + launchAttempt, launchLease, _, err := s.store.PrepareBrowserLaunch(ctx, session, + request.Identity, request.Acceptance, ownership, request.OperationKey, + request.LeaseOwnerIdentity) + if err != nil { + return nil, err + } + launchReview, _, err := s.store.RecordBrowserLaunchReview(ctx, session, + request.Identity, request.Acceptance, ownership, launchAttempt, launchLease, + browserruntime.BrowserLaunchReviewAcceptCandidate, request.ReviewOperationKey, + request.ReviewerIdentity) + if err != nil { + return nil, err + } + networkPlan, err := browserruntime.BuildBrowserNetworkContainmentPlan(session, + request.Identity, request.Acceptance, evidence, review, now, evidence.ExpiresAt) + if err != nil { + return nil, err + } + authorization, err := browserruntime.AuthorizeSafeWebStart(session, request.Identity, + request.Acceptance, ownership, launchAttempt, launchLease, launchReview, + evidence, review, networkPlan, permission, s.permissionCapabilities, + s.runtimeCapabilities, now) + if err != nil { + return nil, err + } + profileLease, err := browserruntime.MaterializeDisposableProfile(authorization, + session, request.Identity, request.Acceptance, ownership, launchAttempt, + launchLease, launchReview, evidence, review, networkPlan, permission, now) + if err != nil { + return nil, err + } + process, err := s.controller.Start(ctx, authorization, session, request.Identity, + request.Acceptance, ownership, launchAttempt, launchLease, launchReview, + evidence, review, networkPlan, permission, profileLease, now) + if err != nil { + return nil, err + } + runtimeID := idgen.New("browser_runtime") + coordinator, err := browserruntime.NewBrowserRuntimeLifecycleCoordinator(runtimeID, + launchAttempt, authorization, ownership, profileLease, process, nil, s.store, now) + if err != nil { + _ = process.Stop(context.Background()) + return nil, err + } + return &BrowserRuntimeHandle{RuntimeID: runtimeID, Coordinator: coordinator}, nil +} + +// Close finalizes a running browser session: it stops the process tree, verifies +// network-containment cleanup, releases and cleans the disposable Profile, and +// records the terminal receipt. Caller cancellation never skips bounded cleanup. +func (s *BrowserRuntimeService) Close(ctx context.Context, + handle *BrowserRuntimeHandle, +) (browserruntime.BrowserRuntimeReceipt, error) { + if s == nil { + return browserruntime.BrowserRuntimeReceipt{}, errors.New("browser runtime service is required") + } + if handle == nil || handle.Coordinator == nil { + return browserruntime.BrowserRuntimeReceipt{}, errors.New("browser runtime handle is required") + } + return handle.Coordinator.Finalize(ctx) +} diff --git a/internal/application/browser_runtime_test.go b/internal/application/browser_runtime_test.go new file mode 100644 index 00000000..aab24414 --- /dev/null +++ b/internal/application/browser_runtime_test.go @@ -0,0 +1,133 @@ +package application + +import ( + "context" + "database/sql" + "testing" + "time" + + "cyberagent-workbench/internal/browserruntime" + "cyberagent-workbench/internal/domain" +) + +type fakeBrowserRuntimeStore struct { + run domain.Run + mission domain.Mission + permission domain.RunBrowserCDPPermissionSnapshot + evidenceErr error +} + +func (f *fakeBrowserRuntimeStore) GetRun(context.Context, string) (domain.Run, error) { + return f.run, nil +} + +func (f *fakeBrowserRuntimeStore) GetMission(context.Context, string) (domain.Mission, error) { + return f.mission, nil +} + +func (f *fakeBrowserRuntimeStore) GetRunBrowserCDPPermission(context.Context, + string, +) (domain.RunBrowserCDPPermissionSnapshot, error) { + return f.permission, nil +} + +func (f *fakeBrowserRuntimeStore) LoadLatestBrowserNetworkEvidence(context.Context, + string, +) (browserruntime.BrowserNetworkContainmentEvidence, error) { + if f.evidenceErr != nil { + return browserruntime.BrowserNetworkContainmentEvidence{}, f.evidenceErr + } + return browserruntime.BrowserNetworkContainmentEvidence{}, sql.ErrNoRows +} + +func (f *fakeBrowserRuntimeStore) LoadBrowserNetworkReview(context.Context, + string, +) (browserruntime.BrowserNetworkContainmentReview, error) { + return browserruntime.BrowserNetworkContainmentReview{}, sql.ErrNoRows +} + +func (f *fakeBrowserRuntimeStore) PrepareBrowserLaunch(context.Context, + browserruntime.SessionPlan, browserruntime.BrowserExecutableIdentity, + browserruntime.BrowserAcceptanceCandidate, browserruntime.ProfileOwnershipPlan, + string, string, +) (browserruntime.BrowserLaunchAttempt, browserruntime.BrowserLaunchLease, bool, error) { + panic("PrepareBrowserLaunch must not be reached in fail-closed tests") +} + +func (f *fakeBrowserRuntimeStore) RecordBrowserLaunchReview(context.Context, + browserruntime.SessionPlan, browserruntime.BrowserExecutableIdentity, + browserruntime.BrowserAcceptanceCandidate, browserruntime.ProfileOwnershipPlan, + browserruntime.BrowserLaunchAttempt, browserruntime.BrowserLaunchLease, + browserruntime.BrowserLaunchReviewDecision, string, string, +) (browserruntime.BrowserLaunchReview, bool, error) { + panic("RecordBrowserLaunchReview must not be reached in fail-closed tests") +} + +func (f *fakeBrowserRuntimeStore) RecordBrowserRuntimeCheckpoint(context.Context, + browserruntime.BrowserRuntimeCheckpoint, +) error { + panic("RecordBrowserRuntimeCheckpoint must not be reached in fail-closed tests") +} + +func (f *fakeBrowserRuntimeStore) RecordBrowserRuntimeReceipt(context.Context, + browserruntime.BrowserRuntimeReceipt, +) error { + panic("RecordBrowserRuntimeReceipt must not be reached in fail-closed tests") +} + +func TestBrowserRuntimeLaunchFailsClosedWithZeroIdentity(t *testing.T) { + controller, err := browserruntime.NewPlatformBrowserProcessController() + if err != nil { + t.Fatal(err) + } + service := NewBrowserRuntimeService(&fakeBrowserRuntimeStore{}, controller, + browserruntime.ProductionRuntimeCapabilities{ + SafeWebStartEnabled: true, DisposableProfileEnabled: true, + NetworkContainmentEnabled: true, + }, + domain.BrowserCDPPermissionRuntimeCapabilities{ControlEnabled: true}) + + if _, err := service.Launch(t.Context(), BrowserRuntimeLaunchRequest{ + RunID: "run-1", Target: "http://127.0.0.1:8080", + }); err == nil { + t.Fatal("zero browser identity unexpectedly launched a runtime") + } +} + +func TestBrowserRuntimeLaunchRefusesWhenNotReady(t *testing.T) { + now := time.Now().UTC() + identity, acceptance, _, _ := safeWebRuntimeFixture(t) + permission, err := domain.NewInitialRunBrowserCDPPermissionSnapshot( + "browser-cdp-runtime", domain.Run{ID: "run-runtime", MissionID: "mission-runtime", + SessionID: "session-runtime", Status: domain.RunCreated, CreatedAt: now}, + domain.Mission{ID: "mission-runtime", WorkspaceID: "workspace-runtime", CreatedAt: now}, + "runtime-operator", now) + if err != nil { + t.Fatal(err) + } + store := &fakeBrowserRuntimeStore{ + run: domain.Run{ID: "run-runtime", MissionID: "mission-runtime", + SessionID: "session-runtime", Status: domain.RunCreated, CreatedAt: now}, + mission: domain.Mission{ID: "mission-runtime", WorkspaceID: "workspace-runtime", CreatedAt: now}, + permission: permission, + } + controller, err := browserruntime.NewPlatformBrowserProcessController() + if err != nil { + t.Fatal(err) + } + service := NewBrowserRuntimeService(store, controller, + browserruntime.ProductionRuntimeCapabilities{ + SafeWebStartEnabled: true, DisposableProfileEnabled: true, + NetworkContainmentEnabled: true, + }, + domain.BrowserCDPPermissionRuntimeCapabilities{ControlEnabled: true}) + _, err = service.Launch(t.Context(), BrowserRuntimeLaunchRequest{ + RunID: "run-runtime", Target: "http://127.0.0.1:8080", + Identity: identity, Acceptance: acceptance, ProfileRoot: t.TempDir(), + OperationKey: "launch-op-1", LeaseOwnerIdentity: "runtime-worker", + ReviewerIdentity: "runtime-reviewer", ReviewOperationKey: "review-op-1", + }) + if err == nil { + t.Fatal("not-ready Safe Web unexpectedly launched a runtime") + } +} diff --git a/internal/application/browser_safe_web_runtime.go b/internal/application/browser_safe_web_runtime.go new file mode 100644 index 00000000..d033ff70 --- /dev/null +++ b/internal/application/browser_safe_web_runtime.go @@ -0,0 +1,71 @@ +package application + +import ( + "context" + "database/sql" + "errors" + "time" + + "cyberagent-workbench/internal/apperror" + "cyberagent-workbench/internal/browserruntime" +) + +// BrowserSafeWebRuntimeStore supplies the durable production containment +// evidence and operator review the readiness judgment consumes. +type BrowserSafeWebRuntimeStore interface { + LoadLatestBrowserNetworkEvidence(ctx context.Context, + executableIdentityFingerprint string) (browserruntime.BrowserNetworkContainmentEvidence, error) + LoadBrowserNetworkReview(ctx context.Context, + evidenceFingerprint string) (browserruntime.BrowserNetworkContainmentReview, error) +} + +// BrowserSafeWebRuntimeService turns durable network containment facts into a +// process-local Safe Web readiness judgment. It never starts a browser and never +// widens launch authority; it only decides whether the entry may be offered. +type BrowserSafeWebRuntimeService struct { + store BrowserSafeWebRuntimeStore +} + +func NewBrowserSafeWebRuntimeService(store BrowserSafeWebRuntimeStore) *BrowserSafeWebRuntimeService { + return &BrowserSafeWebRuntimeService{store: store} +} + +// Readiness loads the latest containment evidence and its operator review for +// the exact accepted browser and collapses them into a fail-closed readiness +// receipt. A missing evidence or review is not an error: it yields a +// Ready=false receipt so callers can surface the precise blocking reason. +func (s *BrowserSafeWebRuntimeService) Readiness(ctx context.Context, + identity browserruntime.BrowserExecutableIdentity, + acceptance browserruntime.BrowserAcceptanceCandidate, +) (browserruntime.BrowserSafeWebReadiness, error) { + if s == nil || s.store == nil { + return browserruntime.BrowserSafeWebReadiness{}, apperror.New( + apperror.CodeFailedPrecondition, "browser safe-web runtime store is required") + } + if err := browserruntime.ValidateBrowserExecutableIdentity(identity); err != nil { + return browserruntime.BrowserSafeWebReadiness{}, apperror.Wrap( + apperror.CodeInvalidArgument, "browser executable identity is invalid", err) + } + if err := browserruntime.ValidateBrowserAcceptanceCandidate(acceptance, identity); err != nil { + return browserruntime.BrowserSafeWebReadiness{}, apperror.Wrap( + apperror.CodeInvalidArgument, "browser acceptance candidate is invalid", err) + } + evidence, err := s.store.LoadLatestBrowserNetworkEvidence(ctx, identity.Fingerprint) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return browserruntime.BrowserSafeWebReadiness{}, apperror.Normalize(err) + } + review := browserruntime.BrowserNetworkContainmentReview{} + if evidence.Fingerprint != "" { + review, err = s.store.LoadBrowserNetworkReview(ctx, evidence.Fingerprint) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return browserruntime.BrowserSafeWebReadiness{}, apperror.Normalize(err) + } + } + readiness, err := browserruntime.BuildBrowserSafeWebReadiness(evidence, review, + identity, acceptance, time.Now().UTC()) + if err != nil { + return browserruntime.BrowserSafeWebReadiness{}, apperror.Wrap( + apperror.CodeInvalidArgument, "browser safe-web readiness judgment failed", err) + } + return readiness, nil +} diff --git a/internal/application/browser_safe_web_runtime_test.go b/internal/application/browser_safe_web_runtime_test.go new file mode 100644 index 00000000..932601e7 --- /dev/null +++ b/internal/application/browser_safe_web_runtime_test.go @@ -0,0 +1,201 @@ +package application + +import ( + "context" + "crypto/sha256" + "database/sql" + "encoding/hex" + "encoding/json" + "errors" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "cyberagent-workbench/internal/browserruntime" +) + +type fakeSafeWebRuntimeStore struct { + evidence browserruntime.BrowserNetworkContainmentEvidence + review browserruntime.BrowserNetworkContainmentReview + evidenceErr error + reviewErr error +} + +func (f *fakeSafeWebRuntimeStore) LoadLatestBrowserNetworkEvidence(context.Context, + string, +) (browserruntime.BrowserNetworkContainmentEvidence, error) { + if f.evidenceErr != nil { + return browserruntime.BrowserNetworkContainmentEvidence{}, f.evidenceErr + } + return f.evidence, nil +} + +func (f *fakeSafeWebRuntimeStore) LoadBrowserNetworkReview(context.Context, + string, +) (browserruntime.BrowserNetworkContainmentReview, error) { + if f.reviewErr != nil { + return browserruntime.BrowserNetworkContainmentReview{}, f.reviewErr + } + return f.review, nil +} + +func safeWebRuntimeFixture(t *testing.T) (browserruntime.BrowserExecutableIdentity, + browserruntime.BrowserAcceptanceCandidate, browserruntime.BrowserNetworkContainmentEvidence, + browserruntime.BrowserNetworkContainmentReview, +) { + 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 = safeWebRuntimeFingerprint(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 = safeWebRuntimeFingerprint(t, acceptance) + if err := browserruntime.ValidateBrowserAcceptanceCandidate(acceptance, identity); err != nil { + t.Fatal(err) + } + now := time.Now().UTC().Round(time.Millisecond) + evidence, err := browserruntime.BuildBrowserNetworkContainmentEvidence(identity, acceptance, + browserruntime.BrowserNetworkProbeReport{ + ID: "browser-network-evidence-app", CollectorIdentity: "network-probe-operator", + Adapter: browserruntime.WindowsWFPBrowserContainmentAdapterName, + DynamicSessionObserved: true, AtomicInstallObserved: true, + ExactTargetObserved: true, WrongPortDenied: true, + WrongLoopbackAddressDenied: true, NonLoopbackAddressDenied: true, + IPv6Denied: true, RuleCleanupObserved: true, Production: true, + StartedAt: now, CompletedAt: now.Add(time.Second), + }) + if err != nil { + t.Fatal(err) + } + review, err := browserruntime.BuildBrowserNetworkContainmentReview(evidence, + identity, acceptance, "browser-network-review-app", "independent-network-reviewer", + true, evidence.CompletedAt.Add(time.Second)) + if err != nil { + t.Fatal(err) + } + return identity, acceptance, evidence, review +} + +func safeWebRuntimeFingerprint(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 safe-web runtime 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 TestBrowserSafeWebRuntimeServiceReadiness(t *testing.T) { + identity, acceptance, evidence, review := safeWebRuntimeFixture(t) + + t.Run("ready", func(t *testing.T) { + service := NewBrowserSafeWebRuntimeService(&fakeSafeWebRuntimeStore{ + evidence: evidence, review: review, + }) + readiness, err := service.Readiness(t.Context(), identity, acceptance) + if err != nil { + t.Fatal(err) + } + if !readiness.Ready || readiness.BlockingReason != "" { + t.Fatalf("expected ready receipt, got %#v", readiness) + } + }) + + t.Run("missing evidence", func(t *testing.T) { + service := NewBrowserSafeWebRuntimeService(&fakeSafeWebRuntimeStore{ + evidenceErr: sql.ErrNoRows, + }) + readiness, err := service.Readiness(t.Context(), identity, acceptance) + if err != nil { + t.Fatal(err) + } + if readiness.Ready || readiness.BlockingReason != browserruntime.BrowserSafeWebBlockedEvidenceMissing { + t.Fatalf("ready=%t reason=%q", readiness.Ready, readiness.BlockingReason) + } + }) + + t.Run("missing review", func(t *testing.T) { + service := NewBrowserSafeWebRuntimeService(&fakeSafeWebRuntimeStore{ + evidence: evidence, reviewErr: sql.ErrNoRows, + }) + readiness, err := service.Readiness(t.Context(), identity, acceptance) + if err != nil { + t.Fatal(err) + } + if readiness.Ready || readiness.BlockingReason != browserruntime.BrowserSafeWebBlockedReviewMissing { + t.Fatalf("ready=%t reason=%q", readiness.Ready, readiness.BlockingReason) + } + }) + + t.Run("rejected review", func(t *testing.T) { + rejected, err := browserruntime.BuildBrowserNetworkContainmentReview(evidence, + identity, acceptance, "browser-network-review-rejected-app", + "independent-network-reviewer", false, evidence.CompletedAt.Add(time.Second)) + if err != nil { + t.Fatal(err) + } + service := NewBrowserSafeWebRuntimeService(&fakeSafeWebRuntimeStore{ + evidence: evidence, review: rejected, + }) + readiness, err := service.Readiness(t.Context(), identity, acceptance) + if err != nil { + t.Fatal(err) + } + if readiness.Ready || readiness.BlockingReason != browserruntime.BrowserSafeWebBlockedReviewNotAccepted { + t.Fatalf("ready=%t reason=%q", readiness.Ready, readiness.BlockingReason) + } + }) + + t.Run("store error surfaces", func(t *testing.T) { + service := NewBrowserSafeWebRuntimeService(&fakeSafeWebRuntimeStore{ + evidenceErr: errors.New("store unavailable"), + }) + if _, err := service.Readiness(t.Context(), identity, acceptance); err == nil { + t.Fatal("store error was swallowed") + } + }) +} diff --git a/internal/browserruntime/readiness.go b/internal/browserruntime/readiness.go new file mode 100644 index 00000000..89bd7c9e --- /dev/null +++ b/internal/browserruntime/readiness.go @@ -0,0 +1,169 @@ +package browserruntime + +import ( + "errors" + "runtime" + "time" +) + +const BrowserSafeWebReadinessProtocolVersion = "browser_safe_web_readiness.v1" + +// BrowserSafeWebReadiness blocking reasons. A ready receipt always carries an +// empty reason; a fail-closed receipt carries exactly one precise reason. +const ( + BrowserSafeWebBlockedEvidenceMissing = "evidence_missing" + BrowserSafeWebBlockedEvidenceVersionMismatch = "evidence_version_mismatch" + BrowserSafeWebBlockedExecutableMismatch = "executable_identity_mismatch" + BrowserSafeWebBlockedAcceptanceMismatch = "acceptance_mismatch" + BrowserSafeWebBlockedPolicyVersionMismatch = "policy_version_mismatch" + BrowserSafeWebBlockedAdapterMismatch = "adapter_mismatch" + BrowserSafeWebBlockedPlatformMismatch = "platform_mismatch" + BrowserSafeWebBlockedEvidenceNotPassed = "evidence_not_passed" + BrowserSafeWebBlockedReviewMissing = "review_missing" + BrowserSafeWebBlockedReviewBindingMismatch = "review_binding_mismatch" + BrowserSafeWebBlockedReviewNotAccepted = "review_not_accepted" + BrowserSafeWebBlockedEvidenceExpired = "evidence_expired" +) + +// BrowserSafeWebReadiness is a bounded, fail-closed judgment over a production +// network-containment evidence + review pair. It records only whether Safe Web +// is currently ready for one exact accepted browser executable, and why not. It +// never authorizes a launch by itself: it is the process-local gate consumed by +// the Safe Web operator entry. +type BrowserSafeWebReadiness struct { + ProtocolVersion string `json:"protocol_version"` + EvidenceFingerprint string `json:"evidence_fingerprint"` + ReviewFingerprint string `json:"review_fingerprint"` + ExecutableIdentityFingerprint string `json:"executable_identity_fingerprint"` + AcceptanceFingerprint string `json:"acceptance_fingerprint"` + Adapter string `json:"adapter"` + PolicyVersion string `json:"policy_version"` + OperatingSystem string `json:"operating_system"` + Architecture string `json:"architecture"` + CollectorIdentity string `json:"collector_identity"` + ReviewerIdentity string `json:"reviewer_identity"` + Ready bool `json:"ready"` + BlockingReason string `json:"blocking_reason,omitempty"` + IssuedAt time.Time `json:"issued_at"` + ExpiresAt time.Time `json:"expires_at"` + Fingerprint string `json:"fingerprint"` +} + +// BuildBrowserSafeWebReadiness evaluates a production containment evidence + +// review pair against the current accepted browser identity. The evidence and +// review are untrusted operator inputs: a missing, expired, version-mismatched, +// hash-mismatched, policy-mismatched, or rejected pair fails closed as a +// Ready=false receipt with a precise blocking reason rather than an error. +func BuildBrowserSafeWebReadiness(evidence BrowserNetworkContainmentEvidence, + review BrowserNetworkContainmentReview, identity BrowserExecutableIdentity, + acceptance BrowserAcceptanceCandidate, now time.Time, +) (BrowserSafeWebReadiness, error) { + if err := ValidateBrowserExecutableIdentity(identity); err != nil { + return BrowserSafeWebReadiness{}, err + } + if err := ValidateBrowserAcceptanceCandidate(acceptance, identity); err != nil { + return BrowserSafeWebReadiness{}, err + } + now = now.UTC() + if now.IsZero() { + return BrowserSafeWebReadiness{}, errors.New("browser safe-web readiness requires a current time") + } + ready, reason := judgeBrowserSafeWebReadiness(evidence, review, identity, acceptance, now) + readiness := BrowserSafeWebReadiness{ + ProtocolVersion: BrowserSafeWebReadinessProtocolVersion, + EvidenceFingerprint: evidence.Fingerprint, + ReviewFingerprint: review.Fingerprint, + ExecutableIdentityFingerprint: identity.Fingerprint, + AcceptanceFingerprint: acceptance.Fingerprint, + Adapter: evidence.Adapter, + PolicyVersion: evidence.PolicyVersion, + OperatingSystem: evidence.OperatingSystem, + Architecture: evidence.Architecture, + CollectorIdentity: evidence.CollectorIdentity, + ReviewerIdentity: review.ReviewerIdentity, + Ready: ready, + BlockingReason: reason, + IssuedAt: now, + ExpiresAt: evidence.ExpiresAt, + } + readiness.Fingerprint = browserRuntimeFingerprint(readiness) + if err := ValidateBrowserSafeWebReadiness(readiness, evidence, review, + identity, acceptance); err != nil { + return BrowserSafeWebReadiness{}, err + } + return readiness, nil +} + +func judgeBrowserSafeWebReadiness(evidence BrowserNetworkContainmentEvidence, + review BrowserNetworkContainmentReview, identity BrowserExecutableIdentity, + acceptance BrowserAcceptanceCandidate, now time.Time, +) (bool, string) { + switch { + case evidence.Fingerprint == "": + return false, BrowserSafeWebBlockedEvidenceMissing + case evidence.ProtocolVersion != BrowserNetworkContainmentEvidenceProtocolVersion: + return false, BrowserSafeWebBlockedEvidenceVersionMismatch + case evidence.ExecutableIdentityFingerprint != identity.Fingerprint: + return false, BrowserSafeWebBlockedExecutableMismatch + case evidence.AcceptanceFingerprint != acceptance.Fingerprint: + return false, BrowserSafeWebBlockedAcceptanceMismatch + case evidence.PolicyVersion != BrowserNetworkContainmentPolicyVersion: + return false, BrowserSafeWebBlockedPolicyVersionMismatch + case evidence.Adapter != WindowsWFPBrowserContainmentAdapterName: + return false, BrowserSafeWebBlockedAdapterMismatch + case evidence.OperatingSystem != runtime.GOOS || evidence.Architecture != runtime.GOARCH: + return false, BrowserSafeWebBlockedPlatformMismatch + case !evidence.Passed: + return false, BrowserSafeWebBlockedEvidenceNotPassed + case review.Fingerprint == "": + return false, BrowserSafeWebBlockedReviewMissing + case review.EvidenceFingerprint != evidence.Fingerprint: + return false, BrowserSafeWebBlockedReviewBindingMismatch + case !review.Accepted: + return false, BrowserSafeWebBlockedReviewNotAccepted + case !now.Before(evidence.ExpiresAt): + return false, BrowserSafeWebBlockedEvidenceExpired + default: + return true, "" + } +} + +// ValidateBrowserSafeWebReadiness re-checks that a readiness receipt still +// binds the exact evidence, review, identity, and acceptance it was judged +// against, and that its ready state and blocking reason agree with a fresh +// judgment at its issued-at time. +func ValidateBrowserSafeWebReadiness(readiness BrowserSafeWebReadiness, + evidence BrowserNetworkContainmentEvidence, review BrowserNetworkContainmentReview, + identity BrowserExecutableIdentity, acceptance BrowserAcceptanceCandidate, +) error { + if err := ValidateBrowserExecutableIdentity(identity); err != nil { + return err + } + if err := ValidateBrowserAcceptanceCandidate(acceptance, identity); err != nil { + return err + } + if readiness.IssuedAt.IsZero() { + return errors.New("browser safe-web readiness has no issued-at time") + } + ready, reason := judgeBrowserSafeWebReadiness(evidence, review, identity, + acceptance, readiness.IssuedAt) + if readiness.ProtocolVersion != BrowserSafeWebReadinessProtocolVersion || + readiness.EvidenceFingerprint != evidence.Fingerprint || + readiness.ReviewFingerprint != review.Fingerprint || + readiness.ExecutableIdentityFingerprint != identity.Fingerprint || + readiness.AcceptanceFingerprint != acceptance.Fingerprint || + readiness.Adapter != evidence.Adapter || + readiness.PolicyVersion != evidence.PolicyVersion || + readiness.OperatingSystem != evidence.OperatingSystem || + readiness.Architecture != evidence.Architecture || + readiness.CollectorIdentity != evidence.CollectorIdentity || + readiness.ReviewerIdentity != review.ReviewerIdentity || + readiness.Ready != ready || readiness.BlockingReason != reason || + readiness.ExpiresAt != evidence.ExpiresAt || + (readiness.Ready && readiness.BlockingReason != "") || + (!readiness.Ready && readiness.BlockingReason == "") || + readiness.Fingerprint != browserRuntimeFingerprint(readiness) { + return errors.New("browser safe-web readiness lost its fail-closed boundary") + } + return nil +} diff --git a/internal/browserruntime/readiness_test.go b/internal/browserruntime/readiness_test.go new file mode 100644 index 00000000..3b18b079 --- /dev/null +++ b/internal/browserruntime/readiness_test.go @@ -0,0 +1,187 @@ +package browserruntime + +import ( + "os" + "strings" + "testing" + "time" +) + +func TestBrowserSafeWebReadinessReady(t *testing.T) { + facts := newLoopbackBrowserRuntimeFacts(t) + readiness, err := BuildBrowserSafeWebReadiness(facts.networkEvidence, + facts.networkReview, facts.identity, facts.acceptance, facts.now) + if err != nil { + t.Fatal(err) + } + if !readiness.Ready || readiness.BlockingReason != "" { + t.Fatalf("expected a ready receipt, got %#v", readiness) + } + if !readiness.ExpiresAt.Equal(facts.networkEvidence.ExpiresAt) { + t.Fatalf("readiness expiry drifted from evidence: %v != %v", + readiness.ExpiresAt, facts.networkEvidence.ExpiresAt) + } + if err := ValidateBrowserSafeWebReadiness(readiness, facts.networkEvidence, + facts.networkReview, facts.identity, facts.acceptance); err != nil { + t.Fatal(err) + } +} + +func TestBrowserSafeWebReadinessFailsClosedOnEvidence(t *testing.T) { + facts := newLoopbackBrowserRuntimeFacts(t) + tests := []struct { + name string + mutate func(*BrowserNetworkContainmentEvidence) + want string + }{ + {"missing", func(e *BrowserNetworkContainmentEvidence) { + *e = BrowserNetworkContainmentEvidence{} + }, BrowserSafeWebBlockedEvidenceMissing}, + {"version", func(e *BrowserNetworkContainmentEvidence) { + e.ProtocolVersion = "browser_network_containment_evidence.v0" + }, BrowserSafeWebBlockedEvidenceVersionMismatch}, + {"policy", func(e *BrowserNetworkContainmentEvidence) { + e.PolicyVersion = "browser_network_containment_policy.v1" + }, BrowserSafeWebBlockedPolicyVersionMismatch}, + {"adapter", func(e *BrowserNetworkContainmentEvidence) { + e.Adapter = FakeBrowserContainmentAdapterName + }, BrowserSafeWebBlockedAdapterMismatch}, + {"platform", func(e *BrowserNetworkContainmentEvidence) { + e.OperatingSystem = "plan9" + }, BrowserSafeWebBlockedPlatformMismatch}, + {"not_passed", func(e *BrowserNetworkContainmentEvidence) { + e.Passed = false + }, BrowserSafeWebBlockedEvidenceNotPassed}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + evidence := facts.networkEvidence + test.mutate(&evidence) + readiness, err := BuildBrowserSafeWebReadiness(evidence, + facts.networkReview, facts.identity, facts.acceptance, facts.now) + if err != nil { + t.Fatal(err) + } + if readiness.Ready || readiness.BlockingReason != test.want { + t.Fatalf("ready=%t reason=%q, want reason=%q", + readiness.Ready, readiness.BlockingReason, test.want) + } + }) + } +} + +func TestBrowserSafeWebReadinessFailsClosedOnIdentityAndAcceptance(t *testing.T) { + facts := newLoopbackBrowserRuntimeFacts(t) + _, otherIdentity, otherAcceptance, _ := browserLaunchFixture(t) + readiness, err := BuildBrowserSafeWebReadiness(facts.networkEvidence, + facts.networkReview, otherIdentity, otherAcceptance, facts.now) + if err != nil { + t.Fatal(err) + } + if readiness.Ready || readiness.BlockingReason != BrowserSafeWebBlockedExecutableMismatch { + t.Fatalf("identity mismatch ready=%t reason=%q", readiness.Ready, readiness.BlockingReason) + } + + driftedAcceptance, err := buildBrowserAcceptanceCandidate(facts.identity, + func(*os.File, string) (AuthenticodeEvidence, error) { + return AuthenticodeEvidence{ + Source: AuthenticodeSourceWindows, Publisher: "Google LLC", + CertificateSHA256: strings.Repeat("b", 64), SignatureVerified: true, + SameOpenHandleVerified: true, CacheOnlyVerification: true, + }, nil + }) + if err != nil { + t.Fatal(err) + } + readiness, err = BuildBrowserSafeWebReadiness(facts.networkEvidence, + facts.networkReview, facts.identity, driftedAcceptance, facts.now) + if err != nil { + t.Fatal(err) + } + if readiness.Ready || readiness.BlockingReason != BrowserSafeWebBlockedAcceptanceMismatch { + t.Fatalf("acceptance mismatch ready=%t reason=%q", readiness.Ready, readiness.BlockingReason) + } +} + +func TestBrowserSafeWebReadinessFailsClosedOnReview(t *testing.T) { + facts := newLoopbackBrowserRuntimeFacts(t) + + t.Run("missing", func(t *testing.T) { + readiness, err := BuildBrowserSafeWebReadiness(facts.networkEvidence, + BrowserNetworkContainmentReview{}, facts.identity, facts.acceptance, facts.now) + if err != nil { + t.Fatal(err) + } + if readiness.Ready || readiness.BlockingReason != BrowserSafeWebBlockedReviewMissing { + t.Fatalf("ready=%t reason=%q", readiness.Ready, readiness.BlockingReason) + } + }) + + t.Run("binding", func(t *testing.T) { + review := facts.networkReview + review.EvidenceFingerprint = strings.Repeat("c", 64) + readiness, err := BuildBrowserSafeWebReadiness(facts.networkEvidence, + review, facts.identity, facts.acceptance, facts.now) + if err != nil { + t.Fatal(err) + } + if readiness.Ready || readiness.BlockingReason != BrowserSafeWebBlockedReviewBindingMismatch { + t.Fatalf("ready=%t reason=%q", readiness.Ready, readiness.BlockingReason) + } + }) + + t.Run("rejected", func(t *testing.T) { + rejected, err := BuildBrowserNetworkContainmentReview(facts.networkEvidence, + facts.identity, facts.acceptance, "browser-network-review-rejected", + "independent-network-reviewer", false, + facts.networkEvidence.CompletedAt.Add(time.Second)) + if err != nil { + t.Fatal(err) + } + readiness, err := BuildBrowserSafeWebReadiness(facts.networkEvidence, + rejected, facts.identity, facts.acceptance, facts.now) + if err != nil { + t.Fatal(err) + } + if readiness.Ready || readiness.BlockingReason != BrowserSafeWebBlockedReviewNotAccepted { + t.Fatalf("ready=%t reason=%q", readiness.Ready, readiness.BlockingReason) + } + }) +} + +func TestBrowserSafeWebReadinessFailsClosedOnExpiry(t *testing.T) { + facts := newLoopbackBrowserRuntimeFacts(t) + readiness, err := BuildBrowserSafeWebReadiness(facts.networkEvidence, + facts.networkReview, facts.identity, facts.acceptance, + facts.networkEvidence.ExpiresAt) + if err != nil { + t.Fatal(err) + } + if readiness.Ready || readiness.BlockingReason != BrowserSafeWebBlockedEvidenceExpired { + t.Fatalf("ready=%t reason=%q", readiness.Ready, readiness.BlockingReason) + } +} + +func TestBrowserSafeWebReadinessRejectsTampering(t *testing.T) { + facts := newLoopbackBrowserRuntimeFacts(t) + readiness, err := BuildBrowserSafeWebReadiness(facts.networkEvidence, + facts.networkReview, facts.identity, facts.acceptance, facts.now) + if err != nil { + t.Fatal(err) + } + for name, tamper := range map[string]func(*BrowserSafeWebReadiness){ + "ready_flip": func(r *BrowserSafeWebReadiness) { r.Ready = !r.Ready }, + "reason_forged": func(r *BrowserSafeWebReadiness) { r.BlockingReason = BrowserSafeWebBlockedEvidenceExpired }, + "identity_forged": func(r *BrowserSafeWebReadiness) { r.ExecutableIdentityFingerprint = strings.Repeat("d", 64) }, + "expiry_forged": func(r *BrowserSafeWebReadiness) { r.ExpiresAt = r.ExpiresAt.Add(time.Hour) }, + } { + t.Run(name, func(t *testing.T) { + receipt := readiness + tamper(&receipt) + if err := ValidateBrowserSafeWebReadiness(receipt, facts.networkEvidence, + facts.networkReview, facts.identity, facts.acceptance); err == nil { + t.Fatal("tampered readiness receipt was accepted") + } + }) + } +} diff --git a/internal/httpapi/browser_safe_web_readiness.go b/internal/httpapi/browser_safe_web_readiness.go new file mode 100644 index 00000000..c7b37461 --- /dev/null +++ b/internal/httpapi/browser_safe_web_readiness.go @@ -0,0 +1,66 @@ +package httpapi + +import ( + "fmt" + "net/http" + "strings" + + "cyberagent-workbench/internal/apperror" + "cyberagent-workbench/internal/application" + "cyberagent-workbench/internal/browserruntime" +) + +// browserSafeWebReadiness is a read-only query that collapses the latest +// durable production containment evidence and its operator review into one +// fail-closed readiness receipt. It never starts a browser and never grants +// authority; a missing evidence or review is reported as a Ready=false receipt. +func (a *API) browserSafeWebReadiness(request *http.Request) (any, *Page, error) { + if err := validateSingleQueryValues(request.URL.Query(), "product"); err != nil { + return nil, nil, err + } + productRaw := strings.ToLower(strings.TrimSpace(request.URL.Query().Get("product"))) + if productRaw == "" { + productRaw = string(browserruntime.BrowserProductChrome) + } + product := browserruntime.BrowserProduct(productRaw) + switch product { + case browserruntime.BrowserProductEdge, browserruntime.BrowserProductChrome, + browserruntime.BrowserProductChromium: + default: + return nil, nil, apperror.New(apperror.CodeInvalidArgument, + "browser-readiness product must be edge, chrome, or chromium") + } + identity, acceptance, err := discoverAcceptedBrowser(product) + if err != nil { + return nil, nil, apperror.New(apperror.CodeNotFound, err.Error()) + } + service := application.NewBrowserSafeWebRuntimeService(a.store) + readiness, err := service.Readiness(request.Context(), identity, acceptance) + if err != nil { + return nil, nil, apperror.Normalize(err) + } + return readiness, nil, nil +} + +func discoverAcceptedBrowser(product browserruntime.BrowserProduct) ( + browserruntime.BrowserExecutableIdentity, browserruntime.BrowserAcceptanceCandidate, error, +) { + identities, err := browserruntime.DiscoverInstalledBrowsers() + if err != nil { + return browserruntime.BrowserExecutableIdentity{}, + browserruntime.BrowserAcceptanceCandidate{}, err + } + for _, identity := range identities { + if identity.Product != product { + continue + } + acceptance, acceptanceErr := browserruntime.BuildBrowserAcceptanceCandidate(identity) + if acceptanceErr == nil && acceptance.Decision == browserruntime.BrowserAcceptanceAccepted && + acceptance.ReviewEligible { + return identity, acceptance, nil + } + } + return browserruntime.BrowserExecutableIdentity{}, + browserruntime.BrowserAcceptanceCandidate{}, + fmt.Errorf("no accepted stable %s executable is available", product) +} diff --git a/internal/httpapi/browser_safe_web_readiness_test.go b/internal/httpapi/browser_safe_web_readiness_test.go new file mode 100644 index 00000000..8d0a000a --- /dev/null +++ b/internal/httpapi/browser_safe_web_readiness_test.go @@ -0,0 +1,18 @@ +package httpapi + +import ( + "net/http" + "testing" +) + +func TestBrowserSafeWebReadinessRejectsUnknownProduct(t *testing.T) { + fixture := newAPIFixture(t) + response := fixture.get(t, "/api/v1/browser/safe-web-readiness?product=firefox") + assertAPIError(t, response, http.StatusBadRequest, "INVALID_ARGUMENT") +} + +func TestBrowserSafeWebReadinessRejectsUnknownQueryParameter(t *testing.T) { + fixture := newAPIFixture(t) + response := fixture.get(t, "/api/v1/browser/safe-web-readiness?product=chrome&extra=1") + assertAPIError(t, response, http.StatusBadRequest, "INVALID_ARGUMENT") +} diff --git a/internal/httpapi/handlers.go b/internal/httpapi/handlers.go index cdcd38c8..1695f0e6 100644 --- a/internal/httpapi/handlers.go +++ b/internal/httpapi/handlers.go @@ -125,6 +125,8 @@ func (a *API) route(request *http.Request) (any, *Page, error) { return a.health(request) case "/api/v1/capabilities": return a.runtimeCapabilities(request) + case "/api/v1/browser/safe-web-readiness": + return a.browserSafeWebReadiness(request) case "/api/v1/operation-receipts": return a.operationReceiptHistory(request) } diff --git a/internal/httpapi/openapi.go b/internal/httpapi/openapi.go index 71afbed5..0df27fce 100644 --- a/internal/httpapi/openapi.go +++ b/internal/httpapi/openapi.go @@ -13,6 +13,7 @@ import ( "cyberagent-workbench/internal/application" "cyberagent-workbench/internal/approval" "cyberagent-workbench/internal/artifact" + "cyberagent-workbench/internal/browserruntime" "cyberagent-workbench/internal/credential" "cyberagent-workbench/internal/domain" "cyberagent-workbench/internal/events" @@ -302,6 +303,14 @@ func openAPIOperationSpecs() []openAPIOperationSpec { Summary: "Inspect process-local runtime capabilities", Tag: "System", Description: "Returns bounded enablement metadata and Run wake worker health without bearer tokens, owner or lease identities, private errors, runtime enable controls, or persistent-service authority.", DataType: reflect.TypeOf(RuntimeCapabilitiesView{})}, + {Path: "/api/v1/browser/safe-web-readiness", OperationID: "getBrowserSafeWebReadiness", + Summary: "Inspect Safe Web readiness", Tag: "Browser", + Description: "Returns a fail-closed readiness receipt over the latest production containment evidence and operator review for one accepted browser. It never starts a browser and never grants authority.", + Parameters: []openAPIParameter{{Name: "product", In: "query", + Description: "Accepted browser product", Required: false, + Schema: map[string]any{"type": "string", + "enum": []string{"chrome", "edge", "chromium"}}}}, + DataType: reflect.TypeOf(browserruntime.BrowserSafeWebReadiness{})}, {Path: "/api/v1/models", OperationID: "getModelAvailability", Summary: "Inspect redacted model availability", Tag: "Models", Description: "Returns deterministic Provider registration and route metadata without API keys, base URLs, environment variable names, network probes, or model calls.", @@ -1587,7 +1596,7 @@ var openAPIFieldEnums = map[string][]string{ "DockerSandboxCancellationView.reason_code": {domain.DockerSandboxReasonCancelled}, "EventView.version": {events.EnvelopeVersion}, "RunActivityView.version": {runactivity.ProtocolVersion}, - "RunActivityItemView.kind": {string(runactivity.KindHarnessStatus), string(runactivity.KindModelUpdate), string(runactivity.KindOperatorInput), string(runactivity.KindModelCall), string(runactivity.KindToolCall), string(runactivity.KindApproval), string(runactivity.KindFileChange), string(runactivity.KindPlan), string(runactivity.KindDependency)}, + "RunActivityItemView.kind": {string(runactivity.KindHarnessStatus), string(runactivity.KindModelUpdate), string(runactivity.KindOperatorInput), string(runactivity.KindModelCall), string(runactivity.KindToolCall), string(runactivity.KindApproval), string(runactivity.KindFileChange), string(runactivity.KindPlan), string(runactivity.KindDependency), string(runactivity.KindBrowser)}, "RunActivityItemView.source": {string(runactivity.SourceHarness), string(runactivity.SourceModel), string(runactivity.SourceOperator)}, "IndexView.api_version": {Version}, "HealthView.status": {"ok"}, @@ -1756,6 +1765,10 @@ var openAPIFieldEnums = map[string][]string{ "RunBrowserCDPPermissionView.required_gate": {string(domain.BrowserCDPPermissionGateRestricted), string(domain.BrowserCDPPermissionGateFullDebug)}, "RunBrowserCDPPermissionView.policy_version": {domain.RunBrowserCDPPermissionPolicyVersion}, "RunBrowserCDPPermissionControlRequestView.mode": {string(domain.RunBrowserCDPPermissionRestricted), string(domain.RunBrowserCDPPermissionFullDebug)}, + "BrowserSafeWebReadiness.protocol_version": {browserruntime.BrowserSafeWebReadinessProtocolVersion}, + "BrowserSafeWebReadiness.blocking_reason": {browserruntime.BrowserSafeWebBlockedEvidenceMissing, browserruntime.BrowserSafeWebBlockedEvidenceVersionMismatch, browserruntime.BrowserSafeWebBlockedExecutableMismatch, browserruntime.BrowserSafeWebBlockedAcceptanceMismatch, browserruntime.BrowserSafeWebBlockedPolicyVersionMismatch, browserruntime.BrowserSafeWebBlockedAdapterMismatch, browserruntime.BrowserSafeWebBlockedPlatformMismatch, browserruntime.BrowserSafeWebBlockedEvidenceNotPassed, browserruntime.BrowserSafeWebBlockedReviewMissing, browserruntime.BrowserSafeWebBlockedReviewBindingMismatch, browserruntime.BrowserSafeWebBlockedReviewNotAccepted, browserruntime.BrowserSafeWebBlockedEvidenceExpired}, + "BrowserSafeWebReadiness.adapter": {browserruntime.WindowsWFPBrowserContainmentAdapterName}, + "BrowserSafeWebReadiness.policy_version": {browserruntime.BrowserNetworkContainmentPolicyVersion}, "RunExecutionInteractionView.protocol_version": {domain.RunExecutionInteractionProtocolVersion}, "RunExecutionInteractionView.mode": {string(domain.RunExecutionInteractionPreview), string(domain.RunExecutionInteractionControlled), string(domain.RunExecutionInteractionDebug), string(domain.RunExecutionInteractionCyber)}, "RunExecutionInteractionView.surface": {string(domain.ExecutionSurfaceCode), string(domain.ExecutionSurfaceCyber)}, diff --git a/internal/httpapi/openapi_test.go b/internal/httpapi/openapi_test.go index c9e31f0a..26b058e2 100644 --- a/internal/httpapi/openapi_test.go +++ b/internal/httpapi/openapi_test.go @@ -965,7 +965,15 @@ func TestOpenAPIRoutesMatchAuthenticatedLiveHandlers(t *testing.T) { } else { response = fixture.get(t, requestPath) } - if response.Code != expectedStatus { + if spec.OperationID == "getBrowserSafeWebReadiness" { + // Browser availability is environment-dependent: a machine with an + // accepted browser returns 200 with a fail-closed readiness receipt, + // otherwise the route is live with 404 (no accepted browser). + if response.Code != http.StatusOK && response.Code != http.StatusNotFound { + t.Fatalf("documented route is not live: path=%s status=%d body=%s", + requestPath, response.Code, response.Body.String()) + } + } else if response.Code != expectedStatus { t.Fatalf("documented route is not live: path=%s status=%d body=%s", requestPath, response.Code, response.Body.String()) } diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 9eb74c20..c8495b89 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -22,6 +22,7 @@ import ( "cyberagent-workbench/internal/apperror" "cyberagent-workbench/internal/approval" "cyberagent-workbench/internal/artifact" + "cyberagent-workbench/internal/browserruntime" "cyberagent-workbench/internal/domain" "cyberagent-workbench/internal/events" "cyberagent-workbench/internal/fileedit" @@ -215,6 +216,11 @@ type Store interface { GetRunArtifactDescriptor(ctx context.Context, id string) (artifact.Descriptor, error) ListRunArtifacts(ctx context.Context, filter artifact.ListFilter) ([]artifact.Descriptor, error) + + LoadLatestBrowserNetworkEvidence(ctx context.Context, + executableIdentityFingerprint string) (browserruntime.BrowserNetworkContainmentEvidence, error) + LoadBrowserNetworkReview(ctx context.Context, + evidenceFingerprint string) (browserruntime.BrowserNetworkContainmentReview, error) } type Config struct { diff --git a/internal/runactivity/activity.go b/internal/runactivity/activity.go index 48f61c99..505c5516 100644 --- a/internal/runactivity/activity.go +++ b/internal/runactivity/activity.go @@ -44,6 +44,7 @@ const ( KindFileChange Kind = "file_change" KindPlan Kind = "plan" KindDependency Kind = "dependency" + KindBrowser Kind = "browser" ) type Item struct { @@ -256,6 +257,16 @@ func projectEvent(event events.Event) (Item, bool) { case events.DependencyLivelockDetectedEvent: base.Kind, base.Title, base.Status = KindDependency, "检测到依赖轮询循环", "blocked" base.Detail = dependencyStallDetail(event.PayloadJSON) + case events.BrowserLaunchAttemptPreparedEvent: + base.Kind, base.Title, base.Status = KindBrowser, "浏览器启动已准备", "pending" + case events.BrowserLaunchLeaseRecordedEvent: + base.Kind, base.Title, base.Status = KindBrowser, "浏览器启动租约已记录", "pending" + case events.BrowserLaunchReviewedEvent: + base.Kind, base.Title, base.Status = KindBrowser, "浏览器启动已复核", "completed" + case events.BrowserRuntimeCheckpointRecordedEvent: + base.Kind, base.Title, base.Status = KindBrowser, "浏览器运行时检查点已记录", "running" + case events.BrowserRuntimeReceiptRecordedEvent: + base.Kind, base.Title, base.Status = KindBrowser, "浏览器运行时收据已记录", "completed" case events.ChildTaskProposedEvent: base.Kind, base.Title, base.Status = KindPlan, "子任务提案已记录", "pending" base.Detail = childTaskDetail(event.PayloadJSON) diff --git a/internal/runactivity/activity_test.go b/internal/runactivity/activity_test.go index 95c2d027..2e34b1a8 100644 --- a/internal/runactivity/activity_test.go +++ b/internal/runactivity/activity_test.go @@ -404,3 +404,41 @@ func TestBuildProjectsDependencyWaitsWithoutRawOutput(t *testing.T) { t.Fatalf("livelock item is wrong: %#v", got.Items[3]) } } + +func TestBuildProjectsBrowserLifecycleAsGoObservedFacts(t *testing.T) { + now := time.Now().UTC() + source := []events.Event{ + event(1, events.BrowserLaunchAttemptPreparedEvent, `{}`, now), + event(2, events.BrowserLaunchLeaseRecordedEvent, `{}`, now), + event(3, events.BrowserLaunchReviewedEvent, `{}`, now), + event(4, events.BrowserRuntimeCheckpointRecordedEvent, `{}`, now), + event(5, events.BrowserRuntimeReceiptRecordedEvent, `{}`, now), + } + got, err := Build("run-1", source, false) + if err != nil { + t.Fatal(err) + } + if len(got.Items) != 5 { + t.Fatalf("unexpected projection: %#v", got.Items) + } + for _, item := range got.Items { + if item.Kind != KindBrowser || !item.Verifiable { + t.Fatalf("browser activity has wrong provenance: %#v", item) + } + } + if got.Items[0].Status != "pending" || got.Items[0].Title != "浏览器启动已准备" { + t.Fatalf("prepared item is wrong: %#v", got.Items[0]) + } + if got.Items[1].Status != "pending" || got.Items[1].Title != "浏览器启动租约已记录" { + t.Fatalf("lease item is wrong: %#v", got.Items[1]) + } + if got.Items[2].Status != "completed" || got.Items[2].Title != "浏览器启动已复核" { + t.Fatalf("reviewed item is wrong: %#v", got.Items[2]) + } + if got.Items[3].Status != "running" || got.Items[3].Title != "浏览器运行时检查点已记录" { + t.Fatalf("checkpoint item is wrong: %#v", got.Items[3]) + } + if got.Items[4].Status != "completed" || got.Items[4].Title != "浏览器运行时收据已记录" { + t.Fatalf("receipt item is wrong: %#v", got.Items[4]) + } +} diff --git a/internal/store/browser_launch.go b/internal/store/browser_launch.go index c8525070..14a485b6 100644 --- a/internal/store/browser_launch.go +++ b/internal/store/browser_launch.go @@ -39,24 +39,26 @@ func (s *SQLiteStore) PrepareBrowserLaunch(ctx context.Context, acceptance browserruntime.BrowserAcceptanceCandidate, ownership browserruntime.ProfileOwnershipPlan, operationKey string, leaseOwnerIdentity string, -) (BrowserLaunchPreparationRecord, error) { +) (browserruntime.BrowserLaunchAttempt, browserruntime.BrowserLaunchLease, bool, error) { if s == nil || s.db == nil { - return BrowserLaunchPreparationRecord{}, errors.New("sqlite store is not open") + return browserruntime.BrowserLaunchAttempt{}, browserruntime.BrowserLaunchLease{}, + false, errors.New("sqlite store is not open") } if err := session.Validate(); err != nil { - return BrowserLaunchPreparationRecord{}, err + return browserruntime.BrowserLaunchAttempt{}, browserruntime.BrowserLaunchLease{}, false, err } if err := browserruntime.ValidateProfileOwnershipPlan(ownership, session, identity); err != nil { - return BrowserLaunchPreparationRecord{}, err + return browserruntime.BrowserLaunchAttempt{}, browserruntime.BrowserLaunchLease{}, false, err } if err := browserruntime.ValidateBrowserAcceptanceCandidate(acceptance, identity); err != nil { - return BrowserLaunchPreparationRecord{}, err + return browserruntime.BrowserLaunchAttempt{}, browserruntime.BrowserLaunchLease{}, false, err } operationKey = strings.TrimSpace(operationKey) leaseOwnerIdentity = strings.TrimSpace(leaseOwnerIdentity) if !validBrowserLaunchStoreIdentity(operationKey) || !validBrowserLaunchStoreIdentity(leaseOwnerIdentity) { - return BrowserLaunchPreparationRecord{}, errors.New("browser launch preparation identity is invalid") + return browserruntime.BrowserLaunchAttempt{}, browserruntime.BrowserLaunchLease{}, + false, errors.New("browser launch preparation identity is invalid") } keyDigest := browserLaunchStoreDigest("browser-launch-preparation-operation.v1", operationKey) ownerDigest := browserLaunchStoreDigest("browser-launch-lease-owner-request.v1", @@ -67,77 +69,80 @@ func (s *SQLiteStore) PrepareBrowserLaunch(ctx context.Context, tx, err := s.db.BeginTx(ctx, &sql.TxOptions{}) if err != nil { - return BrowserLaunchPreparationRecord{}, err + return browserruntime.BrowserLaunchAttempt{}, browserruntime.BrowserLaunchLease{}, false, err } defer func() { _ = tx.Rollback() }() if record, found, loadErr := loadBrowserLaunchPreparationOperationTx(ctx, tx, keyDigest); loadErr != nil { - return BrowserLaunchPreparationRecord{}, loadErr + return browserruntime.BrowserLaunchAttempt{}, browserruntime.BrowserLaunchLease{}, false, loadErr } else if found { if record.requestFingerprint != requestFingerprint { - return BrowserLaunchPreparationRecord{}, errors.New("browser launch preparation operation key was reused with another request") + return browserruntime.BrowserLaunchAttempt{}, browserruntime.BrowserLaunchLease{}, + false, errors.New("browser launch preparation operation key was reused with another request") } replayed, loadErr := loadBrowserLaunchPreparationTx(ctx, tx, record.attemptID, record.leaseID) if loadErr != nil { - return BrowserLaunchPreparationRecord{}, loadErr + return browserruntime.BrowserLaunchAttempt{}, browserruntime.BrowserLaunchLease{}, false, loadErr } if err := browserruntime.ValidateBrowserLaunchAttempt(replayed.Attempt, session, identity, acceptance, ownership); err != nil { - return BrowserLaunchPreparationRecord{}, err + return browserruntime.BrowserLaunchAttempt{}, browserruntime.BrowserLaunchLease{}, false, err } - replayed.Replayed = true - return replayed, nil + return replayed.Attempt, replayed.Lease, true, nil } missionID, err := validateBrowserLaunchRunBindingTx(ctx, tx, session) if err != nil { - return BrowserLaunchPreparationRecord{}, err + return browserruntime.BrowserLaunchAttempt{}, browserruntime.BrowserLaunchLease{}, false, err } now := time.Now().UTC() attempt, err := browserruntime.BuildBrowserLaunchAttempt(session, identity, acceptance, ownership, idgen.New("browser_attempt"), now) if err != nil { - return BrowserLaunchPreparationRecord{}, err + return browserruntime.BrowserLaunchAttempt{}, browserruntime.BrowserLaunchLease{}, false, err } lease, err := browserruntime.BuildBrowserLaunchLease(attempt, idgen.New("browser_lease"), leaseOwnerIdentity, now, 30*time.Second) if err != nil { - return BrowserLaunchPreparationRecord{}, err + return browserruntime.BrowserLaunchAttempt{}, browserruntime.BrowserLaunchLease{}, false, err } attemptJSON, err := json.Marshal(attempt) if err != nil { - return BrowserLaunchPreparationRecord{}, err + return browserruntime.BrowserLaunchAttempt{}, browserruntime.BrowserLaunchLease{}, false, err } leaseJSON, err := json.Marshal(lease) if err != nil { - return BrowserLaunchPreparationRecord{}, err + return browserruntime.BrowserLaunchAttempt{}, browserruntime.BrowserLaunchLease{}, false, err } if _, err := tx.ExecContext(ctx, `INSERT INTO browser_launch_attempts (id, run_id, mission_id, workspace_id, session_id, fingerprint, generation, payload_json, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, attempt.ID, attempt.RunID, missionID, attempt.WorkspaceID, attempt.SessionID, attempt.Fingerprint, attempt.Generation, string(attemptJSON), ts(attempt.CreatedAt)); err != nil { - return BrowserLaunchPreparationRecord{}, fmt.Errorf("insert browser launch attempt: %w", err) + return browserruntime.BrowserLaunchAttempt{}, browserruntime.BrowserLaunchLease{}, + false, fmt.Errorf("insert browser launch attempt: %w", err) } if _, err := tx.ExecContext(ctx, `INSERT INTO browser_launch_leases (id, attempt_id, fingerprint, generation, status, payload_json, acquired_at, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, lease.ID, lease.AttemptID, lease.Fingerprint, lease.Generation, lease.Status, string(leaseJSON), ts(lease.AcquiredAt), ts(lease.ExpiresAt)); err != nil { - return BrowserLaunchPreparationRecord{}, fmt.Errorf("insert browser launch lease: %w", err) + return browserruntime.BrowserLaunchAttempt{}, browserruntime.BrowserLaunchLease{}, + false, fmt.Errorf("insert browser launch lease: %w", err) } if _, err := tx.ExecContext(ctx, `INSERT INTO browser_launch_preparation_operations (key_digest, request_fingerprint, attempt_id, lease_id, created_at) VALUES (?, ?, ?, ?, ?)`, keyDigest, requestFingerprint, attempt.ID, lease.ID, ts(attempt.CreatedAt)); err != nil { - return BrowserLaunchPreparationRecord{}, fmt.Errorf("insert browser launch preparation operation: %w", err) + return browserruntime.BrowserLaunchAttempt{}, browserruntime.BrowserLaunchLease{}, + false, fmt.Errorf("insert browser launch preparation operation: %w", err) } if err := appendBrowserLaunchPreparedEventsTx(ctx, tx, missionID, attempt, lease); err != nil { - return BrowserLaunchPreparationRecord{}, err + return browserruntime.BrowserLaunchAttempt{}, browserruntime.BrowserLaunchLease{}, false, err } if err := tx.Commit(); err != nil { - return BrowserLaunchPreparationRecord{}, err + return browserruntime.BrowserLaunchAttempt{}, browserruntime.BrowserLaunchLease{}, false, err } - return BrowserLaunchPreparationRecord{Attempt: attempt, Lease: lease, MissionID: missionID}, nil + return attempt, lease, false, nil } func (s *SQLiteStore) RecordBrowserLaunchReview(ctx context.Context, @@ -147,22 +152,22 @@ func (s *SQLiteStore) RecordBrowserLaunchReview(ctx context.Context, attempt browserruntime.BrowserLaunchAttempt, lease browserruntime.BrowserLaunchLease, decision browserruntime.BrowserLaunchReviewDecision, operationKey string, reviewerIdentity string, -) (BrowserLaunchReviewRecord, error) { +) (browserruntime.BrowserLaunchReview, bool, error) { if s == nil || s.db == nil { - return BrowserLaunchReviewRecord{}, errors.New("sqlite store is not open") + return browserruntime.BrowserLaunchReview{}, false, errors.New("sqlite store is not open") } if err := browserruntime.ValidateBrowserLaunchAttempt(attempt, session, identity, acceptance, ownership); err != nil { - return BrowserLaunchReviewRecord{}, err + return browserruntime.BrowserLaunchReview{}, false, err } if err := browserruntime.ValidateBrowserLaunchLease(lease, attempt); err != nil { - return BrowserLaunchReviewRecord{}, err + return browserruntime.BrowserLaunchReview{}, false, err } operationKey = strings.TrimSpace(operationKey) reviewerIdentity = strings.TrimSpace(reviewerIdentity) if !validBrowserLaunchStoreIdentity(operationKey) || !validBrowserLaunchStoreIdentity(reviewerIdentity) { - return BrowserLaunchReviewRecord{}, errors.New("browser launch review identity is invalid") + return browserruntime.BrowserLaunchReview{}, false, errors.New("browser launch review identity is invalid") } keyDigest := browserLaunchStoreDigest("browser-launch-review-operation.v1", operationKey) reviewerDigest := browserLaunchStoreDigest("browser-launch-reviewer-request.v1", @@ -172,71 +177,71 @@ func (s *SQLiteStore) RecordBrowserLaunchReview(ctx context.Context, tx, err := s.db.BeginTx(ctx, &sql.TxOptions{}) if err != nil { - return BrowserLaunchReviewRecord{}, err + return browserruntime.BrowserLaunchReview{}, false, err } defer func() { _ = tx.Rollback() }() if record, found, loadErr := loadBrowserLaunchReviewOperationTx(ctx, tx, keyDigest); loadErr != nil { - return BrowserLaunchReviewRecord{}, loadErr + return browserruntime.BrowserLaunchReview{}, false, loadErr } else if found { if record.requestFingerprint != requestFingerprint { - return BrowserLaunchReviewRecord{}, errors.New("browser launch review operation key was reused with another request") + return browserruntime.BrowserLaunchReview{}, false, errors.New("browser launch review operation key was reused with another request") } replayed, loadErr := loadBrowserLaunchReviewTx(ctx, tx, record.reviewID) if loadErr != nil { - return BrowserLaunchReviewRecord{}, loadErr + return browserruntime.BrowserLaunchReview{}, false, loadErr } if err := browserruntime.ValidateBrowserLaunchReview(replayed.Review, session, identity, acceptance, ownership, attempt, lease); err != nil { - return BrowserLaunchReviewRecord{}, err + return browserruntime.BrowserLaunchReview{}, false, err } - replayed.Replayed = true - return replayed, nil + + return replayed.Review, true, nil } missionID, err := validateBrowserLaunchRunBindingTx(ctx, tx, session) if err != nil { - return BrowserLaunchReviewRecord{}, err + return browserruntime.BrowserLaunchReview{}, false, err } stored, err := loadBrowserLaunchPreparationTx(ctx, tx, attempt.ID, lease.ID) if err != nil { - return BrowserLaunchReviewRecord{}, err + return browserruntime.BrowserLaunchReview{}, false, err } if stored.Attempt.Fingerprint != attempt.Fingerprint || stored.Lease.Fingerprint != lease.Fingerprint || stored.MissionID != missionID { - return BrowserLaunchReviewRecord{}, errors.New("browser launch review preparation binding changed") + return browserruntime.BrowserLaunchReview{}, false, errors.New("browser launch review preparation binding changed") } var existing int if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM browser_launch_reviews WHERE attempt_id = ?`, attempt.ID).Scan(&existing); err != nil { - return BrowserLaunchReviewRecord{}, err + return browserruntime.BrowserLaunchReview{}, false, err } if existing != 0 { - return BrowserLaunchReviewRecord{}, errors.New("browser launch attempt already has an immutable review") + return browserruntime.BrowserLaunchReview{}, false, errors.New("browser launch attempt already has an immutable review") } var auditParent string err = tx.QueryRowContext(ctx, `SELECT fingerprint FROM browser_launch_reviews WHERE run_id = ? ORDER BY event_sequence DESC LIMIT 1`, session.RunID).Scan(&auditParent) if err != nil && !errors.Is(err, sql.ErrNoRows) { - return BrowserLaunchReviewRecord{}, err + return browserruntime.BrowserLaunchReview{}, false, err } now := time.Now().UTC() review, err := browserruntime.BuildBrowserLaunchReview(session, identity, acceptance, ownership, attempt, lease, idgen.New("browser_review"), decision, reviewerIdentity, operationKey, auditParent, now) if err != nil { - return BrowserLaunchReviewRecord{}, err + return browserruntime.BrowserLaunchReview{}, false, err } event, err := newBrowserLaunchReviewEvent(session.RunID, missionID, review) if err != nil { - return BrowserLaunchReviewRecord{}, err + return browserruntime.BrowserLaunchReview{}, false, err } event.CreatedAt = review.CreatedAt event, err = insertRunEventTx(ctx, tx, event) if err != nil { - return BrowserLaunchReviewRecord{}, err + return browserruntime.BrowserLaunchReview{}, false, err } reviewJSON, err := json.Marshal(review) if err != nil { - return BrowserLaunchReviewRecord{}, err + return browserruntime.BrowserLaunchReview{}, false, err } if _, err := tx.ExecContext(ctx, `INSERT INTO browser_launch_reviews (id, attempt_id, lease_id, run_id, mission_id, workspace_id, decision, @@ -245,19 +250,17 @@ func (s *SQLiteStore) RecordBrowserLaunchReview(ctx context.Context, review.ID, attempt.ID, lease.ID, session.RunID, missionID, session.WorkspaceID, review.Decision, review.Fingerprint, review.AuditParentFingerprint, review.ReviewerSHA256, event.Sequence, string(reviewJSON), ts(review.CreatedAt)); err != nil { - return BrowserLaunchReviewRecord{}, fmt.Errorf("insert browser launch review: %w", err) + return browserruntime.BrowserLaunchReview{}, false, fmt.Errorf("insert browser launch review: %w", err) } if _, err := tx.ExecContext(ctx, `INSERT INTO browser_launch_review_operations (key_digest, request_fingerprint, review_id, created_at) VALUES (?, ?, ?, ?)`, keyDigest, requestFingerprint, review.ID, ts(review.CreatedAt)); err != nil { - return BrowserLaunchReviewRecord{}, fmt.Errorf("insert browser launch review operation: %w", err) + return browserruntime.BrowserLaunchReview{}, false, fmt.Errorf("insert browser launch review operation: %w", err) } if err := tx.Commit(); err != nil { - return BrowserLaunchReviewRecord{}, err + return browserruntime.BrowserLaunchReview{}, false, err } - return BrowserLaunchReviewRecord{ - Review: review, MissionID: missionID, EventSequence: event.Sequence, - }, nil + return review, false, nil } func validateBrowserLaunchRunBindingTx(ctx context.Context, tx *sql.Tx, diff --git a/internal/store/browser_launch_test.go b/internal/store/browser_launch_test.go index 2d863347..3c6c95c9 100644 --- a/internal/store/browser_launch_test.go +++ b/internal/store/browser_launch_test.go @@ -52,66 +52,66 @@ func TestBrowserLaunchPreparationAndReviewAreDurableImmutableAndNonAuthorizing(t t.Cleanup(func() { _ = state.Close() }) sessionPlan, identity, acceptance, ownership := browserLaunchStoreFixture(t, state) - prepared, err := state.PrepareBrowserLaunch(ctx, sessionPlan, identity, acceptance, - ownership, "browser-launch-preparation-operation-001", "browser-worker-001") + attempt, lease, preparedReplayed, err := state.PrepareBrowserLaunch(ctx, sessionPlan, + identity, acceptance, ownership, "browser-launch-preparation-operation-001", + "browser-worker-001") if err != nil { t.Fatal(err) } - if prepared.Replayed || prepared.Attempt.ProcessStartAuthorized || - prepared.Lease.ProcessExecutionAuthorized || !prepared.Attempt.StartBlocked || - !prepared.Lease.StartBlocked { - t.Fatalf("durable browser launch preparation widened authority: %#v", prepared) + if preparedReplayed || attempt.ProcessStartAuthorized || + lease.ProcessExecutionAuthorized || !attempt.StartBlocked || + !lease.StartBlocked { + t.Fatalf("durable browser launch preparation widened authority: attempt=%#v lease=%#v", + attempt, lease) } - replayed, err := state.PrepareBrowserLaunch(ctx, sessionPlan, identity, acceptance, - ownership, "browser-launch-preparation-operation-001", "browser-worker-001") - if err != nil || !replayed.Replayed || - replayed.Attempt.ID != prepared.Attempt.ID || replayed.Lease.ID != prepared.Lease.ID { - t.Fatalf("browser launch preparation replay diverged: %#v err=%v", replayed, err) + replayAttempt, replayLease, replayReplayed, err := state.PrepareBrowserLaunch(ctx, + sessionPlan, identity, acceptance, ownership, + "browser-launch-preparation-operation-001", "browser-worker-001") + if err != nil || !replayReplayed || + replayAttempt.ID != attempt.ID || replayLease.ID != lease.ID { + t.Fatalf("browser launch preparation replay diverged: %#v err=%v", replayAttempt, err) } - if _, err := state.PrepareBrowserLaunch(ctx, sessionPlan, identity, acceptance, + if _, _, _, err := state.PrepareBrowserLaunch(ctx, sessionPlan, identity, acceptance, ownership, "browser-launch-preparation-operation-001", "another-worker"); err == nil { t.Fatal("browser launch preparation key accepted changed ownership") } - reviewed, err := state.RecordBrowserLaunchReview(ctx, sessionPlan, identity, acceptance, - ownership, prepared.Attempt, prepared.Lease, + review, reviewedReplayed, err := state.RecordBrowserLaunchReview(ctx, sessionPlan, + identity, acceptance, ownership, attempt, lease, browserruntime.BrowserLaunchReviewAcceptCandidate, "browser-launch-review-operation-001", "independent-operator") if err != nil { t.Fatal(err) } - if reviewed.Replayed || reviewed.EventSequence <= 0 || - !reviewed.Review.AcceptedForFutureAdapter || reviewed.Review.StartAuthorized || - reviewed.Review.ProcessExecutionAuthorized || reviewed.Review.NetworkAuthorized || - reviewed.Review.ProcessTerminationAuthorized || - reviewed.Review.FilesystemCleanupAuthorized || - reviewed.Review.ArtifactCommitAuthorized { - t.Fatalf("durable browser launch review widened authority: %#v", reviewed) + if reviewedReplayed || !review.AcceptedForFutureAdapter || review.StartAuthorized || + review.ProcessExecutionAuthorized || review.NetworkAuthorized || + review.ProcessTerminationAuthorized || review.FilesystemCleanupAuthorized || + review.ArtifactCommitAuthorized { + t.Fatalf("durable browser launch review widened authority: %#v", review) } - reviewReplay, err := state.RecordBrowserLaunchReview(ctx, sessionPlan, identity, acceptance, - ownership, prepared.Attempt, prepared.Lease, + reviewReplayReview, reviewReplayReplayed, err := state.RecordBrowserLaunchReview(ctx, + sessionPlan, identity, acceptance, ownership, attempt, lease, browserruntime.BrowserLaunchReviewAcceptCandidate, "browser-launch-review-operation-001", "independent-operator") - if err != nil || !reviewReplay.Replayed || - reviewReplay.Review.ID != reviewed.Review.ID { - t.Fatalf("browser launch review replay diverged: %#v err=%v", reviewReplay, err) + if err != nil || !reviewReplayReplayed || + reviewReplayReview.ID != review.ID { + t.Fatalf("browser launch review replay diverged: %#v err=%v", reviewReplayReview, err) } - if _, err := state.RecordBrowserLaunchReview(ctx, sessionPlan, identity, acceptance, - ownership, prepared.Attempt, prepared.Lease, - browserruntime.BrowserLaunchReviewRejectCandidate, + if _, _, err := state.RecordBrowserLaunchReview(ctx, sessionPlan, identity, acceptance, + ownership, attempt, lease, browserruntime.BrowserLaunchReviewRejectCandidate, "browser-launch-review-operation-001", "independent-operator"); err == nil { t.Fatal("browser launch review key accepted a changed decision") } if _, err := state.db.ExecContext(ctx, `UPDATE browser_launch_attempts - SET generation = 2 WHERE id = ?`, prepared.Attempt.ID); err == nil { + SET generation = 2 WHERE id = ?`, attempt.ID); err == nil { t.Fatal("browser launch attempt update unexpectedly passed") } if _, err := state.db.ExecContext(ctx, `DELETE FROM browser_launch_leases - WHERE id = ?`, prepared.Lease.ID); err == nil { + WHERE id = ?`, lease.ID); err == nil { t.Fatal("browser launch lease delete unexpectedly passed") } if _, err := state.db.ExecContext(ctx, `UPDATE browser_launch_reviews - SET decision = 'reject_candidate' WHERE id = ?`, reviewed.Review.ID); err == nil { + SET decision = 'reject_candidate' WHERE id = ?`, review.ID); err == nil { t.Fatal("browser launch review update unexpectedly passed") } @@ -152,22 +152,20 @@ func TestBrowserLaunchReviewRequiresIndependentLiveLease(t *testing.T) { } t.Cleanup(func() { _ = state.Close() }) sessionPlan, identity, acceptance, ownership := browserLaunchStoreFixture(t, state) - prepared, err := state.PrepareBrowserLaunch(ctx, sessionPlan, identity, acceptance, - ownership, "browser-launch-preparation-operation-002", "same-operator") + attempt, lease, _, err := state.PrepareBrowserLaunch(ctx, sessionPlan, identity, + acceptance, ownership, "browser-launch-preparation-operation-002", "same-operator") if err != nil { t.Fatal(err) } - if _, err := state.RecordBrowserLaunchReview(ctx, sessionPlan, identity, acceptance, - ownership, prepared.Attempt, prepared.Lease, - browserruntime.BrowserLaunchReviewAcceptCandidate, + if _, _, err := state.RecordBrowserLaunchReview(ctx, sessionPlan, identity, acceptance, + ownership, attempt, lease, browserruntime.BrowserLaunchReviewAcceptCandidate, "browser-launch-review-operation-002", "same-operator"); err == nil { t.Fatal("browser worker unexpectedly reviewed its own attempt") } - tampered := prepared.Lease + tampered := lease tampered.ProcessExecutionAuthorized = true - if _, err := state.RecordBrowserLaunchReview(ctx, sessionPlan, identity, acceptance, - ownership, prepared.Attempt, tampered, - browserruntime.BrowserLaunchReviewAcceptCandidate, + if _, _, err := state.RecordBrowserLaunchReview(ctx, sessionPlan, identity, acceptance, + ownership, attempt, tampered, browserruntime.BrowserLaunchReviewAcceptCandidate, "browser-launch-review-operation-003", "independent-operator"); err == nil { t.Fatal("authorizing browser launch lease unexpectedly reached review") } diff --git a/internal/store/browser_network_readiness.go b/internal/store/browser_network_readiness.go new file mode 100644 index 00000000..4003989d --- /dev/null +++ b/internal/store/browser_network_readiness.go @@ -0,0 +1,296 @@ +package store + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "strings" + + "cyberagent-workbench/internal/browserruntime" +) + +type BrowserNetworkEvidenceRecord struct { + Evidence browserruntime.BrowserNetworkContainmentEvidence + Replayed bool +} + +type BrowserNetworkReviewRecord struct { + Review browserruntime.BrowserNetworkContainmentReview + Replayed bool +} + +// RecordBrowserNetworkEvidence persists one immutable production containment +// evidence payload keyed by an idempotent operation key. It never runs a probe +// and never authorizes a launch. +func (s *SQLiteStore) RecordBrowserNetworkEvidence(ctx context.Context, + evidence browserruntime.BrowserNetworkContainmentEvidence, operationKey string, +) (BrowserNetworkEvidenceRecord, error) { + if s == nil || s.db == nil { + return BrowserNetworkEvidenceRecord{}, errors.New("sqlite store is not open") + } + if err := validateStoredBrowserNetworkEvidence(evidence); err != nil { + return BrowserNetworkEvidenceRecord{}, err + } + operationKey = strings.TrimSpace(operationKey) + if !validBrowserLaunchStoreIdentity(operationKey) { + return BrowserNetworkEvidenceRecord{}, errors.New("browser network evidence operation key is invalid") + } + keyDigest := browserLaunchStoreDigest("browser-network-evidence-operation.v1", operationKey) + requestFingerprint := browserLaunchStoreDigest("browser-network-evidence-request.v1", + evidence.Fingerprint) + + tx, err := s.db.BeginTx(ctx, &sql.TxOptions{}) + if err != nil { + return BrowserNetworkEvidenceRecord{}, err + } + defer func() { _ = tx.Rollback() }() + var existingEvidenceID string + var existingFingerprint string + err = tx.QueryRowContext(ctx, `SELECT evidence_id, request_fingerprint + FROM browser_network_evidence_operations WHERE key_digest = ?`, keyDigest). + Scan(&existingEvidenceID, &existingFingerprint) + if err == nil { + if existingFingerprint != requestFingerprint { + return BrowserNetworkEvidenceRecord{}, errors.New("browser network evidence operation key was reused with another payload") + } + replayed, loadErr := loadBrowserNetworkEvidenceByIDTx(ctx, tx, existingEvidenceID) + if loadErr != nil { + return BrowserNetworkEvidenceRecord{}, loadErr + } + replayed.Replayed = true + return replayed, nil + } + if !errors.Is(err, sql.ErrNoRows) { + return BrowserNetworkEvidenceRecord{}, err + } + evidenceJSON, err := json.Marshal(evidence) + if err != nil { + return BrowserNetworkEvidenceRecord{}, err + } + passed := 0 + if evidence.Passed { + passed = 1 + } + if _, err := tx.ExecContext(ctx, `INSERT INTO browser_network_evidences + (id, fingerprint, executable_identity_fingerprint, acceptance_fingerprint, + adapter, policy_version, collector_identity, passed, payload_json, + completed_at, expires_at, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + evidence.ID, evidence.Fingerprint, evidence.ExecutableIdentityFingerprint, + evidence.AcceptanceFingerprint, evidence.Adapter, evidence.PolicyVersion, + evidence.CollectorIdentity, passed, string(evidenceJSON), + ts(evidence.CompletedAt), ts(evidence.ExpiresAt), ts(evidence.CompletedAt)); err != nil { + return BrowserNetworkEvidenceRecord{}, fmt.Errorf("insert browser network evidence: %w", err) + } + if _, err := tx.ExecContext(ctx, `INSERT INTO browser_network_evidence_operations + (key_digest, request_fingerprint, evidence_id, created_at) VALUES (?, ?, ?, ?)`, + keyDigest, requestFingerprint, evidence.ID, ts(evidence.CompletedAt)); err != nil { + return BrowserNetworkEvidenceRecord{}, fmt.Errorf("insert browser network evidence operation: %w", err) + } + if err := tx.Commit(); err != nil { + return BrowserNetworkEvidenceRecord{}, err + } + return BrowserNetworkEvidenceRecord{Evidence: evidence}, nil +} + +// RecordBrowserNetworkReview persists one immutable operator review of a stored +// evidence payload keyed by an idempotent operation key. +func (s *SQLiteStore) RecordBrowserNetworkReview(ctx context.Context, + review browserruntime.BrowserNetworkContainmentReview, operationKey string, +) (BrowserNetworkReviewRecord, error) { + if s == nil || s.db == nil { + return BrowserNetworkReviewRecord{}, errors.New("sqlite store is not open") + } + if err := validateStoredBrowserNetworkReview(review); err != nil { + return BrowserNetworkReviewRecord{}, err + } + operationKey = strings.TrimSpace(operationKey) + if !validBrowserLaunchStoreIdentity(operationKey) { + return BrowserNetworkReviewRecord{}, errors.New("browser network review operation key is invalid") + } + keyDigest := browserLaunchStoreDigest("browser-network-review-operation.v1", operationKey) + requestFingerprint := browserLaunchStoreDigest("browser-network-review-request.v1", + review.Fingerprint) + + tx, err := s.db.BeginTx(ctx, &sql.TxOptions{}) + if err != nil { + return BrowserNetworkReviewRecord{}, err + } + defer func() { _ = tx.Rollback() }() + var existingReviewID string + var existingFingerprint string + err = tx.QueryRowContext(ctx, `SELECT review_id, request_fingerprint + FROM browser_network_review_operations WHERE key_digest = ?`, keyDigest). + Scan(&existingReviewID, &existingFingerprint) + if err == nil { + if existingFingerprint != requestFingerprint { + return BrowserNetworkReviewRecord{}, errors.New("browser network review operation key was reused with another payload") + } + replayed, loadErr := loadBrowserNetworkReviewByIDTx(ctx, tx, existingReviewID) + if loadErr != nil { + return BrowserNetworkReviewRecord{}, loadErr + } + replayed.Replayed = true + return replayed, nil + } + if !errors.Is(err, sql.ErrNoRows) { + return BrowserNetworkReviewRecord{}, err + } + var evidenceExists int + if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM browser_network_evidences + WHERE fingerprint = ?`, review.EvidenceFingerprint).Scan(&evidenceExists); err != nil { + return BrowserNetworkReviewRecord{}, err + } + if evidenceExists == 0 { + return BrowserNetworkReviewRecord{}, errors.New("browser network review references an unknown evidence") + } + reviewJSON, err := json.Marshal(review) + if err != nil { + return BrowserNetworkReviewRecord{}, err + } + accepted := 0 + if review.Accepted { + accepted = 1 + } + if _, err := tx.ExecContext(ctx, `INSERT INTO browser_network_reviews + (id, fingerprint, evidence_fingerprint, reviewer_identity, accepted, + reason_code, payload_json, reviewed_at, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + review.ID, review.Fingerprint, review.EvidenceFingerprint, review.ReviewerIdentity, + accepted, review.ReasonCode, string(reviewJSON), ts(review.ReviewedAt), + ts(review.ReviewedAt)); err != nil { + return BrowserNetworkReviewRecord{}, fmt.Errorf("insert browser network review: %w", err) + } + if _, err := tx.ExecContext(ctx, `INSERT INTO browser_network_review_operations + (key_digest, request_fingerprint, review_id, created_at) VALUES (?, ?, ?, ?)`, + keyDigest, requestFingerprint, review.ID, ts(review.ReviewedAt)); err != nil { + return BrowserNetworkReviewRecord{}, fmt.Errorf("insert browser network review operation: %w", err) + } + if err := tx.Commit(); err != nil { + return BrowserNetworkReviewRecord{}, err + } + return BrowserNetworkReviewRecord{Review: review}, nil +} + +// LoadLatestBrowserNetworkEvidence returns the most recent evidence recorded for +// an exact executable identity fingerprint, or sql.ErrNoRows if none exists. +func (s *SQLiteStore) LoadLatestBrowserNetworkEvidence(ctx context.Context, + executableIdentityFingerprint string, +) (browserruntime.BrowserNetworkContainmentEvidence, error) { + if s == nil || s.db == nil { + return browserruntime.BrowserNetworkContainmentEvidence{}, errors.New("sqlite store is not open") + } + executableIdentityFingerprint = strings.TrimSpace(executableIdentityFingerprint) + if len(executableIdentityFingerprint) != 64 { + return browserruntime.BrowserNetworkContainmentEvidence{}, errors.New("browser network evidence identity fingerprint is invalid") + } + var payload string + err := s.db.QueryRowContext(ctx, `SELECT payload_json FROM browser_network_evidences + WHERE executable_identity_fingerprint = ? + ORDER BY completed_at DESC, id DESC LIMIT 1`, executableIdentityFingerprint).Scan(&payload) + if err != nil { + return browserruntime.BrowserNetworkContainmentEvidence{}, err + } + var evidence browserruntime.BrowserNetworkContainmentEvidence + if err := json.Unmarshal([]byte(payload), &evidence); err != nil { + return browserruntime.BrowserNetworkContainmentEvidence{}, err + } + if err := validateStoredBrowserNetworkEvidence(evidence); err != nil { + return browserruntime.BrowserNetworkContainmentEvidence{}, err + } + return evidence, nil +} + +// LoadBrowserNetworkReview returns the review recorded for an exact evidence +// fingerprint, or sql.ErrNoRows if none exists. +func (s *SQLiteStore) LoadBrowserNetworkReview(ctx context.Context, + evidenceFingerprint string, +) (browserruntime.BrowserNetworkContainmentReview, error) { + if s == nil || s.db == nil { + return browserruntime.BrowserNetworkContainmentReview{}, errors.New("sqlite store is not open") + } + evidenceFingerprint = strings.TrimSpace(evidenceFingerprint) + if len(evidenceFingerprint) != 64 { + return browserruntime.BrowserNetworkContainmentReview{}, errors.New("browser network review evidence fingerprint is invalid") + } + var payload string + err := s.db.QueryRowContext(ctx, `SELECT payload_json FROM browser_network_reviews + WHERE evidence_fingerprint = ? ORDER BY reviewed_at DESC, id DESC LIMIT 1`, + evidenceFingerprint).Scan(&payload) + if err != nil { + return browserruntime.BrowserNetworkContainmentReview{}, err + } + var review browserruntime.BrowserNetworkContainmentReview + if err := json.Unmarshal([]byte(payload), &review); err != nil { + return browserruntime.BrowserNetworkContainmentReview{}, err + } + if err := validateStoredBrowserNetworkReview(review); err != nil { + return browserruntime.BrowserNetworkContainmentReview{}, err + } + return review, nil +} + +func validateStoredBrowserNetworkEvidence(evidence browserruntime.BrowserNetworkContainmentEvidence) error { + if evidence.ProtocolVersion != browserruntime.BrowserNetworkContainmentEvidenceProtocolVersion || + evidence.Adapter != browserruntime.WindowsWFPBrowserContainmentAdapterName || + evidence.PolicyVersion != browserruntime.BrowserNetworkContainmentPolicyVersion || + evidence.ID == "" || len(evidence.Fingerprint) != 64 || + evidence.ExecutableIdentityFingerprint == "" || evidence.AcceptanceFingerprint == "" || + evidence.CollectorIdentity == "" || evidence.StartedAt.IsZero() || + evidence.CompletedAt.IsZero() || !evidence.CompletedAt.After(evidence.StartedAt) || + evidence.ExpiresAt.IsZero() || !evidence.ExpiresAt.After(evidence.CompletedAt) { + return errors.New("browser network evidence lost an immutable production boundary") + } + return nil +} + +func validateStoredBrowserNetworkReview(review browserruntime.BrowserNetworkContainmentReview) error { + if review.ProtocolVersion != browserruntime.BrowserNetworkContainmentReviewProtocolVersion || + review.ID == "" || len(review.Fingerprint) != 64 || + review.EvidenceFingerprint == "" || review.ReviewerIdentity == "" || + review.ReviewedAt.IsZero() { + return errors.New("browser network review lost an immutable operator boundary") + } + expectedReason := "operator_rejected" + if review.Accepted { + expectedReason = "production_probe_confirmed" + } + if review.ReasonCode != expectedReason { + return errors.New("browser network review acceptance and reason disagree") + } + return nil +} + +func loadBrowserNetworkEvidenceByIDTx(ctx context.Context, tx *sql.Tx, id string) (BrowserNetworkEvidenceRecord, error) { + var payload string + if err := tx.QueryRowContext(ctx, `SELECT payload_json FROM browser_network_evidences + WHERE id = ?`, id).Scan(&payload); err != nil { + return BrowserNetworkEvidenceRecord{}, err + } + var evidence browserruntime.BrowserNetworkContainmentEvidence + if err := json.Unmarshal([]byte(payload), &evidence); err != nil { + return BrowserNetworkEvidenceRecord{}, err + } + if err := validateStoredBrowserNetworkEvidence(evidence); err != nil { + return BrowserNetworkEvidenceRecord{}, err + } + return BrowserNetworkEvidenceRecord{Evidence: evidence}, nil +} + +func loadBrowserNetworkReviewByIDTx(ctx context.Context, tx *sql.Tx, id string) (BrowserNetworkReviewRecord, error) { + var payload string + if err := tx.QueryRowContext(ctx, `SELECT payload_json FROM browser_network_reviews + WHERE id = ?`, id).Scan(&payload); err != nil { + return BrowserNetworkReviewRecord{}, err + } + var review browserruntime.BrowserNetworkContainmentReview + if err := json.Unmarshal([]byte(payload), &review); err != nil { + return BrowserNetworkReviewRecord{}, err + } + if err := validateStoredBrowserNetworkReview(review); err != nil { + return BrowserNetworkReviewRecord{}, err + } + return BrowserNetworkReviewRecord{Review: review}, nil +} diff --git a/internal/store/browser_network_readiness_test.go b/internal/store/browser_network_readiness_test.go new file mode 100644 index 00000000..4d56cce3 --- /dev/null +++ b/internal/store/browser_network_readiness_test.go @@ -0,0 +1,197 @@ +package store + +import ( + "database/sql" + "errors" + "path/filepath" + "testing" + "time" + + "cyberagent-workbench/internal/browserruntime" +) + +func TestBrowserNetworkEvidenceAndReviewAreDurableAndIdempotent(t *testing.T) { + ctx := t.Context() + state, err := Open(filepath.Join(t.TempDir(), "browser-network-readiness.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = state.Close() }) + _, identity, acceptance, _ := browserLaunchStoreFixture(t, state) + + now := time.Now().UTC().Round(time.Millisecond) + evidence, err := browserruntime.BuildBrowserNetworkContainmentEvidence(identity, acceptance, + browserruntime.BrowserNetworkProbeReport{ + ID: "browser-network-evidence-store", CollectorIdentity: "network-probe-operator", + Adapter: browserruntime.WindowsWFPBrowserContainmentAdapterName, + DynamicSessionObserved: true, AtomicInstallObserved: true, + ExactTargetObserved: true, WrongPortDenied: true, + WrongLoopbackAddressDenied: true, NonLoopbackAddressDenied: true, + IPv6Denied: true, RuleCleanupObserved: true, Production: true, + StartedAt: now, CompletedAt: now.Add(time.Second), + }) + if err != nil { + t.Fatal(err) + } + review, err := browserruntime.BuildBrowserNetworkContainmentReview(evidence, + identity, acceptance, "browser-network-review-store", "independent-network-reviewer", + true, evidence.CompletedAt.Add(time.Second)) + if err != nil { + t.Fatal(err) + } + + recorded, err := state.RecordBrowserNetworkEvidence(ctx, evidence, "evidence-op-001") + if err != nil { + t.Fatal(err) + } + if recorded.Replayed { + t.Fatal("fresh evidence was reported as replayed") + } + replayedEvidence, err := state.RecordBrowserNetworkEvidence(ctx, evidence, "evidence-op-001") + if err != nil { + t.Fatal(err) + } + if !replayedEvidence.Replayed || replayedEvidence.Evidence.Fingerprint != evidence.Fingerprint { + t.Fatalf("evidence replay did not return the exact stored payload: %#v", replayedEvidence) + } + + laterEvidence, err := browserruntime.BuildBrowserNetworkContainmentEvidence(identity, acceptance, + browserruntime.BrowserNetworkProbeReport{ + ID: "browser-network-evidence-store-later", CollectorIdentity: "network-probe-operator", + Adapter: browserruntime.WindowsWFPBrowserContainmentAdapterName, + DynamicSessionObserved: true, AtomicInstallObserved: true, + ExactTargetObserved: true, WrongPortDenied: true, + WrongLoopbackAddressDenied: true, NonLoopbackAddressDenied: true, + IPv6Denied: true, RuleCleanupObserved: true, Production: true, + StartedAt: now.Add(2 * time.Minute), CompletedAt: now.Add(2*time.Minute + time.Second), + }) + if err != nil { + t.Fatal(err) + } + if _, err := state.RecordBrowserNetworkEvidence(ctx, laterEvidence, "evidence-op-002"); err != nil { + t.Fatal(err) + } + + if _, err := state.RecordBrowserNetworkReview(ctx, review, "review-op-001"); err != nil { + t.Fatal(err) + } + replayedReview, err := state.RecordBrowserNetworkReview(ctx, review, "review-op-001") + if err != nil { + t.Fatal(err) + } + if !replayedReview.Replayed || replayedReview.Review.Fingerprint != review.Fingerprint { + t.Fatalf("review replay did not return the exact stored payload: %#v", replayedReview) + } + + loadedEvidence, err := state.LoadLatestBrowserNetworkEvidence(ctx, identity.Fingerprint) + if err != nil { + t.Fatal(err) + } + if loadedEvidence.Fingerprint != laterEvidence.Fingerprint { + t.Fatalf("loaded evidence fingerprint = %q, want latest %q", loadedEvidence.Fingerprint, laterEvidence.Fingerprint) + } + loadedReview, err := state.LoadBrowserNetworkReview(ctx, evidence.Fingerprint) + if err != nil { + t.Fatal(err) + } + if loadedReview.Fingerprint != review.Fingerprint { + t.Fatalf("loaded review fingerprint = %q, want %q", loadedReview.Fingerprint, review.Fingerprint) + } +} + +func TestBrowserNetworkReviewRequiresStoredEvidence(t *testing.T) { + ctx := t.Context() + state, err := Open(filepath.Join(t.TempDir(), "browser-network-review-fk.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = state.Close() }) + _, identity, acceptance, _ := browserLaunchStoreFixture(t, state) + + now := time.Now().UTC().Round(time.Millisecond) + evidence, err := browserruntime.BuildBrowserNetworkContainmentEvidence(identity, acceptance, + browserruntime.BrowserNetworkProbeReport{ + ID: "browser-network-evidence-fk", CollectorIdentity: "network-probe-operator", + Adapter: browserruntime.WindowsWFPBrowserContainmentAdapterName, + DynamicSessionObserved: true, AtomicInstallObserved: true, + ExactTargetObserved: true, WrongPortDenied: true, + WrongLoopbackAddressDenied: true, NonLoopbackAddressDenied: true, + IPv6Denied: true, RuleCleanupObserved: true, Production: true, + StartedAt: now, CompletedAt: now.Add(time.Second), + }) + if err != nil { + t.Fatal(err) + } + review, err := browserruntime.BuildBrowserNetworkContainmentReview(evidence, + identity, acceptance, "browser-network-review-fk", "independent-network-reviewer", + true, evidence.CompletedAt.Add(time.Second)) + if err != nil { + t.Fatal(err) + } + if _, err := state.RecordBrowserNetworkReview(ctx, review, "review-op-fk"); err == nil { + t.Fatal("review referencing an unrecorded evidence was accepted") + } + if _, err := state.LoadBrowserNetworkReview(ctx, evidence.Fingerprint); !errors.Is(err, sql.ErrNoRows) { + t.Fatalf("load review for absent evidence err = %v, want sql.ErrNoRows", err) + } + if _, err := state.LoadLatestBrowserNetworkEvidence(ctx, identity.Fingerprint); !errors.Is(err, sql.ErrNoRows) { + t.Fatalf("load evidence for absent identity err = %v, want sql.ErrNoRows", err) + } +} + +func TestSchemaV103BrowserNetworkReadinessReapplies(t *testing.T) { + path := filepath.Join(t.TempDir(), "schema-v102-browser-network-readiness.db") + state, err := Open(path) + if err != nil { + t.Fatal(err) + } + ctx := t.Context() + _, identity, acceptance, _ := browserLaunchStoreFixture(t, state) + now := time.Now().UTC().Round(time.Millisecond) + evidence, err := browserruntime.BuildBrowserNetworkContainmentEvidence(identity, acceptance, + browserruntime.BrowserNetworkProbeReport{ + ID: "browser-network-evidence-downgrade", CollectorIdentity: "network-probe-operator", + Adapter: browserruntime.WindowsWFPBrowserContainmentAdapterName, + DynamicSessionObserved: true, AtomicInstallObserved: true, + ExactTargetObserved: true, WrongPortDenied: true, + WrongLoopbackAddressDenied: true, NonLoopbackAddressDenied: true, + IPv6Denied: true, RuleCleanupObserved: true, Production: true, + StartedAt: now, CompletedAt: now.Add(time.Second), + }) + if err != nil { + t.Fatal(err) + } + for _, statement := range removeSchemaV103ForTestStatements() { + if _, err := state.db.ExecContext(ctx, statement); err != nil { + t.Fatalf("downgrade v103 fixture with %q: %v", statement, err) + } + } + if err := state.Close(); err != nil { + t.Fatal(err) + } + upgraded, err := Open(path) + if err != nil { + t.Fatal(err) + } + defer func() { _ = upgraded.Close() }() + if _, err := upgraded.RecordBrowserNetworkEvidence(ctx, evidence, "evidence-op-downgrade"); err != nil { + t.Fatal(err) + } + loaded, err := upgraded.LoadLatestBrowserNetworkEvidence(ctx, identity.Fingerprint) + if err != nil { + t.Fatal(err) + } + if loaded.Fingerprint != evidence.Fingerprint { + t.Fatalf("reapplied evidence fingerprint = %q, want %q", loaded.Fingerprint, evidence.Fingerprint) + } +} + +func removeSchemaV103ForTestStatements() []string { + return []string{ + `DROP TABLE browser_network_review_operations`, + `DROP TABLE browser_network_reviews`, + `DROP TABLE browser_network_evidence_operations`, + `DROP TABLE browser_network_evidences`, + `DELETE FROM schema_migrations WHERE version = 103`, + } +} diff --git a/internal/store/browser_runtime_lifecycle_test.go b/internal/store/browser_runtime_lifecycle_test.go index 63e8da16..8fb670ba 100644 --- a/internal/store/browser_runtime_lifecycle_test.go +++ b/internal/store/browser_runtime_lifecycle_test.go @@ -23,13 +23,13 @@ func TestBrowserRuntimeLifecycleRecordsAreAppendOnlyRecoverableAndAudited(t *tes } t.Cleanup(func() { _ = state.Close() }) session, identity, acceptance, ownership := browserLaunchStoreFixture(t, state) - prepared, err := state.PrepareBrowserLaunch(ctx, session, identity, acceptance, + attempt, _, _, err := state.PrepareBrowserLaunch(ctx, session, identity, acceptance, ownership, "browser-runtime-lifecycle-operation", "browser-runtime-worker") if err != nil { t.Fatal(err) } - checkpoints := successfulBrowserRuntimeCheckpointFixture(t, prepared.Attempt, + checkpoints := successfulBrowserRuntimeCheckpointFixture(t, attempt, ownership, "browser-runtime-durable", time.Now().UTC().Round(time.Millisecond)) for _, checkpoint := range checkpoints { if err := state.RecordBrowserRuntimeCheckpoint(ctx, checkpoint); err != nil { @@ -64,7 +64,7 @@ func TestBrowserRuntimeLifecycleRecordsAreAppendOnlyRecoverableAndAudited(t *tes t.Fatal("browser runtime receipt delete unexpectedly passed") } - recoveryInitial := initialBrowserRuntimeCheckpointFixture(t, prepared.Attempt, + recoveryInitial := initialBrowserRuntimeCheckpointFixture(t, attempt, ownership, "browser-runtime-recovery", latest.RecordedAt.Add(time.Second)) if err := state.RecordBrowserRuntimeCheckpoint(ctx, recoveryInitial); err != nil { t.Fatal(err) @@ -135,12 +135,12 @@ func TestBrowserRuntimeLifecycleRejectsBrokenAncestryAndV92Migrates(t *testing.T t.Fatal(err) } session, identity, acceptance, ownership := browserLaunchStoreFixture(t, state) - prepared, err := state.PrepareBrowserLaunch(ctx, session, identity, acceptance, + attempt, _, _, err := state.PrepareBrowserLaunch(ctx, session, identity, acceptance, ownership, "browser-runtime-v92-operation", "browser-runtime-worker") if err != nil { t.Fatal(err) } - initial := initialBrowserRuntimeCheckpointFixture(t, prepared.Attempt, ownership, + initial := initialBrowserRuntimeCheckpointFixture(t, attempt, ownership, "browser-runtime-ancestry", time.Now().UTC().Round(time.Millisecond)) broken := initial broken.ID = broken.RuntimeID + "-checkpoint-2" diff --git a/internal/store/docker_container_io_test.go b/internal/store/docker_container_io_test.go index b5d0eb73..2090e6ea 100644 --- a/internal/store/docker_container_io_test.go +++ b/internal/store/docker_container_io_test.go @@ -706,11 +706,11 @@ func removeSchemaV101ForTestStatements() []string { } func removeSchemaV102ForTestStatements() []string { - return []string{ + return append(removeSchemaV103ForTestStatements(), []string{ `DROP TABLE child_task_review_operations`, `DROP TABLE child_task_assignments`, `DROP TABLE child_task_proposal_operations`, `DROP TABLE child_task_proposals`, `DELETE FROM schema_migrations WHERE version = 102`, - } + }...) } diff --git a/internal/store/migration_v103.go b/internal/store/migration_v103.go new file mode 100644 index 00000000..46d51beb --- /dev/null +++ b/internal/store/migration_v103.go @@ -0,0 +1,102 @@ +package store + +var browserNetworkReadinessStatements = []string{ + `CREATE TABLE browser_network_evidences ( + id TEXT PRIMARY KEY, + fingerprint TEXT NOT NULL UNIQUE, + executable_identity_fingerprint TEXT NOT NULL, + acceptance_fingerprint TEXT NOT NULL, + adapter TEXT NOT NULL, + policy_version TEXT NOT NULL, + collector_identity TEXT NOT NULL, + passed INTEGER NOT NULL, + payload_json TEXT NOT NULL, + completed_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + created_at TEXT NOT NULL, + CHECK(json_valid(payload_json)), + CHECK(json_extract(payload_json, '$.protocol_version') = 'browser_network_containment_evidence.v1'), + CHECK(json_extract(payload_json, '$.id') = id), + CHECK(json_extract(payload_json, '$.fingerprint') = fingerprint), + CHECK(json_extract(payload_json, '$.executable_identity_fingerprint') = executable_identity_fingerprint), + CHECK(json_extract(payload_json, '$.acceptance_fingerprint') = acceptance_fingerprint), + CHECK(json_extract(payload_json, '$.adapter') = adapter), + CHECK(json_extract(payload_json, '$.policy_version') = policy_version), + CHECK(json_extract(payload_json, '$.collector_identity') = collector_identity), + CHECK(json_extract(payload_json, '$.passed') = passed), + CHECK(passed IN (0, 1)), + CHECK(adapter = 'windows_wfp_dynamic.v1'), + CHECK(policy_version = 'browser_network_containment_policy.v2'), + CHECK(length(fingerprint) = 64 AND fingerprint = lower(fingerprint) + AND fingerprint NOT GLOB '*[^0-9a-f]*'), + CHECK(length(executable_identity_fingerprint) = 64 + AND executable_identity_fingerprint = lower(executable_identity_fingerprint) + AND executable_identity_fingerprint NOT GLOB '*[^0-9a-f]*'), + CHECK(length(acceptance_fingerprint) = 64 + AND acceptance_fingerprint = lower(acceptance_fingerprint) + AND acceptance_fingerprint NOT GLOB '*[^0-9a-f]*'), + CHECK(id = trim(id) AND length(id) BETWEEN 1 AND 256 AND instr(id, char(0)) = 0), + CHECK(collector_identity = trim(collector_identity) + AND length(collector_identity) BETWEEN 1 AND 256 AND instr(collector_identity, char(0)) = 0), + CHECK(julianday(completed_at) IS NOT NULL + AND julianday(expires_at) IS NOT NULL + AND julianday(expires_at) > julianday(completed_at)) + ) WITHOUT ROWID;`, + `CREATE INDEX idx_browser_network_evidences_identity_completed + ON browser_network_evidences(executable_identity_fingerprint, completed_at, id);`, + `CREATE TABLE browser_network_evidence_operations ( + key_digest TEXT PRIMARY KEY, + request_fingerprint TEXT NOT NULL, + evidence_id TEXT NOT NULL, + created_at TEXT NOT NULL, + FOREIGN KEY(evidence_id) REFERENCES browser_network_evidences(id), + CHECK(length(key_digest) = 64 AND key_digest = lower(key_digest) + AND key_digest NOT GLOB '*[^0-9a-f]*'), + CHECK(length(request_fingerprint) = 64 AND request_fingerprint = lower(request_fingerprint) + AND request_fingerprint NOT GLOB '*[^0-9a-f]*') + ) WITHOUT ROWID;`, + `CREATE TABLE browser_network_reviews ( + id TEXT PRIMARY KEY, + fingerprint TEXT NOT NULL UNIQUE, + evidence_fingerprint TEXT NOT NULL, + reviewer_identity TEXT NOT NULL, + accepted INTEGER NOT NULL, + reason_code TEXT NOT NULL, + payload_json TEXT NOT NULL, + reviewed_at TEXT NOT NULL, + created_at TEXT NOT NULL, + FOREIGN KEY(evidence_fingerprint) REFERENCES browser_network_evidences(fingerprint), + CHECK(json_valid(payload_json)), + CHECK(json_extract(payload_json, '$.protocol_version') = 'browser_network_containment_review.v1'), + CHECK(json_extract(payload_json, '$.id') = id), + CHECK(json_extract(payload_json, '$.fingerprint') = fingerprint), + CHECK(json_extract(payload_json, '$.evidence_fingerprint') = evidence_fingerprint), + CHECK(json_extract(payload_json, '$.reviewer_identity') = reviewer_identity), + CHECK(json_extract(payload_json, '$.accepted') = accepted), + CHECK(json_extract(payload_json, '$.reason_code') = reason_code), + CHECK(accepted IN (0, 1)), + CHECK(reason_code IN ('production_probe_confirmed', 'operator_rejected')), + CHECK(length(fingerprint) = 64 AND fingerprint = lower(fingerprint) + AND fingerprint NOT GLOB '*[^0-9a-f]*'), + CHECK(length(evidence_fingerprint) = 64 + AND evidence_fingerprint = lower(evidence_fingerprint) + AND evidence_fingerprint NOT GLOB '*[^0-9a-f]*'), + CHECK(id = trim(id) AND length(id) BETWEEN 1 AND 256 AND instr(id, char(0)) = 0), + CHECK(reviewer_identity = trim(reviewer_identity) + AND length(reviewer_identity) BETWEEN 1 AND 256 AND instr(reviewer_identity, char(0)) = 0), + CHECK(julianday(reviewed_at) IS NOT NULL) + ) WITHOUT ROWID;`, + `CREATE INDEX idx_browser_network_reviews_evidence + ON browser_network_reviews(evidence_fingerprint, reviewed_at, id);`, + `CREATE TABLE browser_network_review_operations ( + key_digest TEXT PRIMARY KEY, + request_fingerprint TEXT NOT NULL, + review_id TEXT NOT NULL, + created_at TEXT NOT NULL, + FOREIGN KEY(review_id) REFERENCES browser_network_reviews(id), + CHECK(length(key_digest) = 64 AND key_digest = lower(key_digest) + AND key_digest NOT GLOB '*[^0-9a-f]*'), + CHECK(length(request_fingerprint) = 64 AND request_fingerprint = lower(request_fingerprint) + AND request_fingerprint NOT GLOB '*[^0-9a-f]*') + ) WITHOUT ROWID;`, +} diff --git a/internal/store/migrations.go b/internal/store/migrations.go index 52e331e2..820a3f34 100644 --- a/internal/store/migrations.go +++ b/internal/store/migrations.go @@ -11,7 +11,7 @@ import ( "time" ) -const LatestSchemaVersion = 102 +const LatestSchemaVersion = 103 type migration struct { Version int diff --git a/internal/store/sqlite.go b/internal/store/sqlite.go index d6dca38f..62fce640 100644 --- a/internal/store/sqlite.go +++ b/internal/store/sqlite.go @@ -329,6 +329,7 @@ func migrationPlan() []migration { {Version: 100, Name: "operator price snapshots and the run monetary budget ledger", Statements: monetaryBudgetStatements}, {Version: 101, Name: "structured agent dependency waiting and unique wake receipts", Statements: dependencyWaitStatements}, {Version: 102, Name: "model-proposed bounded child task scheduling", Statements: childTaskStatements}, + {Version: 103, Name: "durable browser network containment evidence and operator review", Statements: browserNetworkReadinessStatements}, } } diff --git a/web/src/api/client.test.ts b/web/src/api/client.test.ts index d2dbf573..628f4b23 100644 --- a/web/src/api/client.test.ts +++ b/web/src/api/client.test.ts @@ -227,6 +227,82 @@ describe("CyberAgentClient", () => { expect(fetchMock).not.toHaveBeenCalled(); }); + it("parses a ready Safe Web readiness receipt", async () => { + const data = { + protocol_version: "browser_safe_web_readiness.v1", + evidence_fingerprint: "a".repeat(64), + review_fingerprint: "b".repeat(64), + executable_identity_fingerprint: "c".repeat(64), + acceptance_fingerprint: "d".repeat(64), + adapter: "windows_wfp_dynamic.v1", + policy_version: "browser_network_containment_policy.v2", + operating_system: "windows", + architecture: "amd64", + collector_identity: "probe-operator", + reviewer_identity: "reviewer", + ready: true, + issued_at: "2026-08-16T00:00:00Z", + expires_at: "2026-08-16T00:15:00Z", + fingerprint: "e".repeat(64), + }; + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify({ + version: "api.v1", request_id: "req-readiness", data, + }), { status: 200, headers: { "Content-Type": "application/json" } }))); + const view = await new CyberAgentClient("read-secret").safeWebReadiness("chrome"); + expect(view).toEqual(data); + }); + + it("rejects a ready receipt that also carries a blocking reason", async () => { + const data = { + protocol_version: "browser_safe_web_readiness.v1", + evidence_fingerprint: "", + review_fingerprint: "", + executable_identity_fingerprint: "c".repeat(64), + acceptance_fingerprint: "d".repeat(64), + adapter: "windows_wfp_dynamic.v1", + policy_version: "browser_network_containment_policy.v2", + operating_system: "windows", + architecture: "amd64", + collector_identity: "probe-operator", + reviewer_identity: "reviewer", + ready: true, + blocking_reason: "evidence_missing", + issued_at: "2026-08-16T00:00:00Z", + expires_at: "2026-08-16T00:15:00Z", + fingerprint: "e".repeat(64), + }; + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify({ + version: "api.v1", request_id: "req-readiness", data, + }), { status: 200, headers: { "Content-Type": "application/json" } }))); + await expect(new CyberAgentClient("read-secret").safeWebReadiness("chrome")) + .rejects.toMatchObject({ code: "INVALID_RESPONSE" }); + }); + + it("rejects a not-ready receipt without a blocking reason", async () => { + const data = { + protocol_version: "browser_safe_web_readiness.v1", + evidence_fingerprint: "", + review_fingerprint: "", + executable_identity_fingerprint: "c".repeat(64), + acceptance_fingerprint: "d".repeat(64), + adapter: "windows_wfp_dynamic.v1", + policy_version: "browser_network_containment_policy.v2", + operating_system: "windows", + architecture: "amd64", + collector_identity: "probe-operator", + reviewer_identity: "reviewer", + ready: false, + issued_at: "2026-08-16T00:00:00Z", + expires_at: "2026-08-16T00:15:00Z", + fingerprint: "e".repeat(64), + }; + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify({ + version: "api.v1", request_id: "req-readiness", data, + }), { status: 200, headers: { "Content-Type": "application/json" } }))); + await expect(new CyberAgentClient("read-secret").safeWebReadiness("chrome")) + .rejects.toMatchObject({ code: "INVALID_RESPONSE" }); + }); + it("returns typed API errors without exposing request headers", async () => { vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify({ version: "api.v1", diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 11cd994d..7eb3cb29 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -20,6 +20,7 @@ import type { ApprovalDecisionControlView, ApprovalQueueView, ArtifactView, + BrowserSafeWebReadiness, CodeHandoffView, CodeHandoffExportView, ControlledCommandProposalReviewRequestView, @@ -1143,6 +1144,36 @@ function parseRuntimeCapabilities(value: unknown): RuntimeCapabilitiesView { return value as unknown as RuntimeCapabilitiesView; } +const safeWebReadinessBlockingReasons = [ + "evidence_missing", "evidence_version_mismatch", "executable_identity_mismatch", + "acceptance_mismatch", "policy_version_mismatch", "adapter_mismatch", + "platform_mismatch", "evidence_not_passed", "review_missing", + "review_binding_mismatch", "review_not_accepted", "evidence_expired", +]; + +function parseSafeWebReadiness(value: unknown): BrowserSafeWebReadiness { + if (!isRecord(value)) { + throw new APIRequestError("Safe Web readiness response is invalid", "INVALID_RESPONSE", 502); + } + if (value.protocol_version !== "browser_safe_web_readiness.v1" || + typeof value.ready !== "boolean" || + !isSHA256(value.executable_identity_fingerprint) || + !isSHA256(value.acceptance_fingerprint) || !isSHA256(value.fingerprint)) { + throw new APIRequestError("Safe Web readiness response is invalid", "INVALID_RESPONSE", 502); + } + const blockingReason = typeof value.blocking_reason === "string" && + value.blocking_reason !== "" ? value.blocking_reason : null; + if (value.ready === (blockingReason !== null)) { + throw new APIRequestError("Safe Web readiness blocking reason disagrees with ready", + "INVALID_RESPONSE", 502); + } + if (blockingReason !== null && !safeWebReadinessBlockingReasons.includes(blockingReason)) { + throw new APIRequestError("Safe Web readiness blocking reason is unrecognized", + "INVALID_RESPONSE", 502); + } + return value as unknown as BrowserSafeWebReadiness; +} + function parseEmbeddedAnalyzerExecution(value: unknown, runID: string): EmbeddedAnalyzerExecutionControlView { if (!hasExactKeys(value, [ @@ -3401,6 +3432,11 @@ export class CyberAgentClient { return parseRuntimeCapabilities(await this.get("/capabilities", {}, signal)); } + async safeWebReadiness(product: string, signal?: AbortSignal): Promise { + return parseSafeWebReadiness(await this.get("/browser/safe-web-readiness", + { product }, signal)); + } + async modelAvailability(signal?: AbortSignal): Promise { const value = await this.get("/models", {}, signal); return parseModelAvailability(value); diff --git a/web/src/api/schema.d.ts b/web/src/api/schema.d.ts index 407583f4..767b79bd 100644 --- a/web/src/api/schema.d.ts +++ b/web/src/api/schema.d.ts @@ -44,6 +44,26 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/browser/safe-web-readiness": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Inspect Safe Web readiness + * @description Returns a fail-closed readiness receipt over the latest production containment evidence and operator review for one accepted browser. It never starts a browser and never grants authority. + */ + get: operations["getBrowserSafeWebReadiness"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/capabilities": { parameters: { query?: never; @@ -2224,6 +2244,30 @@ export interface components { execution_debug_selected: boolean; full_debug_enabled: boolean; }; + BrowserSafeWebReadiness: { + acceptance_fingerprint: string; + /** @enum {string} */ + adapter: "windows_wfp_dynamic.v1"; + architecture: string; + /** @enum {string} */ + blocking_reason?: "evidence_missing" | "evidence_version_mismatch" | "executable_identity_mismatch" | "acceptance_mismatch" | "policy_version_mismatch" | "adapter_mismatch" | "platform_mismatch" | "evidence_not_passed" | "review_missing" | "review_binding_mismatch" | "review_not_accepted" | "evidence_expired"; + collector_identity: string; + evidence_fingerprint: string; + executable_identity_fingerprint: string; + /** Format: date-time */ + expires_at: string; + fingerprint: string; + /** Format: date-time */ + issued_at: string; + operating_system: string; + /** @enum {string} */ + policy_version: "browser_network_containment_policy.v2"; + /** @enum {string} */ + protocol_version: "browser_safe_web_readiness.v1"; + ready: boolean; + review_fingerprint: string; + reviewer_identity: string; + }; BudgetView: { /** Format: double */ max_cost_usd?: number; @@ -4271,7 +4315,7 @@ export interface components { id: string; instruction_authorized: boolean; /** @enum {string} */ - kind: "harness_status" | "model_update" | "operator_input" | "model_call" | "tool_call" | "approval" | "file_change" | "plan" | "dependency"; + kind: "harness_status" | "model_update" | "operator_input" | "model_call" | "tool_call" | "approval" | "file_change" | "plan" | "dependency" | "browser"; /** Format: int32 */ model_attempt?: number; /** Format: int64 */ @@ -5778,6 +5822,40 @@ export interface operations { 500: components["responses"]["InternalError"]; }; }; + getBrowserSafeWebReadiness: { + parameters: { + query?: { + /** @description Accepted browser product */ + product?: "chrome" | "edge" | "chromium"; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful read */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + data: components["schemas"]["BrowserSafeWebReadiness"]; + request_id: string; + /** @constant */ + version: "api.v1"; + }; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 414: components["responses"]["RequestTooLarge"]; + 429: components["responses"]["ResourceExhausted"]; + 500: components["responses"]["InternalError"]; + }; + }; getRuntimeCapabilities: { parameters: { query?: never; diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 2e6e0715..75d255c6 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -16,6 +16,7 @@ export type HostCommandProposalView = export type AgentGraphView = components["schemas"]["AgentGraphView"]; export type AgentNodeView = components["schemas"]["AgentNodeView"]; export type ArtifactView = components["schemas"]["ArtifactView"]; +export type BrowserSafeWebReadiness = components["schemas"]["BrowserSafeWebReadiness"]; export type DelegationView = components["schemas"]["DelegationView"]; export type EventView = components["schemas"]["EventView"]; export type ExternalSkillProjectionItemView = components["schemas"]["ExternalSkillProjectionItemView"]; diff --git a/web/src/components/run-activity-timeline.tsx b/web/src/components/run-activity-timeline.tsx index 67f59375..9a2a7a35 100644 --- a/web/src/components/run-activity-timeline.tsx +++ b/web/src/components/run-activity-timeline.tsx @@ -4,6 +4,7 @@ import { ChevronRight, CircleDot, FilePenLine, + Globe, ListChecks, MessageSquareText, Network, @@ -55,6 +56,8 @@ function ActivityIcon({ kind, source }: Pick