The pitch for local-first AI is simple: your documents never leave your hardware. It’s a true claim, and it’s also an incomplete one, because it quietly assumes the hardware itself is secure. Nobody had actually tested that assumption on the machine doing the work — a Mac Mini M4 Pro that runs local inference for a client-facing document-processing deployment, all day, every day. This is what happened when we did.
The honest starting position: local inference doesn’t eliminate security risk, it relocates it. A cloud AI provider runs professional security operations — audited access controls, incident response, a team whose job is exactly this. Choosing to run inference on your own hardware instead means you’ve taken that job on yourself, whether you’ve actually done the work or not. That’s a legitimate architectural choice. It’s only a responsible one if the compensating controls actually exist.
So we checked. Not by reading a security checklist and ticking boxes — by running the actual commands and seeing what happened.
The first pass looked like most first passes look: some things fine, some things not. Firewall enabled, System Integrity Protection enabled, Gatekeeper enabled, automatic security updates configured correctly. FileVault — full-disk encryption — off. Client documents sitting unencrypted at rest.
The more interesting finding was the AI model’s own network exposure. The local model server was bound to 0.0.0.0 — every network interface, not just the private mesh network it needed to be reachable on — with no authentication of any kind. A single unauthenticated HTTP request confirmed it:
POST /api/generate (no credentials)
→ HTTP 200, real model response
Anyone already on that private network could use the inference API for free, with zero credentials, and there would be no record distinguishing their traffic from legitimate use. That’s not a confidentiality breach in the sense of leaking processed documents — but it’s a real gap in a deployment whose entire selling point is controlled access to the model.
The fix was straightforward: rebind the model server to localhost only, and put a token-authenticated reverse proxy on the private-network-facing side. Anyone reaching the API now needs a bearer token; the model itself is no longer independently reachable from anywhere but the machine it runs on.
The interesting part of this exercise wasn’t the fix. It was what happened when we read the machine’s list of passwordless admin privileges — the kind of narrow, scoped exceptions that make an AI coding agent workable without a human re-typing a password fifteen times an hour — and tried to verify it by actually using each one.
The static listing showed an unrestricted grant to copy files with administrator privileges, no path restriction. On paper, that’s about as bad as a local privilege-escalation finding gets: administrator-level file copy, no scope, no password. Write access to any file on the system, including the files that grant permissions in the first place.
It didn’t work.
$ sudo -n cp /etc/hostname /tmp/proof.txt
sudo: a password is required
Every invocation we tried — the exact command from the listing, simplified variants, direct binary paths — hit the same wall. The grant that looked like the worst finding in the audit wasn’t real, or wasn’t real in the form the listing suggested. The most likely explanation is mundane: the listing tool wrapped two adjacent, narrower rules onto what looked like one line, and reading the wrapped text as a single broad grant was a misread, not a discovery.
A second, smaller version of the same mistake happened inside our own test tooling twenty minutes later. A script checking a power-management utility for the same kind of unrestricted access reported success — passwordless, working fine. It wasn’t. The script had guessed the utility’s location wrong (/usr/sbin/ instead of its actual home in /usr/bin/), so the command failed with “command not found” rather than “a password is required” — and the script’s own pass/fail logic, which only knew to look for the second string, called that success. A different bug, same shape: trust the string that looks like confirmation, and you’ll confirm the wrong thing.
What was real, and confirmed the hard way rather than assumed: an unrestricted tail grant, no path scoping at all. Direct proof:
$ cat /etc/sudoers
cat: /etc/sudoers: Permission denied
$ sudo -n tail -n 5 /etc/sudoers
[ ...contents returned, no password prompt... ]
Denied directly, granted instantly through the back door. That’s a genuine confidentiality-breaking read primitive — anything on the machine that only an administrator can read, this can read too, without asking. It’s read-only, not write, so it’s a smaller finding than the file-copy grant we’d initially worried about — but it’s the one that turned out to be true.
The rest of the audit was a mix of confirmed-narrow and confirmed-fine: passwordless shutdown (availability risk only, not data exposure), passwordless process-listing and power telemetry (standard low-risk debugging tools), a deploy script gated behind administrator privileges that turned out to touch only two hardcoded paths and nothing an attacker could redirect.
One control held up specifically because of how the machine was already structured, not because of anything done for this audit. The account running Ollama and the document pipeline is not the account used for interactive login — it’s a standard, non-privileged account with no general admin rights, holding only the narrow grants listed above. That means the confirmed tail escalation reaches everything a standard account plus its explicit grant list can reach. It does not reach full administrator access. That separation between the human’s session and the automation’s runtime account was already in place before anyone went looking for problems. Worth stating plainly: not every finding in an audit is a gap.
Two things came out of scope for this round, on purpose, not by oversight. Full-disk encryption stays off — the machine runs continuously, and turning it on means every reboot needs a password or a recovery key before services resume, a real tradeoff against uptime that isn’t worth making today. That decision has a concrete revisit trigger, not an indefinite deferral: before a second client’s corpus is onboarded, or if the machine’s physical custody changes. The broad admin-privilege exceptions stay as configured, including the now-confirmed tail grant — they exist to keep an AI coding agent from hitting a password prompt it has no way to answer, and narrowing them is a task for later, with the actual risk now precisely documented instead of guessed at.
One smaller item is a genuine open follow-up rather than an accepted tradeoff: the screen stays unlocked for a full hour of idle time before it asks for a password again. That’s a physical-access risk only — it has no bearing on the pipeline, which keeps running regardless of screen-lock state — and tightening it is simply not done yet.
None of this is a novel finding about macOS permissions. The finding worth keeping is methodological, and it’s the same one that shows up in every Chronos experiment on this blog: a claim about what a system does is not evidence until you’ve run it and watched what happened. A configuration file, a permissions listing, a policy document — all of them describe intent, and intent drifts from behavior in exactly the ways nobody notices until something is tested directly. The listing said one grant was dangerous and it wasn’t. Our own test script said another was safe and it wasn’t. Both errors were caught the same way: by re-running the specific command, by hand, and reading what actually came back.
That’s the whole discipline, really. Read the config to know where to look. Never trust it to tell you what’s true.
The full audit — hypotheses, test scripts, and raw results — is Chronos experiment 020: exp_020_miktam_mini_hardening.