Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
2 changes: 2 additions & 0 deletions apps/headless/Tests/Fixtures/dashboard.html
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,9 @@ <h2>Continue setup</h2>
<p><a href="about:blank">Non-web browser URL</a></p>
<p><a href="http://qa:secret@127.0.0.1/private">Credential-bearing URL</a></p>
<p><a href="/suspicious-installer.dmg">Suspicious installer</a></p>
<p><a href="/download.txt" download>Download fixture</a></p>
<button onclick="location.href='data:text/html,blocked'">Scripted non-web navigation</button>
<button onclick="location.href='about:blank'">Scripted host-blocked navigation</button>
<button onclick="location.href='/next'">Continue</button>
</section>
</main>
Expand Down
61 changes: 61 additions & 0 deletions apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions apps/headless/Tests/fixture-server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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'});
Expand Down
6 changes: 5 additions & 1 deletion apps/headless/Tests/linux-e2e.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"'
Expand Down
13 changes: 13 additions & 0 deletions apps/headless/Tests/macos-e2e.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"'
Expand Down
17 changes: 15 additions & 2 deletions apps/headless/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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)
Expand Down
5 changes: 4 additions & 1 deletion docs/roadmap/improvements-backlog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
Loading