Skip to content

Commit b554850

Browse files
Merge pull request #11603 from Kilo-Org/feat-macos-network-sandbox
feat(sandbox): enforce macOS network isolation
2 parents 614be76 + d017c18 commit b554850

61 files changed

Lines changed: 1608 additions & 37 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@kilocode/cli": minor
3+
"kilo-code": minor
4+
---
5+
6+
Block outbound network access from agent commands and in-process HTTP tools with the optional macOS sandbox, with a Sandboxing setting to allow network access when needed.

.github/workflows/check-opencode-annotations.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@ jobs:
4141
- name: Check Effect Promise facade allowlist
4242
run: bun run script/check-opencode-promise-facades.ts
4343

44+
- name: Check model tool network boundary
45+
run: bun run script/check-model-tool-network.ts
46+
4447
- name: Check workflow allowlist
4548
run: bun run script/check-workflows.ts
4649
# kilocode_change end
Lines changed: 3 additions & 0 deletions
Loading

packages/kilo-sandbox/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@
1414
],
1515
"scripts": {
1616
"typecheck": "tsgo --noEmit",
17-
"test": "bun test --timeout 30000"
17+
"test": "bun test --timeout 30000",
18+
"test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml"
1819
},
1920
"dependencies": {
2021
"effect": "catalog:"

packages/kilo-sandbox/src/backend.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Effect, PlatformError, Scope } from "effect"
22
import { ChildProcess } from "effect/unstable/process"
33
import { current } from "./context"
44
import type { Profile } from "./profile"
5+
import { assertProcessNetwork, networkEnvironment } from "./network"
56
import { seatbelt } from "./seatbelt"
67

