diff --git a/AGENTS.md b/AGENTS.md index 8415ab2..3dcd39c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -128,6 +128,6 @@ If a change brushes against any of these, stop and record a decision in ## Versioning & release Tags `v*` trigger `.github/workflows/release.yml` (macOS zip + Linux -tarballs). `HEADLESS_VERSION` flows from the tag; protocol version (`"0.4"` +tarballs). `HEADLESS_VERSION` flows from the tag; protocol version (`"0.5"` in `Protocol.swift`) is independent — bump it only for wire-visible changes, with a decision entry. diff --git a/CHANGELOG.md b/CHANGELOG.md index 11b26e3..4af3b38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ Two versions travel independently, on purpose: - **Product version** — the git tag, flowing into the macOS `Info.plist` via `HEADLESS_VERSION` and into release assets. - **Protocol version** — `headlessProtocolVersion` in `Protocol.swift`, - currently `0.4`. It changes only when the wire contract changes, and always + currently `0.5`. It changes only when the wire contract changes, and always with an entry in [`docs/roadmap/architecture-decisions.md`](docs/roadmap/architecture-decisions.md). @@ -66,6 +66,9 @@ Cutting that release is tracked in ### Changed +- Protocol 0.5 marks diagnostic reports, events, derived issues, console + messages, and network evidence as untrusted page content. The macOS + page-world observer now has host-owned provenance and a per-document cap. - Both engines now install the compiled agent-runtime resource once per document; Chromium caches and safely invalidates its isolated context instead of resending the runtime for every command. diff --git a/README.md b/README.md index d487e71..870c5eb 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![CI](https://github.com/LockInTime/headless/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/LockInTime/headless/actions/workflows/ci.yml) [![Release](https://img.shields.io/github/v/release/LockInTime/headless?sort=semver)](https://github.com/LockInTime/headless/releases) [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) -[![Protocol](https://img.shields.io/badge/protocol-0.4-informational)](apps/headless/Sources/HeadlessProtocol/Protocol.swift) +[![Protocol](https://img.shields.io/badge/protocol-0.5-informational)](apps/headless/Sources/HeadlessProtocol/Protocol.swift) [![Platforms](https://img.shields.io/badge/platforms-macOS%20%7C%20Linux-lightgrey)](#build-and-install) Persistent browser control for agents, without Playwright scripts or screen diff --git a/apps/headless/Host/AgentBridge.swift b/apps/headless/Host/AgentBridge.swift index 4bd9497..0fc4b8a 100644 --- a/apps/headless/Host/AgentBridge.swift +++ b/apps/headless/Host/AgentBridge.swift @@ -256,7 +256,7 @@ extension BrowserWindowController { } func agentQAReport() -> JSONValue { qaBridge.store.report() } - func agentQAClear() -> JSONValue { qaBridge.store.clear() } + func agentQAClear() -> JSONValue { qaBridge.clear() } func agentConsole(level: String, limit: Int) -> JSONValue { qaBridge.store.console(level: level, limit: limit) diff --git a/apps/headless/Host/QADiagnosticsBridge.swift b/apps/headless/Host/QADiagnosticsBridge.swift index 016a344..80e22fa 100644 --- a/apps/headless/Host/QADiagnosticsBridge.swift +++ b/apps/headless/Host/QADiagnosticsBridge.swift @@ -58,10 +58,10 @@ let webKitQAScript = #""" try { const response = await nativeFetch(...args); post({kind: 'response', requestId, url: response.url || url, method, status: response.status, - requestHeaders, responseHeaders: responseHeaders(response), source: 'webkit-fetch'}); + requestHeaders, responseHeaders: responseHeaders(response)}); return response; } catch (error) { - post({kind: 'request-failed', requestId, url, method, message: text(error), requestHeaders, source: 'webkit-fetch'}); + post({kind: 'request-failed', requestId, url, method, message: text(error), requestHeaders}); throw error; } }; @@ -85,8 +85,7 @@ let webKitQAScript = #""" status: this.status, message: this.status ? '' : 'XHR failed', requestHeaders: this.__headlessRequest?.headers || {}, - responseHeaders: xhrHeaders(this.getAllResponseHeaders()), - source: 'webkit-xhr' + responseHeaders: xhrHeaders(this.getAllResponseHeaders()) }), {once: true}); return nativeSend.apply(this, args); }; @@ -95,11 +94,42 @@ let webKitQAScript = #""" final class WebKitQABridge: NSObject, WKScriptMessageHandler { let store = QADiagnosticStore() + private let lock = NSLock() + private var acceptedEvents = 0 + private var didRejectEvents = false + private let maximumEventsPerDocument = 500 + + func beginDocument() { + lock.lock() + acceptedEvents = 0 + didRejectEvents = false + lock.unlock() + } + + func clear() -> JSONValue { + beginDocument() + return store.clear() + } + + private func acceptEvent() -> Bool { + lock.lock() + if acceptedEvents < maximumEventsPerDocument { + acceptedEvents += 1 + lock.unlock() + return true + } + let firstRejection = !didRejectEvents + didRejectEvents = true + lock.unlock() + if firstRejection { store.markTruncated() } + return false + } func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { guard message.name == "headlessQA", let body = message.body as? [String: Any], let kind = body["kind"] as? String, - ["console", "page-error", "unhandled-rejection", "request-failed", "response"].contains(kind) else { + ["console", "page-error", "unhandled-rejection", "request-failed", "response"].contains(kind), + acceptEvent() else { return } store.append( @@ -107,12 +137,12 @@ final class WebKitQABridge: NSObject, WKScriptMessageHandler { level: body["level"] as? String, message: body["message"] as? String, url: body["url"] as? String, - method: body["method"] as? String, + method: body["method"] as? String, status: (body["status"] as? NSNumber)?.doubleValue, requestID: body["requestId"] as? String, requestHeaders: body["requestHeaders"] as? [String: String], responseHeaders: body["responseHeaders"] as? [String: String], - source: body["source"] as? String + source: "webkit-page-bridge" ) } } diff --git a/apps/headless/Sources/HeadlessProtocol/Diagnostics.swift b/apps/headless/Sources/HeadlessProtocol/Diagnostics.swift index 3371e67..a2d869e 100644 --- a/apps/headless/Sources/HeadlessProtocol/Diagnostics.swift +++ b/apps/headless/Sources/HeadlessProtocol/Diagnostics.swift @@ -43,6 +43,7 @@ public final class QADiagnosticStore: @unchecked Sendable { var event: [String: JSONValue] = [ "kind": .string(bounded(kind, bytes: 64)), "timestamp": .number(timestamp), + "untrustedContent": .bool(true), ] if let level { event["level"] = .string(bounded(level, bytes: 32)) } if let message { event["message"] = .string(bounded(message, bytes: 4_096)) } @@ -62,6 +63,10 @@ public final class QADiagnosticStore: @unchecked Sendable { lock.unlock() } + public func markTruncated() { + lock.lock(); didTruncate = true; lock.unlock() + } + public func clear() -> JSONValue { lock.lock(); let count = events.count; events.removeAll(); didTruncate = false; lock.unlock() return .object(["cleared": .number(Double(count))]) @@ -100,6 +105,7 @@ public final class QADiagnosticStore: @unchecked Sendable { let omittedIssues = issues.count - boundedIssues.count let omittedEvents = snapshot.count - boundedEvents.count return .object([ + "untrustedContent": .bool(true), "summary": .object([ "events": .number(Double(snapshot.count)), "consoleErrors": .number(Double(consoleErrors)), @@ -145,6 +151,7 @@ public final class QADiagnosticStore: @unchecked Sendable { let boundedLimit = max(1, min(limit, 200)) let boundedItems = Array(items.suffix(boundedLimit)) return .object([ + "untrustedContent": .bool(true), "messages": .array(boundedItems), "returned": .number(Double(boundedItems.count)), "available": .number(Double(items.count)), @@ -164,6 +171,7 @@ public final class QADiagnosticStore: @unchecked Sendable { }.map(networkSummary(_:)) let bounded = Array(matching.suffix(max(1, min(limit, 200)))) return .object([ + "untrustedContent": .bool(true), "requests": .array(bounded), "returned": .number(Double(bounded.count)), "available": .number(Double(matching.count)), @@ -176,9 +184,17 @@ public final class QADiagnosticStore: @unchecked Sendable { guard case .object(let object) = event else { return false } return object["requestId"]?.stringValue == requestID }) else { - return .object(["found": .bool(false), "requestId": .string(requestID)]) + return .object([ + "found": .bool(false), + "requestId": .string(requestID), + "untrustedContent": .bool(true), + ]) } - return .object(["found": .bool(true), "request": event]) + return .object([ + "found": .bool(true), + "request": event, + "untrustedContent": .bool(true), + ]) } private func issue(for event: JSONValue) -> JSONValue? { @@ -193,6 +209,7 @@ public final class QADiagnosticStore: @unchecked Sendable { var issue: [String: JSONValue] = [:] func finish(_ severity: String, _ kind: String, _ text: String, _ suggestion: String) -> JSONValue { + issue["untrustedContent"] = .bool(true) issue["severity"] = .string(severity) issue["kind"] = .string(kind) issue["message"] = .string(text) diff --git a/apps/headless/Sources/HeadlessProtocol/Protocol.swift b/apps/headless/Sources/HeadlessProtocol/Protocol.swift index 9f64177..1fe274b 100644 --- a/apps/headless/Sources/HeadlessProtocol/Protocol.swift +++ b/apps/headless/Sources/HeadlessProtocol/Protocol.swift @@ -1,7 +1,7 @@ import Foundation import CoreFoundation -public let headlessProtocolVersion = "0.4" +public let headlessProtocolVersion = "0.5" public let headlessMaximumMessageBytes = 1_048_576 public enum JSONValue: Codable, Equatable, Sendable { diff --git a/apps/headless/Tests/Fixtures/hostile.html b/apps/headless/Tests/Fixtures/hostile.html index 818d42f..dcffde6 100644 --- a/apps/headless/Tests/Fixtures/hostile.html +++ b/apps/headless/Tests/Fixtures/hostile.html @@ -17,6 +17,21 @@ link.textContent = `Target ${index}`; targets.appendChild(link); } + + // A page can see and invoke WebKit's page-world diagnostics handler. + // The host must bound these reports and override their claimed provenance. + const qaHandler = globalThis.webkit?.messageHandlers?.headlessQA; + if (qaHandler) { + qaHandler.postMessage({ + kind: 'console', + level: 'error', + message: 'hostile forged diagnostic', + source: 'hostile-claims-trusted' + }); + for (let index = 0; index < 510; index += 1) { + qaHandler.postMessage({kind: 'console', level: 'warn', message: `hostile spam ${index}`}); + } + } diff --git a/apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift b/apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift index 30894db..e93eb90 100644 --- a/apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift +++ b/apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift @@ -225,11 +225,11 @@ struct ProtocolTests { } static func rejectsUnexpectedRequestFields() throws { - let valid = Data(#"{"id":"request-1","version":"0.4","command":"ping","parameters":{}}"#.utf8) + let valid = Data(#"{"id":"request-1","version":"0.5","command":"ping","parameters":{}}"#.utf8) let decoded = try ProtocolCodec.decodeLine(CommandRequest.self, from: valid) try expect(decoded.command == .ping, "the strict-field control request should decode") - let data = Data(#"{"id":"request-1","version":"0.4","command":"ping","parameters":{},"execute":"anything"}"#.utf8) + let data = Data(#"{"id":"request-1","version":"0.5","command":"ping","parameters":{},"execute":"anything"}"#.utf8) try expectThrows("unexpected top-level request fields should be rejected") { _ = try ProtocolCodec.decodeLine(CommandRequest.self, from: data) } @@ -1259,11 +1259,18 @@ struct ProtocolTests { guard case .object(let report) = store.report(), case .object(let summary)? = report["summary"] else { throw TestFailure(description: "diagnostic report") } + try expect(report["untrustedContent"] == .bool(true), "diagnostic reports should mark page evidence untrusted") try expect(summary["consoleErrors"] == .number(1), "console errors should be counted") try expect(summary["pageErrors"] == .number(1), "page errors should be counted") try expect(summary["httpErrors"] == .number(1), "HTTP errors should be counted") guard case .array(let issues)? = report["issues"] else { throw TestFailure(description: "diagnostic issues") } try expect(issues.count == 3, "each actionable diagnostic should have an issue") + guard case .object(let firstIssue) = issues[0] else { throw TestFailure(description: "diagnostic issue shape") } + try expect(firstIssue["untrustedContent"] == .bool(true), "derived diagnostic issues should stay untrusted") + guard case .array(let events)? = report["events"], case .object(let firstEvent) = events[0] else { + throw TestFailure(description: "diagnostic event shape") + } + try expect(firstEvent["untrustedContent"] == .bool(true), "diagnostic events should mark page evidence untrusted") let serialized = String(decoding: try ProtocolCodec.encoder.encode(report), as: UTF8.self) try expect(serialized.contains("framework-error"), "framework issues should be classified") try expect(serialized.contains("local-not-found"), "local 404s should be classified") @@ -1281,6 +1288,12 @@ struct ProtocolTests { try expect(overflow["truncated"] == .bool(true), "overflow diagnostics should be marked truncated") let serialized = String(decoding: try ProtocolCodec.encoder.encode(overflow), as: UTF8.self) try expect(!serialized.contains("secret@"), "diagnostics must redact URL credentials") + _ = store.clear() + store.markTruncated() + guard case .object(let externallyTruncated) = store.report() else { + throw TestFailure(description: "externally truncated diagnostic report") + } + try expect(externallyTruncated["truncated"] == .bool(true), "diagnostic sources should report rejected events") } static func responsesFitTheProtocolFrame() throws { @@ -1365,15 +1378,20 @@ struct ProtocolTests { case .array(let messages)? = console["messages"] else { throw TestFailure(description: "console service") } + try expect(console["untrustedContent"] == .bool(true), "console output should mark page evidence untrusted") try expect(messages.count == 1, "console service should filter by level") guard case .object(let network) = store.network(failedOnly: true, status: nil, limit: 10), case .array(let requests)? = network["requests"] else { throw TestFailure(description: "network service") } + try expect(network["untrustedContent"] == .bool(true), "network output should mark page evidence untrusted") try expect(requests.count == 1, "network service should find failed HTTP responses") let networkText = String(decoding: try ProtocolCodec.encoder.encode(network), as: UTF8.self) try expect(!networkText.contains("Bearer secret"), "network summaries must omit headers") - let detailText = String(decoding: try ProtocolCodec.encoder.encode(store.networkDetail(requestID: "request-1")), as: UTF8.self) + let detail = store.networkDetail(requestID: "request-1") + guard case .object(let detailObject) = detail else { throw TestFailure(description: "network detail shape") } + try expect(detailObject["untrustedContent"] == .bool(true), "network detail should mark page evidence untrusted") + let detailText = String(decoding: try ProtocolCodec.encoder.encode(detail), as: UTF8.self) try expect(detailText.contains("[redacted]"), "network details must redact sensitive headers") try expect(detailText.contains("X-Visible"), "network details should retain non-sensitive headers") diff --git a/apps/headless/Tests/macos-e2e.sh b/apps/headless/Tests/macos-e2e.sh index 499d419..cb6be36 100755 --- a/apps/headless/Tests/macos-e2e.sh +++ b/apps/headless/Tests/macos-e2e.sh @@ -324,6 +324,16 @@ echo "$SCOPED_ACTIONS" | grep -q '"name":"Copy authentication command"' BOUNDED_SNAPSHOT="$("$CLI" --session qa inspect)" echo "$BOUNDED_SNAPSHOT" | grep -q '"truncated":true' test "$(printf %s "$BOUNDED_SNAPSHOT" | wc -c)" -lt 1048576 +HOSTILE_DIAGNOSTICS="$("$CLI" --session qa qa report)" +echo "$HOSTILE_DIAGNOSTICS" | grep -q 'hostile forged diagnostic' +echo "$HOSTILE_DIAGNOSTICS" | grep -q '"source":"webkit-page-bridge"' +echo "$HOSTILE_DIAGNOSTICS" | grep -q '"untrustedContent":true' +echo "$HOSTILE_DIAGNOSTICS" | grep -q '"events":500' +echo "$HOSTILE_DIAGNOSTICS" | grep -q '"truncated":true' +if echo "$HOSTILE_DIAGNOSTICS" | grep -q 'hostile-claims-trusted'; then + echo "hostile page controlled diagnostic provenance" >&2 + fail +fi "$CLI" session close qa | grep -q '"closed":"qa"' echo "macOS P2 end-to-end flow passed" diff --git a/apps/headless/docs/P0.md b/apps/headless/docs/P0.md index 47efbae..3827dc0 100644 --- a/apps/headless/docs/P0.md +++ b/apps/headless/docs/P0.md @@ -35,7 +35,10 @@ until it has authentication, authorization, and transport security. - Page navigation accepts HTTP and HTTPS only, including before agent control is enabled in the visible macOS app. - No arbitrary JavaScript or shell execution command is exposed. -- Browser helper code runs in an isolated world that page globals cannot replace. +- Agent action and inspection helpers run in an isolated world that page + globals cannot replace. The macOS best-effort console/fetch/XHR observer + necessarily runs in the page world; its bounded output is explicitly marked + untrusted and its provenance is assigned by the host. - Page-provided text is data and never interpreted as a protocol command. - External application URL schemes and local-file navigation are rejected. - Every command has a finite deadline and a structured failure response. diff --git a/apps/headless/docs/P2.md b/apps/headless/docs/P2.md index b225840..f90200b 100644 --- a/apps/headless/docs/P2.md +++ b/apps/headless/docs/P2.md @@ -36,6 +36,15 @@ bounded to 200 steps. diagnostics, trace, and artifact references. Sensitive cookie and storage values are not included. +All diagnostics are page evidence. Protocol 0.5 marks diagnostic reports, +events, derived issues, console listings, and network listings/details with +`untrustedContent: true`. On macOS, console/fetch/XHR observation must patch +the page world because WKWebView has no isolated-world API for those streams; +the bridge is page-observable and forgeable by design. The host therefore +overrides its source as `webkit-page-bridge`, accepts at most 500 bridge events +per document, and reports truncation. Agent actions and inspection remain in +the separate `HeadlessAgent` content world. + ## Controlled networking On the Linux Chromium runtime: diff --git a/apps/headless/main.swift b/apps/headless/main.swift index 8a264ed..0a27e3f 100644 --- a/apps/headless/main.swift +++ b/apps/headless/main.swift @@ -655,6 +655,10 @@ final class BrowserWindowController: NSWindowController, NSWindowDelegate, // MARK: WKNavigationDelegate + func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { + qaBridge.beginDocument() + } + func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!) { let u = webView.url?.absoluteString if u != nil && u != "about:blank" { diff --git a/docs/roadmap/architecture-decisions.md b/docs/roadmap/architecture-decisions.md index 3043207..c9e535d 100644 --- a/docs/roadmap/architecture-decisions.md +++ b/docs/roadmap/architecture-decisions.md @@ -94,7 +94,7 @@ single-sourced and the divergent one is declared, generated into ## 4. Wire protocol: keep as-is, version bump only when necessary **Decision:** keep newline-delimited JSON over the private Unix socket, -version `"0.4"`, strict decoding, per-command parameter allow-lists, 1 MiB +version `"0.5"`, strict decoding, per-command parameter allow-lists, 1 MiB frames. The protocol is deliberately language- and transport-neutral — that neutrality is what keeps both the Windows port and the (rejected-for-now) Rust option cheap. @@ -161,13 +161,10 @@ the answer is offering the Chromium engine on macOS as an *additional* runtime behind the same CLI (the Linux host already builds on macOS-adjacent Foundation APIs) — not hacking WKWebView. That would be a new decision entry. -**Must fix, not accept (backlog §A/§B):** the page-world QA diagnostics bridge -(`Host/QADiagnosticsBridge.swift` injected in the page world, -`main.swift:233-236`) is detectable and forgeable by a hostile page, while -P0.md implies isolation. Either move what's possible into the isolated world / -`WKContentWorld`, or explicitly document the bridge as page-observable and -downgrade its events to untrusted in the report format. The current silent -mismatch between claim and code is the problem. +**Resolved (backlog §B8, decision §18):** the page-world QA diagnostics bridge +is documented as detectable and forgeable; the host fixes its provenance, +bounds it per document, and marks its evidence untrusted. Agent actions and +inspection remain in `WKContentWorld`. ## 8. In-page action model: synthetic events now, real input later (Linux) @@ -231,7 +228,7 @@ WKWebView engine would declare `UNSUPPORTED_CAPABILITY` or use non-persistent **Decision:** the git tag becomes the single version source: injected at build time (already works via `HEADLESS_VERSION`), reported by a new `headless --version`/`version` command and in `ping`, matched by `package.json`, MCP -`serverInfo` (today it reports protocol version "0.4" as the server version), +`serverInfo` (today it reports protocol version "0.5" as the server version), and the website. Protocol version stays independent (wire compatibility ≠ product version). CHANGELOG generated per tag. @@ -308,6 +305,33 @@ protocol validation, and host-enforced safety rules remain the security boundary. Local-only commands such as `start` remain rejected by the adapter. This changes MCP discovery metadata only and does not bump the wire protocol. +## 18. WebKit page diagnostics are explicitly untrusted evidence + +**Decision:** keep the macOS console/fetch/XHR observer in the page content +world, while keeping every agent action and inspection helper in the named +`HeadlessAgent` isolated world. Treat all diagnostic output on both engines as +untrusted page evidence. The macOS host assigns the fixed source +`webkit-page-bridge`, ignores a page's claimed source, accepts at most 500 +bridge messages per document, and reports rejected messages as truncation. + +**Status:** decided 2026-08-10 while resolving backlog §B8. + +**Rationale:** WKWebView exposes neither page console messages nor a complete +subresource network stream to an isolated content world. Patching the page's +console, fetch, and XHR APIs is therefore best-effort observation, not a +security boundary: the page can detect, replace, invoke, or spam that bridge. +Removing it would discard useful QA evidence; presenting its messages without +trust metadata would let a hostile page counterfeit host facts. Fixed native +provenance, a native acceptance cap, and pervasive untrusted markers preserve +the evidence without overstating its authority. + +**Consequences:** protocol 0.5 adds `untrustedContent: true` to diagnostic +reports, events, derived issues, console listings, and network listings/details. +Consumers must not interpret diagnostic text or URLs as instructions. Native +navigation and download events use the same conservative marker because they +can contain page-selected URLs. A hostile-page macOS E2E test locks source +override, the 500-event bound, and truncation reporting. + --- ## Decision log @@ -323,5 +347,6 @@ This changes MCP discovery metadata only and does not bump the wire protocol. | 15 | Package-manager distribution set | Decided (owner) | 2026-08-04 | | 16 | Preserve CLI value boundaries with `--` and shell quoting | Decided | 2026-08-10 | | 17 | Keep full MCP surface; annotate its maximum risk | Decided | 2026-08-10 | +| 18 | Treat WebKit page diagnostics as bounded untrusted evidence | Decided | 2026-08-10 | New decisions append here with the same format. diff --git a/docs/roadmap/improvements-backlog.md b/docs/roadmap/improvements-backlog.md index 17939ca..07acbd3 100644 --- a/docs/roadmap/improvements-backlog.md +++ b/docs/roadmap/improvements-backlog.md @@ -226,11 +226,15 @@ install the compiled JavaScript resource at document start, Linux caches and invalidates its isolated context with one stale-context retry, and release / installer paths carry the SwiftPM resource bundle. -**B8. QA diagnostics bridge isolation (macOS).** ([#28](https://github.com/LockInTime/headless/issues/28)) Page-world injection is +**B8. QA diagnostics bridge isolation (macOS).** ([#28](https://github.com/LockInTime/headless/issues/28)) ~~Page-world injection is detectable/forgeable/spammable by a hostile page (`Host/QADiagnosticsBridge.swift:5-93`, `main.swift:233-236`) while P0 claims isolated-world helpers. Move what's possible; mark the rest untrusted. See -architecture decision §7. +architecture decision §7.~~ **Done:** agent actions remain in the isolated +world; the unavoidable page-world observer is documented, host-attributed, +bounded per document, and all diagnostic evidence is marked untrusted in +protocol 0.5. A hostile WKWebView fixture proves spoofed provenance is ignored +and spam is truncated. ## §C — MCP & agent surface (Phases 1/4)