Skip to article
← COOKD ENGINEERING

Installing yourself into a tool you do not own

To stay fresh, our CLI downloads a binary, registers it in a config file another program owns, and runs unattended after every turn. We mitigated that risk and wrote it down. We did not remove it.

SEP 28, 2026 · 7 MIN · COOKD ENGINEERING TEAM
IN THIS PIECE

cookd's CLI reports Claude Code usage to a server, and the user's access to the phone app depends on that report being recent. The phone cannot reach the laptop, so the laptop has to push. Originally the only way to push was to run the command yourself, and nobody does that on a schedule.

Claude Code has hooks: shell commands registered in ~/.claude/settings.json that fire on session events. So the feature is simple to state. With the user's consent, register a hook that runs our sync.

That sentence contains three things to be nervous about. The npm package ships JavaScript, and a hook needs something runnable, so we download a binary. Registering the hook means writing a file that another program owns and rewrites. And once installed, it runs unattended after every turn for as long as it stays installed.

Our design reviews are AI-assisted: an architect role proposes, a critic role attacks, and the team decides. The critic put the first and third together:

Downloading a binary and then auto-executing it every session is a remote-code-execution surface. A hijacked release or a MITM turns "auto-run cookd" into "auto-run anything," and unlike manual invocation it fires unattended forever.

The architect's answer began "Mitigated, not eliminated." That is the honest outcome, and this article is about what the mitigation consists of and where it stops.

Where the binary comes from

Bundling compiled binaries for five platforms into the npm tarball would add hundreds of megabytes to every install, including for every user who never turns this on. The release pipeline already builds those five binaries as release assets, so at consent time the CLI downloads the one for this machine:

const TARGETS: Record<string, string> = {
  'linux-x64':    'cookd-linux-x64',
  'linux-arm64':  'cookd-linux-arm64',
  'darwin-x64':   'cookd-darwin-x64',
  'darwin-arm64': 'cookd-darwin-arm64',
  'win32-x64':    'cookd-windows-x64.exe',
};
// Must match the `target` values in .github/workflows/release.yml.

A mismatch between that map and the build matrix is a 404 at the moment a user says yes. The comment does not prevent that. It makes the coupling visible to whoever edits either file.

Verify before writing, then rename into place

export function installFromBuffers(bin: Buffer, sums: string, asset: string, dest: string, version?: string): void {
  const expected = parseSums(sums, asset);
  if (!expected) throw new BinaryInstallError(`no checksum for ${asset} in SHA256SUMS`);
  if (!verifyChecksum(bin, expected)) throw new BinaryInstallError(`checksum mismatch for ${asset}`);
  mkdirSync(join(dest, '..'), { recursive: true });
  const tmp = `${dest}.tmp`;
  writeFileSync(tmp, bin);
  if (process.platform !== 'win32') chmodSync(tmp, 0o755);
  renameSync(tmp, dest); // atomic
  if (version) writeFileSync(join(dest, '..', '.version'), `${version}\n`, 'utf8');
}

Both the binary and the checksums file are in memory before the first write. A missing checksum entry is as fatal as a wrong one: unverified is not a degraded mode, it is the failure.

The write goes to a temp path and is renamed into place, so the hook can never fire against a half-written file. The chmod runs on the temp file, so the binary is executable the instant it appears under its real name. The version marker is written after the rename, and the code says why:

Written only AFTER the rename, so a failed or interrupted install can never leave a marker claiming a version that is not on disk.

What this protects against: a tampered or partial download. What it does not: a compromised release pipeline, because the checksums come from the same place as the binary. The decision record says so and rests the remaining trust on two arguments. The binary is the user's own provenance-built artifact, and the user has already extended the same trust by running the package at all.

The download itself shipped with one gap, found in the conformance check: it used raw fetch instead of the sync client's wrapper, which handles proxies and TLS inspection. On a corporate network a roughly 60 MB download would have failed with an opaque error at the point of consent. It was switched to the shared wrapper the same day.

The upgrade that never upgraded

This bug was on main for eight days, and it was a bug in code that did not exist.

The installer's rule was, in effect: if no binary is installed, download one. So an existing install was never replaced. And the hook does not run the npm package. It runs the pinned binary in the install directory. The remedy for every problem, re-run the install command, changed nothing.

The fix:

export function needsBinaryUpgrade(version: string): boolean {
  if (!isBinaryInstalled()) return true;
  // An unmarked binary predates version tracking, so it is by definition older
  // than any release that writes a marker. Unknown counts as stale.
  return installedBinaryVersion() !== version;
}

Two decisions in five lines.

Unknown counts as stale. Every binary installed before version tracking has no marker. Treating that as current would strand exactly the users who most needed replacing.

The version comes from a marker file, not the binary. The obvious alternative is to run cookd --version. The comment explains why not:

Spawning it to read --version would mean executing a downloaded artifact just to decide whether to replace it, on every npx run — a worse trade than one four-byte file.

