vibe coded with ❤️

Documentation

How Gjallarhorn works on a Mac, and how to deploy it to managed ones.

Concept app. A working exploration of an idea, built and shared to show what's possible, not a supported commercial product. Expect rough edges, no SLA and no roadmap guarantees. It has been tested with demo data and for a day on one MDM-managed work Mac, not at scale. Try it on test Macs first; feedback is very welcome.

Overview

Gjallarhorn is a content-filter system extension plus a menu bar app. It shows every connection, can ask before new ones, blocks what you deny, explains the path a connection takes (app, DNS, proxy and PAC, content filters, route or VPN, destination) and exports rule sets for Jamf Pro or Intune. It has no AI and no telemetry; rules, history and recordings stay on the Mac, and the connection history is visible to administrators only.

Tested so far: version 0.1.0 is signed, notarized and unit-tested (179 tests). It has run with demo data and for a day on one MDM-managed work Mac (Jamf schema, package deployment, managed rules and modes). Hidden mode, helpdesk unlock, the audit log and recording under a profile have not yet been tested on a managed Mac. Never use ask mode with a deny fallback on a Mac you depend on.

Requirements

  • macOS 26 or later, Apple silicon
  • Approval of the network extension on first start, unless the deployment profile is installed first (needs a user-approved MDM enrolment or supervision)

Installation

Download Gjallarhorn-0.1.0.pkg (2.8 MB) and open it. It installs /Applications/Gjallarhorn.app. The package is signed (Developer ID) and notarized.

shasum -a 256 Gjallarhorn-0.1.0.pkg
# e87618a329b465250352b05701732bc9b4553e4b1ac22eb73788df99a0ae0e08
spctl --assess --type install -v Gjallarhorn-0.1.0.pkg   # → Notarized Developer ID

The installer is self-contained. The Jamf Pro schema, the example and deployment profiles, the helpdesk tool, the policy test, evidence and uninstall scripts and this guide are installed with the app, in /Applications/Gjallarhorn.app/Contents/Resources/Admin/. Print the path with --cli admin, or choose Show Admin Files in the menu bar item. File names below are relative to that folder.

First Steps

  1. Install the pkg and open Gjallarhorn. It starts in monitor-only mode: nothing is blocked.
  2. Look at Connections, Applications and Network. Use Rules ▸ Preview to see what a rule set would have done.
  3. Roll out profiles in monitor mode first.
Gjallarhorn connections with the flow pane open
Connections with the flow pane open: made-up demo data.

Deployment

Gjallarhorn is configured with a configuration profile (Jamf Pro: Application & Custom Settings, Intune: custom preference payload), preference domain com.spectrechen.gjallarhorn. The network extension reads the profile itself (/Library/Managed Preferences/com.spectrechen.gjallarhorn.plist, re-read every 5 seconds), so enforcement never depends on the app being open or visible.

  1. The pkg: deploy the notarized installer with a Jamf policy or Intune. The app starts the first time someone logs in and installs or upgrades its extension by itself.
  2. The deployment profile Jamf/deployment-gjallarhorn.mobileconfig: a system extension policy that allows the extension without user approval and marks it non-removable from the UI, plus a content filter payload that switches the filter on. Deploy it before or together with the pkg.
  3. The settings profile: upload Jamf/gjallarhorn-schema.json to Jamf Pro (Computers › Configuration Profiles › Application & Custom Settings › Upload, domain com.spectrechen.gjallarhorn) or start from Jamf/example-allowlist.mobileconfig. Roll out in monitor mode first.
  4. Keep the profiles non-removable and the app and profiles in the same scope.

Status per Mac: /Applications/Gjallarhorn.app/Contents/MacOS/Gjallarhorn --cli info, run as root from an extension attribute or by the helpdesk in a remote session. After upgrades the system lists older extension versions as "waiting to uninstall on reboot"; a restart cleans them up.

Allowlist example (only Jamf, Intune, Edge and Safari may connect): StartMode: block and ManagedRules for team 483DWKW443 (Jamf), team UBF8T346G9 (Microsoft), the Safari identifiers, software update, DNS and the local network. For compliance, scope a second profile with a stricter rule set to a smart group of non-compliant Macs; the extension applies it within seconds.

Tested on one MDM-managed Mac: the Jamf schema, package deployment, managed rules and modes. Not yet tested there: hidden mode, helpdesk unlock, the audit log and recording under a profile (run on an unmanaged Mac).

Profile Keys

