blackice

Configuration

blackice.toml, and the event a hook gets and how it answers. A blackice.toml that works in 1.0 keeps working in every 1.x.

blackice.toml

# ~/.config/blackice/blackice.toml
mode = "ask"            # "accept-all", "ask" or "block-all"
timeout_seconds = 10    # 1 to 120; no answer in time means deny
apple_services = true   # macOS's own services reach Apple's hosts without a rule (the default)
loopback = true         # connections to 127.0.0.0/8 and ::1 pass in every mode (the default)

# Permanent rules. Checked before anything is asked.
[[allow]]
app = "com.example.agent"      # signing identifier
team = "ABCDE12345"            # Team ID
hosts = ["api.example.com"]

# Asked about each connection that waits. It answers on its last line of output.
[[hook]]
on = "connection"
program = "/Users/me/.local/bin/fx"
app = "com.vercel.fx"
team = "JW6Y669B67"
args = ["ask", "--no-save", "--no-color", "Use the blackice skill to decide whether to approve the pending connection"]
env = ["HOME=/Users/me/.config/blackice/agent", "FX_DISABLE_KEYCHAIN=1"]
instructions = "fx"            # the daemon writes fx's skill from blackice into the event's directory

The first blackice start writes this file, and blackice agent connect adds its agent's hook and rule. start loads the file after Touch ID; an edit changes nothing until the next start. blackice mode and the menu's Mode switch the mode, and who answers, in what blackice holds and not in the file, and start keeps that switch until the file's own mode or answers changes. An unknown key or a value of the wrong type is an error, never a guess. blackice validate PATH checks a file.

Rules

Each [[allow]] rule names one app and the hosts it may reach, in every mode and on any port.

  • app and team are checked against the signature of the running code. Leave team out only for Apple's own programs, which macOS signs without one.
  • cdhash, in place of app and team, names one build of ad hoc signed code.
  • hosts takes host names (api.example.com), *.example.com (every subdomain, not example.com itself), and addresses and ranges, at most 256 entries.
  • A host name counts only when the address the app connects to was among that name's DNS answers. Otherwise the connection is dropped as address_mismatch, without asking: an approval would not let it through. Ask mode asks only about an app and host that nothing allows yet, and the approval counts from the app's next connection.
  • On port 443 the TLS server name the app sends must match too, or the connection is dropped as server_name_mismatch.
  • With Encrypted Client Hello, blackice sees only the provider's front end, such as cloudflare-ech.com. A rule or approval for that name lets the app reach every site behind it.

codesign -dv shows an app's identity. Apple's own programs show TeamIdentifier=not set; Apple's developer tools, from the Command Line Tools or Xcode, show 59GAB85EFG (git over HTTPS connects as com.apple.git-remote-http).