78
export interface Launch {
@@ -47,16 +48,18 @@ const backend = select()
4748
function environment(profile: Profile, launch: Launch) {
4849
const source = { ...launch.environment, ...profile.environment.set }
4950
const denied = new Set(profile.environment.deny)
50-
return Object.fromEntries(
51-
Object.entries(source).filter(([key, value]) => value !== undefined && !denied.has(key)),
52-
) as Record<string, string>
51+
const entries = Object.entries(source).filter(
52+
(entry): entry is [string, string] => entry[1] !== undefined && !denied.has(entry[0]),
53+
)
54+
return networkEnvironment(profile, Object.fromEntries(entries))
5355
}
5456

5557
export function prepare(launch: Launch) {
5658
return Effect.gen(function* () {
5759
const profile = yield* current
5860
if (!profile) return launch
5961
const next = { ...launch, environment: environment(profile, launch) }
62+
yield* assertProcessNetwork(profile, launch.command)
6063
if (!backend.support.available) return next
6164
return yield* backend.prepare(profile, next)
6265
})

packages/kilo-sandbox/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export type { Profile } from "./profile"
22
export { assertWrite, enabled, run } from "./context"
33
export { decorateFileSystem } from "./filesystem"
4+
export { assertNetwork, decorateHttpClient, httpLayer as networkHttpLayer } from "./network"
45
export { prepareCommand } from "./backend"
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { Effect, Layer, PlatformError } from "effect"
2+
import { HttpClient, HttpClientError, type HttpClientRequest } from "effect/unstable/http"
3+
import { current } from "./context"
4+
import type { Profile } from "./profile"
5+
6+
const proxies = new Set([
7+
"HTTP_PROXY",
8+
"HTTPS_PROXY",
9+
"ALL_PROXY",
10+
"NO_PROXY",
11+
"http_proxy",
12+
"https_proxy",
13+
"all_proxy",
14+
"no_proxy",
15+
])
16+
17+
function target(value: string) {
18+
if (!URL.canParse(value)) return value
19+
const url = new URL(value)
20+
return url.origin
21+
}
22+
23+
function denied(value: string, method: string) {
24+
return PlatformError.systemError({
25+
_tag: "PermissionDenied",
26+
module: "Sandbox",
27+
method,
28+
pathOrDescriptor: target(value),
29+
description: "Sandbox denied outbound network access",
30+
})
31+
}
32+
33+
function unsupported(value: string, method: string) {
34+
return PlatformError.systemError({
35+
_tag: "BadResource",
36+
module: "Sandbox",
37+
method,
38+
pathOrDescriptor: target(value),
39+
description: "Sandbox proxy network mode and allowedHosts are not supported",
40+
})
41+
}
42+
43+
function unsupportedProfile(profile: Profile) {
44+
return profile.network.mode === "proxy" || profile.network.allowedHosts.length > 0
45+
}
46+
47+
export function networkEnvironment(profile: Profile, environment: Record<string, string>) {
48+
if (profile.network.mode === "allow" && profile.network.allowedHosts.length === 0) return environment
49+
return Object.fromEntries(Object.entries(environment).filter(([key]) => !proxies.has(key)))
50+
}
51+
52+
export function assertProcessNetwork(profile: Profile, command: string) {
53+
if (!unsupportedProfile(profile)) return Effect.void
54+
return Effect.fail(unsupported(command, "prepareNetwork"))
55+
}
56+
57+
export function assertNetwork(value: string, method = "network") {
58+
return Effect.gen(function* () {
59+
const profile = yield* current
60+
if (!profile) return
61+
if (unsupportedProfile(profile)) yield* Effect.fail(unsupported(value, method))
62+
if (profile.network.mode === "allow") return
63+
yield* Effect.fail(denied(value, method))
64+
})
65+
}
66+
67+
function requestError(request: HttpClientRequest.HttpClientRequest, description: string) {
68+
return new HttpClientError.HttpClientError({
69+
reason: new HttpClientError.TransportError({ request, description }),
70+
})
71+
}
72+
73+
function assertRequest(request: HttpClientRequest.HttpClientRequest) {
74+
return Effect.gen(function* () {
75+
const profile = yield* current
76+
if (!profile) return request
77+
if (profile.network.mode === "allow" && profile.network.allowedHosts.length === 0) return request
78+
const description = unsupportedProfile(profile)
79+
? "Sandbox proxy network mode and allowedHosts are not supported"
80+
: "Sandbox denied outbound network access"
81+
return yield* Effect.fail(requestError(request, description))
82+
})
83+
}
84+
85+
export function decorateHttpClient(http: HttpClient.HttpClient): HttpClient.HttpClient {
86+
return HttpClient.mapRequestEffect(http, assertRequest)
87+
}
88+
89+
export const httpLayer = Layer.effect(HttpClient.HttpClient, Effect.map(HttpClient.HttpClient, decorateHttpClient))

packages/kilo-sandbox/src/seatbelt-base.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -97,9 +97,7 @@ export const base = `(version 1)
9797
(local-name "com.apple.cfprefsd.agent"))
9898
(allow user-preference-read)
9999
100-
; network is not confined by the file-level sandbox; allow it fully
101-
(allow network-outbound)
102-
(allow network-inbound)
100+
; system services required by common command-line runtimes
103101
(allow system-socket)
104102
(allow mach-lookup
105103
(global-name "com.apple.bsd.dirhelper")
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import type { Profile } from "./profile"
2+
3+
export function networkPolicy(profile: Profile) {
4+
if (profile.network.mode === "allow") {
5+
return "; sandbox network mode: allow\n(allow network-outbound)\n(allow network-inbound)"
6+
}
7+
return [
8+
`; sandbox network mode: ${profile.network.mode}`,
9+
'(deny network-outbound (with message "Sandbox denied outbound network access"))',
10+
"(allow network-inbound)",
11+
].join("\n")
12+
}

packages/kilo-sandbox/src/seatbelt.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Effect } from "effect"
33
import type { Backend, Launch, Support } from "./backend"
44
import type { PathRule, Profile } from "./profile"
55
import { base } from "./seatbelt-base"
6+
import { networkPolicy } from "./seatbelt-network"
67

78
const executable = "/usr/bin/sandbox-exec"
89

@@ -47,7 +48,12 @@ function policy(profile: Profile) {
4748
? ""
4849
: `(allow file-write*\n (require-all\n (require-any ${allow.join(" ")})\n ${[...deny, ...names].join("\n ")}\n )\n)`
4950
return {
50-
value: [base, "; reads are not confined by the file-level sandbox\n(allow file-read*)", write].join("\n"),
51+
value: [
52+
base,
53+
networkPolicy(profile),
54+
"; reads are not confined by the file-level sandbox\n(allow file-read*)",
55+
write,
56+
].join("\n"),
5157
params,
5258
}
5359
}

0 commit comments

Comments
 (0)