KeyTypeMeaning
ManagedRulesArray of dictsRules that always win and cannot be changed on the Mac. See below.
StartModeStringmonitor, ask, blocklist, allow, block. Forces the mode; the user cannot change it. block = only managed allow rules connect. blocklist = everything is allowed except what a Deny rule matches (a narrower Allow rule is an exception): the mode for protocol rules such as "no IPv6", "no FTP" or "no Telnet".
EndUserVisibilityBooltrue shows the app, false hides it. Not set: see Visibility.
PromptTimeoutSecondsIntHow long a prompt waits.
PromptFallbackStringallow or deny: the answer when nobody answers, and when the app is hidden and a connection matches no rule.
HistoryRetentionDaysIntHow long connections are kept.
ManagementLifelineBoolDefault true: five Apple management processes stay allowed in block mode. false removes that safety net.
AllowCaptureBoolDefault false under a profile (without a profile an administrator may record). true lets an administrator at the Mac record one app's next connections (max 30 minutes, max 50 MB, 5 MB by default). Start and stop are in the audit log.
LogDecisionsStringoff (default), denied or all: one unified-log line per decision. Profile applied or removed, unlock events and refused changes are always logged.
LogDestinationsBoolDefault false: destinations in log lines are private. true writes them in the clear.
BlockMessageStringOptional. If set, the user gets a macOS notification with this text when the policy blocks a connection, naming the app and destination. Not set: the app stays silent.
BlockMessageIntervalMinutesIntAt most one notification per this many minutes (default 15); it says how many connections were blocked in between.
UnlockPublicKeysArray of dictsHelpdesk keys, one per technician: Key (base64 Ed25519 public key), Name, NotAfter (date; measured on the Mac's clock, which a local administrator can set back: to really revoke a key, remove it from the profile). Without a valid key unlock is not possible. A single UnlockPublicKey string is still read.
UnlockMaxMinutesIntLongest unlock a code can give (default 480).

ManagedRules entries

Action (allow / deny), AppType (apple, any, team, identifier, path; default identifier), AppValue (team ID, bundle or signing identifier, or path), AppTeam (optional, pins an identifier to a developer), DestinationType (anything, domain, host, network), DestinationValue, Port (with PortTo for a range, e.g. FTP 20 and 21; the text 8000-8100 also works), IPVersion (4 or 6), Protocol (tcp / udp), Note. Entries that cannot be read are skipped; the info command lists them.

Gjallarhorn Rules tab
Rules tab with managed and user rules (demo data).

Visibility

PolicyEndUserVisibilityApp
none (unmanaged Mac)not setvisible: menu bar, windows, prompts
rules or StartMode: blocknot sethidden: no menu bar item, no windows, no prompts
rules or StartMode: blocktruevisible
anyfalsehidden
only settings such as PromptTimeoutSecondsnot setvisible

While the app is hidden, connections that match no rule get PromptFallback (default deny) without a question, and the app cannot change rules, policy or history.

Staying Manageable

A Mac in block mode must still be able to receive its next profile. That needs Apple's push service (apsd, *.push.apple.com:5223), the MDM client (mdmclient), certificate checks (trustd) and name resolution (mDNSResponder). Gjallarhorn therefore always allows these processes plus cloudconfigurationd, the management lifeline, identified by Apple's signature, not by name. An explicit managed deny rule for one of them still wins, and ManagementLifeline: false switches it off.

The lifeline cannot know your third-party management agents. Allow them in the profile (Jamf team 483DWKW443, Microsoft/Intune team UBF8T346G9, your VPN and security tools).

Do not use "all Apple processes" in an allowlist: curl, ssh, nc and python3 are Apple-signed too. Allow the Apple apps you need by identifier.

Swapping or removing a profile is safe: while it is gone the Mac returns to the user's own mode, and a new profile applies within 5 seconds. Before a block profile: run it in monitor mode on a test Mac and read Connections with the filter denied, then check --cli info and the extension attribute on a pilot group.

The boot gap

Until macOS has started the network extension after a restart, the Mac is not filtered. That is the operating system's start-up order. Gjallarhorn applies the profile before it judges its first connection and reports it: --cli info shows "Filter started: N s after boot". If a Mac must never connect before the filter runs, that needs another layer, for example a VPN with "include all networks".

Helpdesk Unlock

Break glass for a Mac locked out of the network, on a live call. Every technician has their own key pair, so one leaked or departed key is removed from the profile without touching the others.

  1. Once per technician: Tools/gjallarhorn-unlock keygen alice.key --name alice --valid-days 90 and add the printed entry to UnlockPublicKeys. The private key stays with the technician.
  2. On the Mac: /Applications/Gjallarhorn.app/Contents/MacOS/Gjallarhorn --cli unlock-challenge prints a request code, valid for 5 minutes.
  3. Technician: Tools/gjallarhorn-unlock issue alice.key <request code> 30 (add --ui to show the app on that Mac) and sends the code (GJ1.…) back.
  4. On the Mac: … --cli unlock <code>. The network is unrestricted for the minutes in the code, at most UnlockMaxMinutes. --cli unlock-end ends it early.

The Mac holds only public keys, so nothing on it can make a code. A code is signed over that Mac's own one-time request code, so it works only there and only once. Durations are measured on a clock that ignores date changes and runs through sleep; an unlock ends at a reboot.

Connection Filter

The field above the Connections list takes a filter expression (press ⌘F, or click the ? next to it for the list of fields; a click on an example puts it into the field). A bare word searches app, host and address. Combine conditions with &&, ||, ! (or and, or, not) and parentheses. Operators: == != < <= > >= contains matches (regular expression) in {a b c}; * and ? are wildcards in ==.

FieldMatchesExample
appApp name; also app.id, app.team, app.pathapp == "Safari"
hostThe name the app asked forhost contains apple.com
ip.addrDestination address; wildcards and CIDRip.addr == 10.0.0.0/8
portDestination port; also tcp.port, udp.portport in {80 443}
protoTransport: tcp or udpproto == udp
resultallow or denyresult == deny
reasonWhy: rule, mode, answer, timeout, …reason == mode
pidProcess IDpid == 4242
ageHow long ago: 30s, 5m, 2h, 1dage < 5m
denied, allowedFlags: only denied or only allowed connectionsdenied
app.apple, ip.private, ip.v6Flags: Apple-signed app, local network, IPv6!app.apple && !ip.private

The Presets menu has ready-made filters, among them proto == tcp and proto == udp. Live keeps the list updating while a row is selected or you scroll; switch it off to hold the list still while you read, and new connections wait behind the newer — Show button. Pause holds the list until you resume. The same expressions work in --cli and in Rules ▸ Preview.

Recording

An administrator at the Mac can record what one app sends and receives: Connections ▸ right-click ▸ Record Next Connections, or --cli capture <bundle-id|/path> [host], then capture-stop and capture-save <file.pcapng>.

  • Only connections opened after the recording starts are recorded.
  • 5 minutes and 5 MB by default, at most 30 minutes and 50 MB; one recording at a time.
  • The payload is real. The Ethernet, IP and TCP/UDP headers are synthetic and the file says so. TLS stays encrypted; plaintext protocols can contain passwords, so treat the file as confidential.
  • Administrators only. Under a profile it is off unless AllowCapture is true. It cannot be started remotely.

Testing a Destination

Network tab ▸ Test a destination, or --cli test <host> [port]: resolves the name like an app would, opens a connection, does the TLS handshake on port 443 and adds what Gjallarhorn did with the latest connection to that host. The summary names the first step that fails and says when the filter itself denied it.

Gjallarhorn Network tab
Network tab with the test field at the bottom (demo data).

Command Line

/Applications/Gjallarhorn.app/Contents/MacOS/Gjallarhorn --cli <command>; without a command it lists them: info, status, rules, history, mode, policy, managed, add-rule, remove-rule, network, path [host], test <host> [port], tls, simulate <file>, report <file.pdf>, capture, capture-stop, capture-status, capture-save, unlock-challenge, unlock, unlock-end, filter, sysext.

Unified Log

The filter writes to subsystem com.spectrechen.gjallarhorn.filter, category audit:

sudo log show --last 1d --predicate 'subsystem == "com.spectrechen.gjallarhorn.filter" AND category == "audit"'

Always logged: profile applied or removed, unlock codes issued, granted, refused, expired or ended, changes refused because of the profile, an unreadable profile file. Decisions only with LogDecisions. Destinations are private unless LogDestinations is true.

Uninstalling

Scripts/uninstall.sh (run with sudo) removes the system extension through the app, then the app. macOS asks the user to approve the removal unless the deployment profile allows it; with the profile marking the extension non-removable, remove the profile first. --keep-data leaves /Library/Application Support/Gjallarhorn (rules, history, recordings); without it the folder is deleted.

What's Verified

  • 179 unit tests pass; every window and tab opens without a crash (smoke test).
  • Tested with demo data and for a day on one MDM-managed work Mac, not at scale.
  • On that managed Mac: the Jamf schema, package deployment, managed rules and modes work. Hidden mode, helpdesk unlock, the audit log and recording under a profile were run on an unmanaged Mac only.
  • The order of the path stages is unverified against several other content filters.

Terms and third-party notices: License.