Running an artifact to decide whether to trust it is backwards, and it would happen on the one path that exists to replace a bad binary.

The timing made this matter more than it looks. A concurrent sync rewrite changed the payload the server expects, so every existing user needed the new binary, and the documented way to get it did nothing. Fixing the installer was a precondition for shipping that rewrite at all.

Writing a file someone else owns

~/.claude/settings.json belongs to Claude Code. It holds the user's own configuration, and its owner may rewrite it at any time. Four rules, in order of how much trouble each saves.

Parse or refuse, before touching anything.

try {
  return JSON.parse(raw) as Settings;
} catch {
  throw new SettingsError(
    '~/.claude/settings.json is not valid JSON — refusing to modify it. Fix or remove it and retry.',
  );
}

The alternative, overwriting with a fresh object, destroys the user's configuration to install a feature they opted into. Refusing and saying why is the only defensible behaviour.

Back up, merge additively, rename atomically. A copy is taken before any write. Our own entries are stripped and re-added rather than appended, so install is idempotent. Everything else in the file passes through untouched.

Identify your entries by what they do, not by a marker you invented.

/** Identify our hook by its command path, not a custom marker field (which Claude
 *  Code could strip on its own rewrite). */
function isCookdHook(h: HookEntry): boolean {
  return typeof h.command === 'string' && h.command.replace(/\\/g, '/').includes('.cookd/bin/cookd');
}

An "_installedBy": "cookd" field would be tidier, and would not survive the owner normalising its own config. The command path is a field the owner must keep, because it needs it to run the hook. The separator normalisation makes the same check work on Windows.

Use the exec form, not a shell string.

/** Exec form (args array) — no shell, so a binary path containing spaces is safe on Windows. */
function cookdEntry(binaryPath: string): HookEntry {
  return { type: 'command', command: binaryPath, args: ['sync'], async: true, timeout: 30 };
}

A command and an argument array, spawned directly. Nothing to quote, so a home directory with a space in it does not break the hook, and nothing in the path is ever interpreted. async: true and the 30-second timeout matter because this fires inside the user's editing loop. A hook that blocks their next turn is worse than no hook.

That entry shape was once flagged in review as a suspected defect, because args and async were not obviously part of the hook schema. It was cleared by probing the shipped Claude Code binary (v2.1.220) directly, not by reading documentation. When you integrate with a tool you do not control, the shipped artifact is the specification.

Order, and a real uninstall

export async function installAutoSync(version: string): Promise<void> {
  if (needsBinaryUpgrade(version)) await downloadBinary(version);
  installCookdHooks(claudeSettingsPath(), binaryPath());
}

Order matters: never write a hook pointing at a binary that isn't there.

The reverse order has a window where the config points at nothing. A failure inside that window leaves a broken hook firing every session, in a file we just promised to be careful with.

Uninstall is the mirror, and it is a command:

export async function uninstallAutoSync(): Promise<void> {
  removeCookdHook(claudeSettingsPath());
  const bin = binaryPath();
  if (existsSync(bin)) rmSync(bin, { force: true });
  // Or a later reinstall would trust a marker describing a binary that is gone.
  clearVersionMarker();
}

It strips only our entries, prunes empty groups, and clears the version marker, because a stale marker next to a deleted binary would make a later reinstall think it was current. Unknown-counts-as-stale, applied at the other end of the lifecycle.

A clean uninstall is part of the security argument, not a courtesy. "You can turn this off completely and it removes itself" is one of the few real bounds on a persistent, unattended execution surface, and it only counts if it is a command rather than a paragraph in a README.

What it cost

  • Trust in the release itself. Checksums prove the download matches the release, not that the release is good. That trust rests on open source, provenance-built artifacts, and a one-command uninstall.
  • A network dependency at the most sensitive moment. About 60 MB during onboarding, often on networks with TLS inspection.
  • Two files that must agree, enforced by a comment. The platform map and the release matrix.
  • Someone else's schema. The hook shape, the settings structure and the available events all belong to Claude Code and can change in a release we do not control. The installer refuses rather than guesses when it does not understand what it sees.

What we would do differently

  • Test the upgrade path, not just the install. A test that installs version N, then runs install for N+1 and asserts the binary changed, would have caught the no-op before it reached main.
  • Replace the coupling comment with a CI check. A job that diffs the target map against the release matrix is a few lines, and a 404 at consent time is an expensive way to find a typo.
  • Keep integrity and origin separate in your head. Checksums shipped alongside a binary prove it arrived intact. Only something outside the pipeline can prove where it came from, and the design should say which of the two it is relying on.

Refuse rather than repair

Almost every rule here is a version of the same instinct. Verify before you write. Treat what you cannot identify as stale. Identify yourself by something the owner has to keep. Refuse to edit a file you do not understand. Make the exit as real as the entrance.

When your code installs itself into someone else's tool and then runs unattended, the uninstall is part of the security model.

cookd is the app you can only open when Claude has rate-limited you. What is cookd →

PUBLISHED BY THE COOKD ENGINEERING TEAM