From 3c04a2882f89369a3e74cf1a4c889f844a1c575b Mon Sep 17 00:00:00 2001 From: Qiyuanqiii <2297740147@qq.com> Date: Sun, 16 Aug 2026 03:26:55 +0800 Subject: [PATCH 01/11] =?UTF-8?q?feat(browser):=20=E6=96=B0=E5=A2=9E=20Saf?= =?UTF-8?q?e=20Web=20readiness=20=E6=94=B6=E6=8D=AE=E4=B8=8E=20fail-closed?= =?UTF-8?q?=20=E5=88=A4=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/browserruntime/readiness.go | 169 +++++++++++++++++++ internal/browserruntime/readiness_test.go | 187 ++++++++++++++++++++++ 2 files changed, 356 insertions(+) create mode 100644 internal/browserruntime/readiness.go create mode 100644 internal/browserruntime/readiness_test.go 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") + } + }) + } +} From ddc3a664b42913d8834245a477fb4d5dfcbc299d Mon Sep 17 00:00:00 2001 From: Qiyuanqiii <2297740147@qq.com> Date: Sun, 16 Aug 2026 03:26:55 +0800 Subject: [PATCH 02/11] =?UTF-8?q?docs(adr):=20ADR=200104=20=E8=AE=B0?= =?UTF-8?q?=E5=BD=95=20Safe=20Web=20readiness=20=E6=94=B6=E6=8D=AE?= =?UTF-8?q?=E5=86=B3=E7=AD=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/adr/0104-safe-web-readiness-receipt.md | 37 +++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 docs/adr/0104-safe-web-readiness-receipt.md diff --git a/docs/adr/0104-safe-web-readiness-receipt.md b/docs/adr/0104-safe-web-readiness-receipt.md new file mode 100644 index 00000000..d5f02188 --- /dev/null +++ b/docs/adr/0104-safe-web-readiness-receipt.md @@ -0,0 +1,37 @@ +# ADR 0104: 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. From 866148f3d9b322ad5531720979b2072c9897d25f Mon Sep 17 00:00:00 2001 From: Qiyuanqiii <2297740147@qq.com> Date: Sun, 16 Aug 2026 03:49:22 +0800 Subject: [PATCH 03/11] =?UTF-8?q?feat(store):=20=E6=8C=81=E4=B9=85?= =?UTF-8?q?=E5=8C=96=E6=B5=8F=E8=A7=88=E5=99=A8=E7=BD=91=E7=BB=9C=E9=9A=94?= =?UTF-8?q?=E7=A6=BB=E8=AF=81=E6=8D=AE=E4=B8=8E=E6=93=8D=E4=BD=9C=E8=80=85?= =?UTF-8?q?=20review=EF=BC=88schema=20v102=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 1 + internal/store/browser_network_readiness.go | 296 ++++++++++++++++++ .../store/browser_network_readiness_test.go | 197 ++++++++++++ internal/store/migration_v102.go | 102 ++++++ internal/store/migrations.go | 2 +- internal/store/sqlite.go | 1 + 6 files changed, 598 insertions(+), 1 deletion(-) create mode 100644 internal/store/browser_network_readiness.go create mode 100644 internal/store/browser_network_readiness_test.go create mode 100644 internal/store/migration_v102.go diff --git a/README.md b/README.md index e76a7e08..83660cd2 100644 --- a/README.md +++ b/README.md @@ -319,6 +319,7 @@ open build/desktop/Prayu.app | v99 | 不可变 Docker Sandbox 产品准入、启动绑定、取消与终态回执 | immutable Docker Sandbox product admission, launch binding, cancellation, and terminal receipts | | 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 | 持久浏览器网络隔离证据与操作者 review | durable browser network containment evidence and operator 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..5b768afd --- /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 TestSchemaV102BrowserNetworkReadinessReapplies(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 removeSchemaV102ForTestStatements() { + if _, err := state.db.ExecContext(ctx, statement); err != nil { + t.Fatalf("downgrade v102 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 removeSchemaV102ForTestStatements() []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 = 102`, + } +} diff --git a/internal/store/migration_v102.go b/internal/store/migration_v102.go new file mode 100644 index 00000000..46d51beb --- /dev/null +++ b/internal/store/migration_v102.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 a6171c05..52e331e2 100644 --- a/internal/store/migrations.go +++ b/internal/store/migrations.go @@ -11,7 +11,7 @@ import ( "time" ) -const LatestSchemaVersion = 101 +const LatestSchemaVersion = 102 type migration struct { Version int diff --git a/internal/store/sqlite.go b/internal/store/sqlite.go index abdc349c..143b1533 100644 --- a/internal/store/sqlite.go +++ b/internal/store/sqlite.go @@ -328,6 +328,7 @@ func migrationPlan() []migration { {Version: 99, Name: "immutable Docker Sandbox product admission and completion", Statements: sandboxDockerProductAdmissionStatements}, {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: "durable browser network containment evidence and operator review", Statements: browserNetworkReadinessStatements}, } } From 397cdcfcbdb51dc4330029c7a1fec9c679d09b08 Mon Sep 17 00:00:00 2001 From: Qiyuanqiii <2297740147@qq.com> Date: Sun, 16 Aug 2026 03:49:22 +0800 Subject: [PATCH 04/11] =?UTF-8?q?feat(application):=20Safe=20Web=20readine?= =?UTF-8?q?ss=20=E8=BF=90=E8=A1=8C=E6=97=B6=E6=9C=8D=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/browser_safe_web_runtime.go | 71 +++++++ .../browser_safe_web_runtime_test.go | 201 ++++++++++++++++++ 2 files changed, 272 insertions(+) create mode 100644 internal/application/browser_safe_web_runtime.go create mode 100644 internal/application/browser_safe_web_runtime_test.go 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") + } + }) +} From 961279e29f71ccd5caad53673b3d1e1ef94274a0 Mon Sep 17 00:00:00 2001 From: Qiyuanqiii <2297740147@qq.com> Date: Sun, 16 Aug 2026 04:45:45 +0800 Subject: [PATCH 05/11] =?UTF-8?q?feat(cli):=20=E6=8E=A5=E5=85=A5=20doctor?= =?UTF-8?q?=20browser-readiness=20/=20browser-network-review=20=E4=B8=8E?= =?UTF-8?q?=E8=AF=81=E6=8D=AE=E6=8C=81=E4=B9=85=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/app/app.go | 1 + internal/app/doctor_command.go | 102 +++++++++++++++++++++++++++- internal/app/doctor_command_test.go | 10 +++ 3 files changed, 111 insertions(+), 2 deletions(-) diff --git a/internal/app/app.go b/internal/app/app.go index 82190be9..08d16981 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -239,6 +239,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()) + } +} From e1665baabacfb0dca8f8824e40337ec549e91813 Mon Sep 17 00:00:00 2001 From: Qiyuanqiii <2297740147@qq.com> Date: Sun, 16 Aug 2026 05:25:12 +0800 Subject: [PATCH 06/11] =?UTF-8?q?feat(httpapi):=20GET=20/api/v1/browser/sa?= =?UTF-8?q?fe-web-readiness=20=E5=8F=AA=E8=AF=BB=20readiness=20=E7=AB=AF?= =?UTF-8?q?=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/openapi.json | 172 ++++++++++++++++++ .../httpapi/browser_safe_web_readiness.go | 66 +++++++ .../browser_safe_web_readiness_test.go | 18 ++ internal/httpapi/handlers.go | 2 + internal/httpapi/openapi.go | 13 ++ internal/httpapi/server.go | 6 + web/src/api/schema.d.ts | 78 ++++++++ 7 files changed, 355 insertions(+) create mode 100644 internal/httpapi/browser_safe_web_readiness.go create mode 100644 internal/httpapi/browser_safe_web_readiness_test.go diff --git a/docs/openapi.json b/docs/openapi.json index 123c9858..d30eb29e 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", @@ -10977,6 +11053,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": { diff --git a/internal/httpapi/browser_safe_web_readiness.go b/internal/httpapi/browser_safe_web_readiness.go new file mode 100644 index 00000000..9e13c423 --- /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.Normalize(err) + } + 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 0df53db9..85ebe119 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 6f51c449..5b5fc22d 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" @@ -300,6 +301,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.", @@ -1720,6 +1729,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/server.go b/internal/httpapi/server.go index b55a9be1..066afcd4 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/web/src/api/schema.d.ts b/web/src/api/schema.d.ts index c9eb3c4f..72533a2b 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; @@ -2124,6 +2144,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; @@ -5610,6 +5654,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; From 213d341bbe85193ac3fc171cbb3dbe47582de756 Mon Sep 17 00:00:00 2001 From: Qiyuanqiii <2297740147@qq.com> Date: Sun, 16 Aug 2026 05:38:33 +0800 Subject: [PATCH 07/11] =?UTF-8?q?feat(web):=20Safe=20Web=20readiness=20?= =?UTF-8?q?=E9=97=A8=EF=BC=88=E8=AE=BE=E7=BD=AE=E9=A1=B5=E5=B1=95=E7=A4=BA?= =?UTF-8?q?=20ready/=E9=98=BB=E5=A1=9E=E5=8E=9F=E5=9B=A0=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/api/client.test.ts | 76 +++++++++++++++++++++++ web/src/api/client.ts | 36 +++++++++++ web/src/api/types.ts | 1 + web/src/components/safe-web-readiness.tsx | 34 ++++++++++ web/src/components/settings-view.test.tsx | 6 +- web/src/components/settings-view.tsx | 10 ++- 6 files changed, 160 insertions(+), 3 deletions(-) create mode 100644 web/src/components/safe-web-readiness.tsx 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 25b228a4..aa6533ff 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -4,6 +4,7 @@ import type { ApprovalDecisionControlView, ApprovalQueueView, ArtifactView, + BrowserSafeWebReadiness, CodeHandoffView, CodeHandoffExportView, ControlledCommandProposalReviewRequestView, @@ -1127,6 +1128,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, [ @@ -3385,6 +3416,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/types.ts b/web/src/api/types.ts index 063c2772..8baa601e 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/safe-web-readiness.tsx b/web/src/components/safe-web-readiness.tsx new file mode 100644 index 00000000..18bcc2c9 --- /dev/null +++ b/web/src/components/safe-web-readiness.tsx @@ -0,0 +1,34 @@ +import { useQuery } from "@tanstack/react-query"; +import type { CyberAgentClient } from "../api/client"; +import { useLocale } from "../lib/locale"; +import { ErrorState, LoadingState, StatusBadge } from "./common"; + +const SafeWebReadinessProduct = "chrome"; + +export function SafeWebReadinessPanel({ client }: { client: CyberAgentClient }) { + const { t } = useLocale(); + const query = useQuery({ + queryKey: ["safe-web-readiness", SafeWebReadinessProduct], + queryFn: ({ signal }) => client.safeWebReadiness(SafeWebReadinessProduct, signal), + retry: false, + }); + if (query.isLoading) { + return ; + } + if (query.isError || !query.data) { + return ; + } + const readiness = query.data; + return ( +
+ + {!readiness.ready && ( +

+ {t("阻塞原因", "Blocking reason")}: {readiness.blocking_reason} +

+ )} +
+ ); +} diff --git a/web/src/components/settings-view.test.tsx b/web/src/components/settings-view.test.tsx index 857d557e..66bc940e 100644 --- a/web/src/components/settings-view.test.tsx +++ b/web/src/components/settings-view.test.tsx @@ -1,4 +1,5 @@ import { fireEvent, render, screen } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { ComponentProps } from "react"; import { CyberAgentClient } from "../api/client"; import { LocaleProvider } from "../lib/locale"; @@ -19,7 +20,10 @@ const health = { const client = new CyberAgentClient("read-token"); function renderSettings(props: ComponentProps) { - return render(); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + ); } describe("SettingsView", () => { diff --git a/web/src/components/settings-view.tsx b/web/src/components/settings-view.tsx index 65af2931..d3808297 100644 --- a/web/src/components/settings-view.tsx +++ b/web/src/components/settings-view.tsx @@ -24,6 +24,7 @@ import { applyRunNavigationMode, readRunNavigationMode, type RunNavigationMode } from "../lib/run-navigation"; import { PrayuBrand } from "./prayu-brand"; import { RunPermissionSettings } from "./run-permission-settings"; +import { SafeWebReadinessPanel } from "./safe-web-readiness"; import { SidebarResizeHandle, clampSidebarWidth, defaultSidebarWidth } from "./workbench-frame"; export type SettingsCapability = { @@ -178,7 +179,7 @@ export function SettingsView({
{section === "profile" && } + desktop={desktop} health={health} client={client} />} {section === "general" && } {section === "permissions" && ; } -function ProfileSettings({ capabilities, desktop, health }: { +function ProfileSettings({ capabilities, desktop, health, client }: { capabilities: SettingsCapability[]; desktop: boolean; health: HealthView | null; + client: CyberAgentClient; }) { const { t } = useLocale(); const enabled = capabilities.filter((capability) => capability.enabled); @@ -260,6 +262,10 @@ function ProfileSettings({ capabilities, desktop, health }: {
{t("本地存储", "Local store")}
SQLite
+
+

{t("Safe Web", "Safe Web")}

+ +

{t("当前能力", "Active capabilities")}

    From b738f90ab64c0037a201dcb0d13d1314cd148f03 Mon Sep 17 00:00:00 2001 From: Qiyuanqiii <2297740147@qq.com> Date: Sun, 16 Aug 2026 05:56:06 +0800 Subject: [PATCH 08/11] =?UTF-8?q?feat(activity):=20=E6=B5=8F=E8=A7=88?= =?UTF-8?q?=E5=99=A8=E7=94=9F=E5=91=BD=E5=91=A8=E6=9C=9F=E6=8A=95=E5=BD=B1?= =?UTF-8?q?=E5=88=B0=20Run=20Activity=EF=BC=88Go=20=E8=A7=82=E5=AF=9F?= =?UTF-8?q?=E4=BA=8B=E5=AE=9E=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/openapi.json | 3 +- internal/httpapi/openapi.go | 2 +- internal/runactivity/activity.go | 11 ++++++ internal/runactivity/activity_test.go | 38 ++++++++++++++++++++ web/src/api/schema.d.ts | 2 +- web/src/components/run-activity-timeline.tsx | 3 ++ 6 files changed, 56 insertions(+), 3 deletions(-) diff --git a/docs/openapi.json b/docs/openapi.json index d30eb29e..f32c7e3b 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -18930,7 +18930,8 @@ "approval", "file_change", "plan", - "dependency" + "dependency", + "browser" ], "type": "string" }, diff --git a/internal/httpapi/openapi.go b/internal/httpapi/openapi.go index 5b5fc22d..d9693adf 100644 --- a/internal/httpapi/openapi.go +++ b/internal/httpapi/openapi.go @@ -1560,7 +1560,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"}, diff --git a/internal/runactivity/activity.go b/internal/runactivity/activity.go index d0913e31..a5d754b2 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" default: return Item{}, false } 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/web/src/api/schema.d.ts b/web/src/api/schema.d.ts index 72533a2b..07d1f0b0 100644 --- a/web/src/api/schema.d.ts +++ b/web/src/api/schema.d.ts @@ -4147,7 +4147,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 */ 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