diff --git a/CHANGELOG.md b/CHANGELOG.md
index 518ce9d..767c097 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -36,6 +36,11 @@ Cutting that release is tracked in
- Recording and transport regression coverage for every ffmpeg format/quality
mapping, executable discovery, capture-failure and stop bounds, capabilities
accuracy, and oversized Unix-socket requests.
+- Cross-uid Unix-socket rejection and host E2E classification checks for
+ blocked top-frame navigation and denied page-initiated downloads.
+- macOS explicitly converts download-intent links, attachment responses, and
+ unsupported response types into cancellable `WKDownload` objects so every
+ denied download is classified without writing page-controlled bytes.
- Progressive context pruning: `inspect --context summary|outline|text|actions|full`
with `--task` ranking, `--within @rN` scoping, and `--limit` / `--budget` /
`--depth` bounds. Every focused response reports `contextStats` and `omitted`.
diff --git a/apps/headless/Tests/Fixtures/dashboard.html b/apps/headless/Tests/Fixtures/dashboard.html
index 8e3cb0e..a6628ec 100644
--- a/apps/headless/Tests/Fixtures/dashboard.html
+++ b/apps/headless/Tests/Fixtures/dashboard.html
@@ -30,7 +30,9 @@
Continue setup
Non-web browser URL
Credential-bearing URL
Suspicious installer
+ Download fixture
+
diff --git a/apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift b/apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift
index 48fb01f..ff5ee8a 100644
--- a/apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift
+++ b/apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift
@@ -1095,6 +1095,48 @@ struct ProtocolTests {
try expect(response.result == nil, "oversized request must not reach the command handler")
}
+ static func differentPeerUserIsRejected() throws {
+ #if os(Linux)
+ // The Linux CI container runs this suite as root, which lets the test
+ // launch one deliberately unprivileged peer. Normal developer runs
+ // still exercise every other transport boundary without requiring
+ // privilege escalation.
+ guard getuid() == 0 else { return }
+ let setpriv = "/usr/bin/setpriv"
+ try expect(FileManager.default.isExecutableFile(atPath: setpriv), "Linux CI should provide setpriv")
+ try LocalRuntime.preparePrivateDirectory()
+ let socketPath = LocalRuntime.directoryURL
+ .appendingPathComponent("peer-uid-\(UUID().uuidString).sock").path
+ let server = LocalSocketServer(socketPath: socketPath)
+ try server.start { request in CommandResponse.success(id: request.id) }
+ defer {
+ server.stop()
+ _ = Glibc.chmod(LocalRuntime.directoryURL.path, 0o700)
+ }
+ // The production modes are 0700/0600. Open them only inside this
+ // disposable test so a different uid can reach accept(), where the
+ // credential check must still fail closed.
+ try expect(Glibc.chmod(LocalRuntime.directoryURL.path, 0o777) == 0, "test runtime directory chmod failed")
+ try expect(Glibc.chmod(socketPath, 0o666) == 0, "test socket chmod failed")
+
+ let process = Process()
+ process.executableURL = URL(fileURLWithPath: setpriv)
+ process.arguments = [
+ "--reuid=65534", "--regid=65534", "--clear-groups",
+ CommandLine.arguments[0], "--peer-denied-client", socketPath,
+ ]
+ process.standardOutput = FileHandle.nullDevice
+ let errors = Pipe()
+ process.standardError = errors
+ try process.run()
+ process.waitUntilExit()
+ let errorText = String(
+ decoding: errors.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self
+ )
+ try expect(process.terminationStatus == 0, "different-uid peer was not rejected: \(errorText)")
+ #endif
+ }
+
static func screenshotSeriesHelpers() throws {
let rawPlan = JSONValue.object([
"initialY": .number(240),
@@ -1386,6 +1428,24 @@ struct ProtocolTests {
}
static func main() {
+ if CommandLine.arguments.count == 3,
+ CommandLine.arguments[1] == "--peer-denied-client" {
+ do {
+ let descriptor = try connectRawUnixSocket(path: CommandLine.arguments[2])
+ defer { closeRawSocket(descriptor) }
+ let response = try ProtocolCodec.decodeLine(
+ CommandResponse.self, from: readRawSocketLine(descriptor: descriptor)
+ )
+ guard response.error?.code == "PEER_DENIED" else {
+ fputs("expected PEER_DENIED\n", stderr)
+ exit(1)
+ }
+ exit(0)
+ } catch {
+ fputs("peer client failed: \(error)\n", stderr)
+ exit(1)
+ }
+ }
let tests: [TestCase] = [
("request round-trip", requestRoundTrip),
("unsafe navigation schemes", rejectsUnsafeNavigationSchemes),
@@ -1426,6 +1486,7 @@ struct ProtocolTests {
("private socket directory", serverRejectsSocketOutsidePrivateDirectory),
("shutdown bypasses busy request", shutdownBypassesBusyRequest),
("oversized socket request", oversizedSocketRequestIsRejected),
+ ("different peer uid", differentPeerUserIsRejected),
]
var failures = 0
diff --git a/apps/headless/Tests/fixture-server.mjs b/apps/headless/Tests/fixture-server.mjs
index 176fe2f..1847275 100644
--- a/apps/headless/Tests/fixture-server.mjs
+++ b/apps/headless/Tests/fixture-server.mjs
@@ -25,6 +25,17 @@ const server = createServer(async (request, response) => {
response.end(body);
return;
}
+ if (pathname === '/download.txt') {
+ const body = Buffer.from('download must remain blocked');
+ response.writeHead(200, {
+ 'content-type': 'text/plain; charset=utf-8',
+ 'content-length': body.length,
+ 'content-disposition': 'attachment; filename="download.txt"',
+ 'cache-control': 'no-store',
+ });
+ response.end(body);
+ return;
+ }
const fixture = routes.get(pathname);
if (!fixture) {
response.writeHead(404, {'content-type': 'text/plain; charset=utf-8'});
diff --git a/apps/headless/Tests/linux-e2e.sh b/apps/headless/Tests/linux-e2e.sh
index dce6326..dd1599d 100755
--- a/apps/headless/Tests/linux-e2e.sh
+++ b/apps/headless/Tests/linux-e2e.sh
@@ -128,8 +128,12 @@ if SUSPICIOUS_RESULT="$(headless --session qa click --role link --name 'Suspicio
exit 1
fi
echo "$SUSPICIOUS_RESULT" | grep -q 'UNSAFE_RESOURCE_TYPE'
-headless --session qa click --role button --name 'Scripted non-web navigation' | grep -q '"clicked"'
+headless --session qa click --role button --name 'Scripted host-blocked navigation' | grep -q '"clicked"'
headless --session qa wait --url /designers/dashboard --settled --timeout 10000 | grep -q 'Designers Dashboard'
+BLOCKED_NAVIGATION_REPORT="$(headless --session qa qa report)"
+echo "$BLOCKED_NAVIGATION_REPORT" | grep -q '"kind":"navigation-blocked"'
+echo "$BLOCKED_NAVIGATION_REPORT" | grep -q 'about:blank'
+headless --session qa qa clear | grep -q '"cleared"'
headless --session qa screenshot --output viewport.png | grep -q '"name":"viewport.png"'
headless --session qa screenshot --full-page --output full-page.png | grep -q '"name":"full-page.png"'
headless --session qa screenshot --role button --name Continue --output continue.png | grep -q '"name":"continue.png"'
diff --git a/apps/headless/Tests/macos-e2e.sh b/apps/headless/Tests/macos-e2e.sh
index c26ad9f..499d419 100755
--- a/apps/headless/Tests/macos-e2e.sh
+++ b/apps/headless/Tests/macos-e2e.sh
@@ -185,28 +185,41 @@ echo "$QA_REPORT" | grep -q '"kind":"local-not-found"'
STEP="safe-input-navigation"
"$CLI" --session qa fill @e1 'Ada Lovelace' | grep -q '"valueLength":12'
"$CLI" --session qa press Escape | grep -q '"pressed":"Escape"'
+STEP="safe-input-external-link"
if EXTERNAL_RESULT="$("$CLI" --session qa click --role link --name 'External application')"; then
echo "external application link was not blocked" >&2
fail
fi
echo "$EXTERNAL_RESULT" | grep -q 'UNSAFE_NAVIGATION'
+STEP="safe-input-non-web-link"
if NON_WEB_RESULT="$("$CLI" --session qa click --role link --name 'Non-web browser URL')"; then
echo "non-HTTP browser URL was not blocked" >&2
fail
fi
echo "$NON_WEB_RESULT" | grep -q 'UNSAFE_NAVIGATION'
+STEP="safe-input-credential-link"
if CREDENTIAL_RESULT="$("$CLI" --session qa click --role link --name 'Credential-bearing URL')"; then
echo "credential-bearing browser URL was not blocked" >&2
fail
fi
echo "$CREDENTIAL_RESULT" | grep -q 'UNSAFE_NAVIGATION'
+STEP="safe-input-suspicious-link"
if SUSPICIOUS_RESULT="$("$CLI" --session qa click --role link --name 'Suspicious installer')"; then
echo "suspicious installer link was not blocked" >&2
fail
fi
echo "$SUSPICIOUS_RESULT" | grep -q 'UNSAFE_RESOURCE_TYPE'
+STEP="safe-input-scripted-navigation"
"$CLI" --session qa click --role button --name 'Scripted non-web navigation' | grep -q '"clicked"'
"$CLI" --session qa wait --url /designers/dashboard --settled --timeout 10000 | grep -q 'Designers Dashboard'
+STEP="safe-input-download"
+"$CLI" --session qa click --role link --name 'Download fixture' | grep -q '"clicked"'
+sleep 1
+BLOCKED_DOWNLOAD_REPORT="$("$CLI" --session qa qa report)"
+STEP="safe-input-download-report"
+echo "$BLOCKED_DOWNLOAD_REPORT" | grep -q '"kind":"download-blocked"'
+echo "$BLOCKED_DOWNLOAD_REPORT" | grep -q '/download.txt'
+"$CLI" --session qa qa clear | grep -q '"cleared"'
STEP="artifacts-visual"
"$CLI" --session qa screenshot --output viewport.png | grep -q '"name":"viewport.png"'
"$CLI" --session qa screenshot --full-page --output full-page.png | grep -q '"name":"full-page.png"'
diff --git a/apps/headless/main.swift b/apps/headless/main.swift
index 50a98d4..57962b4 100644
--- a/apps/headless/main.swift
+++ b/apps/headless/main.swift
@@ -706,6 +706,12 @@ final class BrowserWindowController: NSWindowController, NSWindowDelegate,
decisionHandler(.cancel)
return
}
+ if navigationAction.shouldPerformDownload {
+ // Convert explicit download intent into WKDownload so the
+ // delegate below can classify and cancel it deterministically.
+ decisionHandler(.download)
+ return
+ }
// Hand non-web schemes (mailto:, facetime:, app links…) to the system.
if let url = navigationAction.request.url, let scheme = url.scheme?.lowercased(),
!["http", "https", "file", "about", "data", "blob", "javascript"].contains(scheme) {
@@ -721,10 +727,17 @@ final class BrowserWindowController: NSWindowController, NSWindowDelegate,
if let response = navigationResponse.response as? HTTPURLResponse {
qaBridge.store.append(kind: "response", url: response.url?.absoluteString,
method: "GET", status: Double(response.statusCode))
+ if response.value(forHTTPHeaderField: "Content-Disposition")?
+ .lowercased().contains("attachment") == true {
+ decisionHandler(.download)
+ return
+ }
}
if !navigationResponse.canShowMIMEType {
- showToast("Can’t display this file type")
- decisionHandler(.cancel)
+ // Unsupported navigation responses would otherwise become
+ // implicit downloads. Route them through the same fail-closed
+ // cancellation and diagnostic path.
+ decisionHandler(.download)
return
}
decisionHandler(.allow)
diff --git a/docs/roadmap/improvements-backlog.md b/docs/roadmap/improvements-backlog.md
index f5e1697..7dfb897 100644
--- a/docs/roadmap/improvements-backlog.md
+++ b/docs/roadmap/improvements-backlog.md
@@ -287,7 +287,10 @@ screenshot-plan caps and 96 px deduplication, budget text fallback, and stale
element-reference errors. Recording coverage now locks every format/quality
argument mapping, strict executable discovery, consecutive-failure aborts, and
bounded stop timeouts; capabilities and oversized socket requests are covered.
-Peer-credential and host navigation/download classification gaps remain.
+Linux CI also connects through a deliberately different uid and requires
+`PEER_DENIED`; Linux and macOS E2E assert `navigation-blocked` and
+`download-blocked` diagnostics respectively. **Done:** every gap named in D2
+now has direct regression coverage.
**D3. Web CI:** ~~`next build` + eslint on PR (site can break invisibly today).~~
**Done** — the `web` job in `ci.yml` runs `pnpm --filter @headless/web lint`