$ codesign -dv /Applications/Tailscale.app/Contents/Library/SystemExtensions/*.systemextension 2>&1 | grep -E '^(Identifier|TeamIdentifier)='
Identifier=io.tailscale.ipn.macsys.network-extension
TeamIdentifier=W5364U7YZB

Ad hoc signed code

Tools installed with Homebrew, uv, pipx, npm, nvm or go install are often signed ad hoc, with no developer identity, so no app rule can name them. A rule names one build by its cdhash instead: the CDHash that codesign -dvvv prints, also in the blackice approve line that blackice pending shows for it.

$ codesign -dvvv /opt/homebrew/bin/gh 2>&1 | grep -E '^(Signature|CDHash)='
Signature=adhoc
CDHash=8fbec897bc4d2bf005bff3945dc800000ded2752
[[allow]]
cdhash = "8fbec897bc4d2bf005bff3945dc800000ded2752"   # Homebrew's gh 2.103.3
hosts = ["api.github.com", "uploads.github.com"]
  • cdhash is 40 lower-case hex digits, and takes no team. hosts works as in any rule, with the same checks, * and addresses included.
  • The rule covers that one build, in ask and block-all. An upgrade or a rebuild changes the cdhash: the rule stops matching, ask mode asks again, and the rule needs the new cdhash.
  • A program in a folder macOS protects is named by its cdhash too, and a cdhash rule covers it the same way.
  • The cdhash names the program file, not what it loads. Ad hoc code has no hardened runtime, so a program that starts it with DYLD_INSERT_LIBRARIES, or swaps a library it loads from /opt/homebrew, runs its own code under the rule. Give a cdhash rule only the hosts that tool needs.

Never an interpreter. A cdhash rule does not count for python, node, bun, deno, ruby, perl, php, lua, tclsh, R, julia, osascript or a shell, with or without a version in the name (python3.14): a rule for one would let every script it runs reach those hosts, a malicious dependency's included. Their connections are decided as if the rule were not there, and the log's reason ends in ;interpreter. blackice approve --cdhash still approves one, for at most 24 hours. blackice tells an interpreter by its file name, and a copy under another name keeps the interpreter's cdhash, so never put an interpreter's cdhash in a rule.

Any destination

hosts = ["*"], alone in the list, lets the app reach anything: any host, address and port, with no checks.

It is for VPN clients and DNS proxies, which connect by address. With Tailscale's DNS on, macOS sends its lookups through Tailscale's network extension with no host name, so without this rule names stop resolving in ask and block-all and Tailscale goes offline. This is the rule for the standalone Tailscale build, from tailscale.com; check yours with codesign -dv:

[[allow]]
app = "io.tailscale.ipn.macsys.network-extension"
team = "W5364U7YZB"
hosts = ["*"]

A browser's traffic comes from its network process: Brave's from com.brave.Browser.helper (team KL8N8XSYF4), Safari's from com.apple.WebKit.Networking, which also carries the web views of every other app.

The risk. Everything that app does is allowed. An agent that can drive it, by opening a URL in the browser for example, can use it to reach anything and send data anywhere. Give * only to an app you would allow in accept-all anyway.

Addresses and ranges

For an app that reaches a few networks you know, such as ssh to your Tailscale machines, addresses and CIDR ranges are narrower than *:

[[allow]]
app = "com.apple.ssh"
hosts = ["100.64.0.0/10", "fd7a:115c:a1e0::/48", "10.0.0.5", "*.ts.net"]
  • An address entry matches a connection to that address, named or not, with no lookup. On port 443 it does not check the server name, so an address many sites share, such as a CDN's, lets the app reach all of them.
  • IPv4 has no leading zeros (010.0.0.1 is an error). A range has no bits set after its prefix: 10.0.0.0/8, not 10.0.0.5/8.
  • 0.0.0.0/0 and ::/0 are errors: write *. So are interfaces (%en0) and IPv4-mapped addresses (::ffff:10.0.0.5).

Loopback

With loopback = true, the default, connections to 127.0.0.0/8 and ::1 pass in every mode, before any rule. loopback = false filters them like any other connection.

The trade-off. With loopback allowed, any app can use a proxy that listens on the Mac. The proxy's own connections are still filtered under its identity, but a VPN client with a * rule that offers a local proxy relays for every app.

Connections with no name

A connection is named by the host the app asked for, by the Mac's DNS answers for its address, or by its TLS server name on port 443. In ask mode a TCP connection with none of these, such as ssh to 100.66.32.75, is asked about by its address, and an approval of that address covers only connections to it that have no name. A link-local address (fe80::…) is asked about with no interface, since macOS gives the filter none: its approval covers that address on every network the Mac is on. Block-all drops them unless an address entry or * covers them. UDP with no name (WebRTC, games, network discovery, QUIC) and multicast and broadcast connections are dropped with no question.

Wi-Fi with a sign-in page. On a network that shows a page before it lets you online (hotels, airports, trains), nothing reaches the internet until you sign in, so in ask mode the agent cannot answer, and the page waits until its connections time out. Switch to accept-all to sign in, then back: blackice mode accept-all, sign in, blackice mode ask.

Encrypted DNS. In ask and block-all, macOS's encrypted DNS (from com.apple.mDNSResponder, on port 443 or 853) is dropped as encrypted_dns, and macOS falls back to plain DNS, whose answers name the addresses apps connect to. To use an encrypted resolver on purpose, give it a rule; more connections are then asked about by address.

[[allow]]
app = "com.apple.mDNSResponder"
hosts = ["dns.google"]

An app updated while it runs keeps the identity blackice checked, for as long as that process runs. An app that blackice first sees after its file was removed is named by its cdhash, as below, until you restart it.

Programs in folders macOS protects. macOS keeps the filter from reading ~/Documents, ~/Desktop, ~/Downloads, iCloud Drive and removable and network volumes, so it cannot check the signature of a program that runs from one of them, such as a tool a project installs in node_modules or a build in target/. blackice names that program by the cdhash of the code the kernel loaded, as it names ad hoc signed code: no app rule matches it, a cdhash rule or an approval covers that exact build, and otherwise ask mode asks about it and block-all drops it. For an app rule to match a tool, run it from another folder, such as ~/.local/bin or a project outside those folders. Before 1.0.1 these connections were dropped as unknown_identity, with no question.

macOS's own services

With apple_services = true, the default, a built-in list lets macOS's own services reach Apple's hosts in ask and block-all without a rule: iCloud, Apple Account, push notifications, iMessage and FaceTime, Find My, Software Update, the App Store, Siri and Spotlight suggestions, Maps, network time, Gatekeeper, certificate checks and others.

  • Each entry is one program Apple signed as part of macOS, and the Apple domains it uses. The same identifier with a Team ID is someone else's code and gets nothing.
  • Tools that run commands or fetch whatever they are given are never on it: curl, nscurl, python3, perl, ruby, the shells, ssh, scp, nc, git, osascript, Safari, WebKit's networking process and nsurlsessiond.
  • trustd and ocspd, which check certificates, may reach Apple and the main certificate authorities (DigiCert, Entrust, Let's Encrypt, Sectigo, GlobalSign, GoDaddy, Google, Amazon and Microsoft), not any host.
  • A service missing from the list is asked about, or dropped in block-all. So is an account for another provider, such as a Google calendar synced by com.apple.dataaccess.dataaccessd, which needs its own rule.

apple_services = false turns the list off. In block-all, iCloud, updates and push notifications then stop until you add rules for them.

Hooks

In ask mode the daemon runs each [[hook]] with on = "connection" for a connection that waits. The agent a hook runs needs a rule to reach its model, or it waits on its own approval; agent connect adds both.

  • program is an absolute path. The daemon starts it with no shell, and macOS runs it only when its signature names app and team: a Developer ID signature with that Team ID, or, with no team, Apple's own code. Otherwise it is killed before it runs, and the log says hook_refused. Name the agent's own executable, not a script or wrapper that starts it. With System Integrity Protection off, macOS skips that check: the daemon then checks the program only once it runs, and a program put in its place can start the agent with arguments of its own before that check. Keep SIP on.
  • args lists the arguments, as strings. Nothing from the event is ever put in them.
  • env adds NAME=value strings to the environment. A name may not start with BLACKICE_.
  • stdin = "none" keeps the event off stdin, so it is only in its file, for a program that names no instructions. The default, "event", sends it on stdin too.
  • instructions ("fx", "claude-code", "codex" or "opencode") has the daemon pass that agent its instructions, from blackice itself, and your notes, on its command line or in its environment. The agent gets the event on stdin, whatever stdin says, runs in /var/empty, and gets no file. The daemon also looks in the agent's home first: a file there that Codex or fx would load as instructions, and that blackice did not put there, keeps the hook from starting (hook_refused, reason agent_home), and one that appears while it runs voids its answer. The connection then waits for you.

run = '…', in place of program, is a line for /bin/sh -c. Nothing checks what it starts, so any program running as you can put something else in its place. It takes no app, team, args, env, stdin or instructions, and gets the event as a file too, as a program that names no instructions does (below).

The agent table

[agent]
answers = "claude-code"
notes = """
For work I run a cloud CLI with Homebrew's python (/opt/homebrew/bin/python3); it reaches api.cloud.example.
Deny analytics and tracking hosts, such as stats.example.com, whatever the app.
"""
  • answers names the agent whose hook runs: "fx", "claude-code", "codex", "opencode", or "you" for none. Hooks without instructions run either way. With no answers, every hook runs.
  • notes is your guidance for the agent: what you use for work, what you never want reached. The daemon passes it to the agent after its instructions, and the agent follows it for judgment calls, never over its safety rules. A hook that names no instructions gets it as notes.md beside the event's file. Plain text, at most 4 KiB, and the only value that may span lines. Like the hooks, it changes only with your Touch ID.

The hook's environment

The daemon builds the environment a hook starts with:

VariableValue
HOME, USER, LOGNAMEFrom the user database, for the user the daemon runs as.
TMPDIRThat user's temporary directory.
PATH/usr/bin:/bin:/usr/sbin:/sbin
LANGen_US.UTF-8
BLACKICE_EVENT_IDThe event's ID.
BLACKICE_EVENT_FILEThe event's file, for a hook that names no instructions.

A program hook's env may replace any of these but the BLACKICE_ ones.

The event a hook receives

A hook that names an agent's instructions gets the event on stdin only, and no file: up to 1.0.2 it got the event, the instructions and the notes as files in a folder of yours, which any program running as you could change while the agent read them. For any other hook the daemon writes the event to event.json, in a private directory that is the hook's working directory and is deleted afterwards, and names the file in BLACKICE_EVENT_FILE. Unless the hook says stdin = "none", it gets the event on stdin too.

{
  "event": "connection",
  "id": "ev-7f3a91c2",
  "app": { "signing_id": "com.example.tool", "team_id": "ABCDE12345", "path": "/Applications/Tool.app" },
  "host": "example.com",
  "ip": "203.0.113.7",
  "port": 443,
  "deadline": "2026-11-02T14:36:11Z"
}

Code with no identity blackice could check, signed ad hoc or run from a folder macOS protects, has "cdhash" in app in place of signing_id and team_id. For a connection with no name, host is the address, the same as ip. For an interpreter (python, node, ruby, a shell and the others blackice knows), app also has "script": the script its command line names, such as /Users/me/.local/bin/mailctl, or -m module for python -m. Only the script is read, never the other arguments. A process that wrote a title over its command line, as node does for npm, has no script left to read: app then has "title" in its place, such as npm run build, cut to 64 bytes. Any program can start an interpreter with any script name, and a process gives itself any title, so script and title tell tools apart for the agent and your notes, and no rule names them. The agent's answer covers only the script its event named, and for an event with no script, the interpreter whenever no script can be read (interpreters' answers). The event carries no token: that stays with the extension and the daemon.

How a hook answers

When the hook exits with status 0 before the deadline, the daemon reads its last non-empty line. It must be exactly one of these:

LineAnswer
denyDrop the waiting connections, and for 10 minutes the app's new ones to that host.
approve <N>mApprove for N minutes, from 1 to 1440.
approve <N>hApprove for N hours, from 1 to 24.

The answer is for the event's app and host, never for anything the hook prints. Anything else is no answer, and the connection is dropped at its deadline: a line such as approve 1h. or **deny**, more than 64 KiB on stdout, an exit status other than 0, or a hook still running at the deadline.

A deny is remembered for 10 minutes, whoever gave it: the app's new connections to that host are dropped at once, with no hook. A rule still allows what it names, and an approval replaces the denial. No answer in time is not remembered.

The event log, ~/Library/Logs/blackice/events.jsonl, records each hook_answer, or hook_no_answer with the reason. Of an unrecognized line it keeps only the first 24 characters, and nothing else of a hook's output.

edit this page