Security notes for IT
SpectiX is a menu bar app that shows the live state of every Claude Code session on a developer's Mac: which one is working, which one finished, which one is sitting on a permission prompt. It is one signed and notarized bundle, installed per user, with no daemon, no admin rights, and no network stack linked into it.
This page is the evidence for that sentence. Every claim below is followed by a command you can run yourself against the shipped binary, and by a note on what that command does not prove. The second half is deliberate: an evaluator who finds an unstated limitation on their own has learned something worse than the limitation itself.
The short version, if you are approving this for a fleet: it reads local files under
~/.claude/, it writes small status files under ~/.claude/spectix/,
it adds one shell script to Claude Code's hook configuration, and the binary opens no
outbound connection. Two things can still put traffic on the wire, and neither of
them is the app: an optional Apple Watch push sent by that shell
script, and the Claude CLI the developer already runs, which the app can start in the
background to read a subscription-usage figure. The first is the only outbound path SpectiX
adds, and it stays off until somebody configures it; the second is traffic you already
permit. Both are named and bounded below.
1. The binary makes no network calls
This is not a privacy-policy sentence. It is clause 4 of the licence accepted at install time, which states that SpectiX performs no network activity: no outbound connection, no account system, no telemetry, no analytics, no crash reporting, no update ping, no licence-check call. Clause 2 then grants you, in writing, the right to verify that from outside the binary — reading its code signature and entitlements, listing its linked libraries, observing its sockets, and running it behind an outbound firewall are all expressly permitted.
Inside the project the guarantee is held up by two build-time gates, so that it cannot quietly rot the first time somebody adds a convenient "check for updates":
- Before compiling — the sources are scanned for networking APIs (URLSession, CFNetwork, NWConnection, socket(, getaddrinfo and their relatives). One hit and the build fails. Line comments are stripped first, so documentation may name those APIs; code may not use them.
- After compiling — the produced binary is put through otool -L, and the build fails if it links CFNetwork, Network.framework, libcurl, or a TLS library. That second gate is the same command we are about to ask you to run.
Verify the linked libraries yourself:
Then watch its sockets. Not "a few harmless connections" — none, on launch and after a week of uptime:
Then read what it asked the system for. The entitlement list is covered by the code signature, so it cannot be edited without breaking the signature:
And confirm the bundle is what we shipped, unmodified, and notarized by Apple:
If you would rather not take any of that on trust, block it outright: add SpectiX to Little Snitch, LuLu, or your MDM's outbound rules, or run the Mac under an egress deny-all. Nothing in the app degrades, because nothing in the app was reaching out.
What these checks do not prove.
SpectiX does not enable the App Sandbox, so the absent network entitlement is not the kernel
holding it back — a non-sandboxed process with no network entitlement can still open a
socket. We cannot enable the sandbox: the app has to read arbitrary paths under
~/.claude/ and drive other applications' windows through the Accessibility API,
and the sandbox permits neither. So read the entitlement check as "here is the complete,
tamper-evident list of what it asked the system for", not as "the kernel is stopping it".
The three checks that actually close that gap are otool -L (it
cannot link what it does not link), lsof (it cannot hide a socket
from the kernel's own table), and your firewall.
2. It asks for one system permission
SpectiX requests Accessibility. That is the whole list. macOS will additionally show an Automation entry for one specific terminal app the first time a developer uses the "jump to my terminal" action, because driving Terminal or iTerm to raise a window is an Apple Event.
Accessibility is what turns a click in the menu bar into the right window coming forward. Four operations use it, and no others: raising a window, locating a split pane so the highlight ring lands on the correct one, reading window titles so a session's working directory can be matched to the editor window that owns it, and detecting the state of the Claude desktop app.
What it never asks for:
| Permission | Status |
|---|---|
| Screen Recording | Not requested, and never prompted for. An early version read cross-Space window titles through CGWindowListCopyWindowInfo, which macOS gates behind Screen Recording. That was removed — titles now come from the Accessibility API. Nothing in the app captures pixels. |
| Full Disk Access | Not requested. It reads ~/.claude/, which is the
user's own home directory and needs no TCC grant. |
| Files & Folders | Not requested — no Documents, Desktop, or Downloads prompt. |
| Camera · Microphone | Not requested. |
| Contacts · Calendar · Reminders · Photos | Not requested. |
| Location | Not requested. |
| Admin · sudo · root | Never asked, at install time or afterwards. See Deploy & remove. |
Walk the list yourself in System Settings › Privacy & Security. SpectiX appears under Accessibility, and under Automation for whichever terminal app it has been asked to raise. It appears nowhere else.
What this section does not claim. Accessibility is a genuinely powerful grant: any app holding it can observe and drive other applications' windows, and we are not going to tell you it is harmless. What we can tell you is what it is used for — the four operations above — that it is revocable at any time in System Settings, and that revoking it costs the click-to-jump feature and nothing else. The status rows keep working without it.
3. Processes it starts, and what your EDR will see
An app that raises other applications' windows has to talk to them, and on macOS that means starting short-lived child processes. Endpoint detection tools ship rules for some of these, so here is the complete list rather than a surprise in someone's alert queue.
| Process | When, and how often |
|---|---|
| /usr/bin/osascript | Only when the developer clicks a row that lives in Terminal or iTerm. The script raises that window and selects the right tab — nothing else. This is the process most likely to be flagged by an EDR rule, which is exactly why it is named here. It runs as SpectiX rather than disclaimed, so the resulting Automation prompt is attributed to SpectiX and can be pre-approved by profile. |
| /usr/bin/open | Only when the developer clicks a row that lives in VS Code, Cursor, or Windsurf: the editor bundle is asked to bring the right window forward. |
| claude (the CLI already installed) | Background probe for the subscription-usage figure shown in the header, rate limited to once per minute at most. This is the developer's own Claude Code CLI, and it talks to Anthropic exactly as it does when they run it by hand — see Limits. It is started disclaimed, so any permission prompt the CLI triggers is attributed to the CLI and not to SpectiX. |
| /usr/bin/tccutil | Only when someone presses "reset permission" in Settings, to clear this app's own Accessibility grant so macOS will prompt again. |
One process is deliberately not in that table. /usr/bin/curl appears on the machine when the optional Apple Watch push is configured, but it is started by the hook script described in section 5 — a separate process in the user's own home directory, not a child of the app. That distinction is the whole reason the no-network guarantee survives the feature, and it is examined in section 6.
4. Everything it stores stays in one visible folder
All state lives in ~/.claude/spectix/. It is a plain directory of small
plain files — nothing there is encoded, compressed, or obfuscated. Open it and read it:
Files ending in -<tty> exist once per terminal, keyed to the terminal device name such as ttys003. Sessions with no terminal — the editor's chat panel — key on the process id instead. The Derived from work column is the one to read closely: it marks every file whose contents come from what the developer is doing, rather than from counters and timestamps.
| File | Contents | Derived from work |
|---|---|---|
| state-<tty> | One word: working, done, needs, paused, idle. | no |
| title-<tty> | The row label for that session — a short summary of the current task, or the first few words of the prompt. | yes |
| step-<tty> | The tool step in flight, such as the name of the file being read. | yes |
| tp-<tty> | The absolute path of that session's transcript file. A path, not its contents — but the path contains the project directory name. | yes |
| agents-<tty> agent-step-<tty>-<id> |
Which subagents are still running and what each is doing. The roster carries each subagent's task description. | yes |
| title-ai-used-<tty> title-ai-stamp-<tty> |
Bookkeeping so a repeated row label is not applied twice. title-ai-used holds the label itself; title-ai-stamp is only a timestamp. | yes |
| bg-<tty> | Which background shell commands are still running, by identifier. | no |
| ctx-<tty> | How full that session's context window is, as a number. | no |
| tick-<tty> | A heartbeat timestamp, written at most once a minute. | no |
| clear-boundary-<tty> | A timestamp marking the last context reset, used as the baseline for token counting. | no |
| events.jsonl | Append-only, one line per counted event, feeding the local statistics window. Each line holds a timestamp, the local date, the event kind, the basename of the session's working directory, the terminal name, and on completed turns the token tallies, round-trip count and model id. Lines for a new turn also carry a prompt summary, truncated to 80 characters — the same short string shown on the row. | yes |
| projects.json | Recently seen project paths, so the "recent projects" list survives a restart. These are absolute paths. | yes |
| jump-diag.log | A capped diagnostic log of how each jump was resolved. The home
directory is replaced with ~, but project and file names are kept on purpose
so a misrouted jump can be explained. It self-truncates and is written by release builds
too, not only by development ones. |
yes |
| terminals-<pid>.json | Written by the editor extension rather than by the app: the list of open terminal tabs in one editor window, with their titles. Claude Code renames those tabs after the task in flight, so the titles carry task descriptions. One file per editor window. | yes |
| impact.jsonl | Append-only log behind the effectiveness panel: when a jump was offered, whether it was taken, how long the developer stayed. Counters and timestamps only — no project names, no titles. | no |
| usage.json | A snapshot of Claude subscription usage percentages and the next reset time. See Limits. | no |
| popover-debug.log ring-diag.log focusring-sweep.log |
Capped diagnostic logs for the menu bar panel and the highlight ring: screen coordinates, window identifiers and timings. No titles, no paths. Written by release builds too. | no |
| desktop-windows.json desktop-design-wids.json desktop-probe.log |
Bookkeeping for detecting the Claude desktop app's windows — window identifiers and probe error counts. | no |
| design-ax-dump.request | Not created by the app — it is a diagnostic flag you create by hand. While the file exists, the app dumps the accessibility tree of the Claude desktop app's windows into design-tree-*.txt beside it, and an accessibility tree contains the text on screen. Nothing is written unless you put the flag there; delete it to stop. Listed here because it is the one thing in this folder that can capture conversation text, even though it takes a deliberate act to switch on. | only if you turn it on |
| active-terminal active-window focus-request chat-focus-request open-terminal-request |
One-shot request files used by the app and the editor extension to talk to each other. Written and consumed immediately; one of them carries the path of the directory to open. | partly |
| icons/ | The per-project icons the user picked. | no |
| sound-done sound-needs sound-volume |
Sound preferences. | no |
| push-key push-server push-done push-last push-at-<tty>-<state> |
Settings and rate-limit timestamps for the optional Apple Watch push, plus the outcome of the last attempt. Absent entirely until the feature is configured. | no |
Read the marked rows plainly. Nine of them carry material derived from the developer's work: a short row label, the current tool step, a transcript path, subagent task descriptions, the bookkeeping file that holds a repeated label, recently opened project paths, project names with an 80-character prompt summary in the statistics log, the jump diagnostic log that keeps project and file names on purpose, and editor tab titles. None of those nine contains source code, a full prompt, or model output, and none of them is transmitted anywhere. The single thing in this folder that can capture text off the screen is the diagnostic flag two rows above, and it produces nothing until somebody creates it by hand. That is the honest shape of it: the claim is "it stays here", not "there is nothing to see".
There is no in-app switch that stops the statistics log. If you do not want it, delete the file, or remove the hook wiring in section 5 — which is what writes it. The app treats a missing log as an empty one and carries on. Application preferences live in the standard per-user location rather than in that folder:
What this section does not claim. The app also reads files it does not own: the last 256 KB of the current Claude Code transcript, to learn which model the session is on and how full its context window is, and the session directory as a fallback status signal. It reads them; it does not copy them and it does not send them. But it is a read of a conversation, and you should know that before approving it.
5. It installs one hook script, and a helper beside it
SpectiX does not read terminal buffers, attach a debugger, or scrape the screen. It learns a session's state because Claude Code tells it: a hook script runs on the events that matter and writes one word to a file. Two files are installed, both plain text — the hook itself, and a small parser it calls for the subscription-usage figure:
and seven entries are merged into the hooks block of
~/.claude/settings.json:
What the script does, in order: work out which terminal fired the event by walking up
the process chain; write that session's state, label, step and counters into
~/.claude/spectix/, each through a temporary file and a rename so a reader never
sees a half-written file; optionally play a system sound; optionally send the Apple Watch push
described in section 6; and append one line to the local statistics log. It returns in
milliseconds and makes no network call other than that opt-in push.
Two details matter if you are diffing configuration files across a fleet, and they are easy to mistake for one behaviour:
- The installer overwrites; the app does not. Running the installer is an explicit "give me this version" request, so it replaces both scripts and re-applies its own seven entries. The app's own first launch is the opposite: if a script of that name already exists it is left alone, and an event that is already wired is not touched. In both cases hooks you wrote yourself are preserved — only entries belonging to this app are ever removed.
- The settings file is rewritten whole, with its keys
sorted. The merge reads the JSON, edits the hooks block and writes the document back
out with sorted keys and standard indentation. No setting of yours is lost, but
the key order in the file will change, so a byte-level diff of
settings.jsonafter install will show more movement than the seven added entries. If the file cannot be parsed, it is backed up with a timestamped name before anything is rebuilt.
Read the script before trusting it — it is commented throughout, and it is the same copy that ships inside the bundle, so a diff proves nothing rewrote it after install:
6. The one outbound path SpectiX adds, and why it is not a contradiction
SpectiX can buzz an Apple Watch when a session stops and waits for input. That notification does leave the machine — and it is still true that the binary makes no network calls, because the binary is not what sends it. The push is made by the hook script, a separate process, using curl. Run the same otool -L and lsof checks from section 1 with the feature switched on and the result is unchanged.
The detour exists because a local macOS notification cannot reach a Watch at all: watchOS mirrors notifications that the paired iPhone received, and a Mac is not in that chain. Reaching the wrist means going out to a push service. How the feature is bounded:
- Off until configured. There is no separate toggle — an empty device key means the code path is never entered. Nothing is sent and nothing is contacted.
- Your server, if you want one. It posts to a Bark server; the default is Bark's public endpoint and the address is an ordinary setting. Point it at a Bark server you host and the traffic never leaves your network's edge.
- Minimal payload. A fixed title, the row label for that session, and a group name. No project path, no prompt, no code, and no machine identifier beyond what the key itself implies.
- Waiting only, by default. Only the "needs your input" state pushes. "Finished" is off unless it is explicitly switched on.
- Rate limited. At most one push per terminal per state per thirty seconds.
- Never critical. The push is sent as time-sensitive so it can arrive during a Focus mode, but never as a critical alert — it will not pierce a silenced ringer.
- Fail-quiet. A push that cannot go out is dropped; the outcome is shown in Settings and never interrupts the developer's turn.
If your policy is no egress at all.
Leave the key empty and there is nothing to block. If you want to be categorical, delete the
push function from ~/.claude/hooks/spectix-status.sh — the rest of the script does
not depend on it. And it remains blockable by ordinary means: it is
curl running from a script in the user's own home directory, subject
to the same firewall rules as everything else there.
7. No telemetry, no account, no crash reporting
- No account. No sign-in, no licence key to activate, no per-seat registration, no online entitlement check.
- No telemetry. No usage counters, no feature pings, no install ping, no first-run beacon.
- No analytics library. None is linked — see otool -L above.
- No crash reporting. Crashes go where macOS puts every crash, into the local system report, and stay there unless someone chooses to send us one.
- No auto-update. The app does not check for versions. A new build arrives the way this one did: somebody downloads it.
Each of those is a term of clause 4 of the licence, not a policy that can be quietly revised. If a future version ever introduces network activity, the licence binds us to make it opt-in, to document it before it ships, and to announce it in the changelog. It could not be switched on by a background update in any case, because there is no background update.
One thing to read correctly: SpectiX does show usage statistics — turns per day, per project, token tallies, estimated cost. Those are computed on the developer's own Mac from the local log in section 4 and displayed to them. They are theirs. We never see them.
8. Limits, in our own words
Five things an evaluator should know, which we would rather state than have you discover.
- The subscription-usage figure starts the developer's own Claude CLI. The row showing how much of a Claude subscription is used is obtained by running claude in the background and parsing what it prints. That child process is Claude Code, and it talks to Anthropic exactly as it does when run by hand. It is not SpectiX traffic, and SpectiX still opens no connection of its own, but a strict egress audit will see the CLI you already permit. Switching the feature off in Settings stops the app from starting it — but note that the hook script runs its own probe at the end of a turn, and that one is governed by the hook, not by the app's setting. To stop it completely, remove the probe from the hook script.
- It reads local transcripts. To show the model in use and how full the context window is, the app reads the tail of Claude Code's own transcript file. A local read, no copy, no transmission — but it is a read of a conversation.
- Diagnostic logs are on in release builds. The capped log files listed in section 4 are written by every build, not only by development ones, and one of them keeps project and file names on purpose so a misrouted jump can be explained. They are in that table rather than left for you to find.
- It needs a terminal. Session state is keyed to the controlling terminal. A Claude Code session running somewhere that allocates no pty simply will not appear — no row, no error. Sessions in an editor's chat panel are handled, but a headless or containerised host is out of scope.
- The source is not published. You cannot read the app's code, and that is precisely why this page is built out of commands you can run against the shipped binary instead of out of assurances. The one component whose full text you can read is the hook script — plain text on your own disk — and it is where every claim about what gets collected can be checked line by line.
9. Deploying it, and removing it completely
Requirements. macOS 13.0 or later, Apple Silicon or Intel — one universal binary. No admin rights, no sudo, and no installer package that runs as root.
What an install touches. The application bundle goes
to /Applications, or to ~/Applications if that account cannot write
to the system folder — the installer falls back rather than asking for a password. Beyond
that: the two hook scripts and the wiring from section 5, the state directory from section 4,
optionally an editor extension in ~/.vscode/extensions/ and the equivalent folder
for Cursor and Windsurf, and one per-user login item registered on first launch through the
system's login-item service. A monitor that is not running tells you nothing, so it starts
enabled; switching it off in System Settings or in the app keeps it off, and it is never
re-registered.
There is no LaunchDaemon, no LaunchAgent property list of our own, no kernel extension, no background service that survives quitting the app, and no file outside the account that installed it.
Fleet deployment
The app is a self-contained bundle, so copying it into
/Applications through your MDM is a complete install. On first launch it copies
its hook scripts into the user's hooks directory and merges the wiring, both idempotently, so
re-deploying over an existing install changes nothing.
The Accessibility grant is per user and per binary. If you would rather your users never saw the prompt, pre-approve it with a PPPC payload — a ready-made profile is below, and it is deliberately published for anyone to use, MDM-managed fleets included.
Signing identity to allow-list:
Verify those against the copy you downloaded before you trust this page for them:
The profile grants Accessibility, plus Apple Events to Terminal and iTerm for the jump-to-terminal action. Import it into Jamf, Kandji, Intune or any other MDM as a PPPC payload; it declares no other service, and the app requests none.
Complete removal
Quit SpectiX from the menu bar icon. Then, while the bundle is still on disk, revoke the grant under System Settings › Privacy & Security › Accessibility and remove the entry under System Settings › General › Login Items.
That order is not cosmetic. Once the bundle is deleted, macOS can no longer resolve the identifier, the revocation fails silently, and an orphaned permission record is left behind. The same trap applies to preferences: run defaults delete rather than deleting a preference file by hand, because the preferences daemon holds those values in memory and writes them straight back to the file you just removed.
Only then remove the files:
Finally, remove the entries whose command contains
spectix-status.sh from the hooks block of
~/.claude/settings.json — your own hooks are untouched by this — and repeat the
extension line for Cursor and Windsurf if either is installed.
That is the whole footprint. Nothing in /Library, nothing
in /usr/local, and no receipt in the package database, because nothing was
installed as root.
Questions this page did not answer
The licence is the only binding text; this page is a reading of it. For the individual-user version of the same story — written for the developer rather than for the reviewer — see Privacy & permissions. For what changed in each build, including anything that would ever alter clause 4, see the changelog. Anything still unanswered: write to us — mail reaches a person.