unsnooze 1.16.2 → 1.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,160 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.17.0 — 2026-08-23
4
+
5
+ - **A state directory unsnooze cannot write to no longer pins a CPU core
6
+ forever.** The lock around `~/.unsnooze/state.json` treated every `mkdir`
7
+ failure as contention. `EEXIST` really is contention — someone else holds the
8
+ lock — but `EACCES` on an unwritable state directory, `EROFS` on a read-only
9
+ filesystem and `ENOSPC` on a full one are not, and that branch retried with
10
+ neither a sleep nor a deadline check. Since `mkdir -p` on a directory that
11
+ already exists succeeds as a no-op, there was nothing to break the cycle:
12
+ the loop spun at around 70% of a core, ignored `UNSNOOZE_LOCK_TIMEOUT_MS`
13
+ entirely, and never returned. It took every writer with it — the daemon, any
14
+ CLI command, and the StopFailure hook that runs *inside* your agent. unsnooze
15
+ now repairs a state directory it owns and otherwise fails on the deadline with
16
+ the errno that caused it.
17
+
18
+ - **Everything under `~/.unsnooze` is now owner-only (0700 / 0600).** The
19
+ directory was 0755 and every file inside it 0644, so on a shared machine any
20
+ other local user could read them. That directory holds your ntfy bearer token
21
+ (`config.json`), the text of queued prompts and the paths they run in
22
+ (`state.json`), your fleet's ssh destinations and the commands that fetch
23
+ their passwords (`hosts.json`), a mirror of every remote host's sessions
24
+ (`fleet-cache.json`), a lease file per pane carrying its working directory
25
+ (`leases/`), and — for a headless revival — the entire stdout and stderr of
26
+ an unattended agent run (`headless/*.log`). Every writer now creates its file
27
+ owner-only and its directory 0700.
28
+
29
+ Upgrading an existing install needed more than that, because none of it is
30
+ something a writer can fix: `mkdir`'s mode is ignored for a directory that
31
+ already exists, appending to a log reuses the file's inode, and
32
+ `config.json` and `hosts.json` are written only by `config set` and
33
+ `hosts add`, so an install that never touches them again would have kept
34
+ their old mode — and the token in one of them — indefinitely. So a state
35
+ write repairs the modes directly, walking the `events/`, `leases/`,
36
+ `mux-sessions/` and `headless/` subdirectories as well as the top-level
37
+ files. It re-checks on an interval rather than once per process, because
38
+ `daemon.log` is created by launchd/systemd redirecting the daemon's stdout
39
+ and can appear after a first pass has already run — in a process that then
40
+ lives for days. It strips group and other access without touching the
41
+ owner's own bits, so an executable in there stays executable; it never
42
+ follows a symlink or a hardlink into a file it is about to change; and it
43
+ only ever touches unsnooze's own state directory. `unsnooze doctor` reports
44
+ from the same walk the repair acts on, so the two cannot disagree about what
45
+ counts as exposed. It is inert on Windows, which has no POSIX mode bits —
46
+ libuv synthesises them by mirroring the owner's, so every file there reads
47
+ as world-readable and no `chmod` can change it; access is governed by the
48
+ profile ACL instead. The same is true of a state directory on a filesystem
49
+ that does not implement permissions at all — a CIFS or vfat mount, WSL's
50
+ `drvfs`, a bind mount from a Windows host — and the platform name does not
51
+ identify those, so unsnooze finds out by attempting the change and looking:
52
+ a `chmod` that succeeds while the bits stay put means the filesystem has
53
+ nothing to set, and the directory is left alone rather than rewritten every
54
+ minute forever. `unsnooze doctor --fix` reports those three outcomes
55
+ separately, so "could not" is never printed as "did".
56
+
57
+ The statusline shim's own drop directory, `~/.claude/unsnooze`, gets the
58
+ same treatment: it holds your live rate-limit numbers and was created 0755.
59
+ It is repaired on the shim's next run rather than only for new installs.
60
+
61
+ `unsnooze doctor` now also reports anything under the state directory that
62
+ other users can read, and `doctor --fix` narrows it. That check exists
63
+ because the repair deliberately ignores a failed `chmod` — crashing every
64
+ state write on a filesystem that has no usable one would be the worse bug —
65
+ so something has to be able to tell you it did not take.
66
+
67
+ No credential was ever stored in plaintext: a password source is a
68
+ *reference* to your keychain, environment or secret tool, never the secret.
69
+ On a single-user machine nothing here was reachable by anyone new.
70
+
71
+ - **A hostile `session_id` can no longer steer where unsnooze reads or
72
+ writes.** Three places took that value — which arrives in a payload from the
73
+ agent — and used it directly as a filename. The opt-in Claude Code statusline
74
+ shim built its drop filename by concatenating it, so a `/` or a `..` walked
75
+ the write out of `~/.claude/unsnooze` and over any `.json` file you can
76
+ write. `transcriptPath`, on the StopFailure hook path that everyone has
77
+ enabled, composed a transcript path the same way, turning a read of your own
78
+ transcript into a read of any `.jsonl` you name. And the Kimi adapter probed
79
+ for a session directory by the same concatenation, which leaked whether an
80
+ arbitrary path exists. Claude Code sends a uuid, so all three needed a
81
+ malicious or compromised agent to reach, and the two reads are only ever
82
+ mined for a timestamp or a boolean — never echoed back. None of them was in
83
+ a position to know that. All three now require a filename-shaped id and fall
84
+ back to `unknown` / no-transcript / not-found. If you have the statusline
85
+ shim installed, re-run `unsnooze usage --install-statusline` to pick up the
86
+ fix; the other two need nothing.
87
+
88
+ - **A corrupt `state.json` no longer prints its own first bytes into the log.**
89
+ The quarantine message included V8's `JSON.parse` error, and V8 embeds a
90
+ snippet of the input it choked on — so a state file that had been replaced
91
+ with something else echoed that file's opening characters into
92
+ `unsnooze.log`. The quarantined file is still kept on disk, and the log still
93
+ names it and says why.
94
+
95
+ - **Three narrower lock fixes.** A writer whose stale-lock steal was itself
96
+ stolen no longer deletes the thief's lock on the way out — it only removes a
97
+ lock still stamped with its own pid. The steal itself now re-checks that the
98
+ directory it is about to remove is still the one it judged stale, narrowing
99
+ a window where two contenders could both decide to steal and the slower one
100
+ would delete the winner's fresh lock. And a lock is now stolen on age alone
101
+ once it is very old: liveness was decided by signalling the recorded pid,
102
+ which cannot tell "unsnooze is still working" from "that number now belongs
103
+ to some unrelated long-lived process", so a leaked lock whose pid was later
104
+ recycled wedged every writer permanently, with no recovery but deleting the
105
+ directory by hand.
106
+
107
+ That last one is a trade rather than a free win — stealing from a holder that
108
+ really is still working puts two writers in the critical section, and one
109
+ overwrites the other. It is chosen because a permanent wedge is the worse
110
+ failure, and the exposure is kept small by making sure the lock is only ever
111
+ held for in-memory work plus a single write: `upsertSession` used to compute
112
+ its workspace fingerprint inside the lock, which shells out to git, and
113
+ `execFileSync`'s timeout is a soft bound — it signals the child and then
114
+ waits for it to actually exit, which a git wedged on a hung network mount
115
+ never does. That call now happens before the lock is taken, so nothing
116
+ unbounded runs inside it.
117
+
118
+ *Raised in [#17](https://github.com/saaranshM/unsnooze/issues/17). The report
119
+ also flagged the askpass `command` source as command injection and reading
120
+ `state.json` as a symlink-following bug; neither is a vulnerability. A
121
+ `command` source is your own configured command, in the shape git's
122
+ `credential.helper = !cmd` has always taken, and it can only be set by
123
+ someone who can already write to your home directory. As for the symlink:
124
+ reads do follow one — point `state.json` at a file holding valid JSON and
125
+ its contents are read as state — but planting that link needs write access
126
+ to a directory that is now 0700, which is not a boundary anyone crosses
127
+ without already owning the account. The write side is genuinely safe: the
128
+ quarantine renames the link rather than the file it points at, and a state
129
+ write replaces the link with a real file, so the target is never truncated
130
+ or overwritten.*
131
+
132
+ ## 1.16.3 — 2026-08-22
133
+
134
+ - **A dashboard left open overnight no longer runs the machine out of memory.**
135
+ ([#16](https://github.com/saaranshM/unsnooze/issues/16)) `unsnooze status` in
136
+ a spare pane is exactly what it is for, and after nine or ten hours it was
137
+ dying on *"FATAL ERROR: Ineffective mark-compacts near heap limit — JavaScript
138
+ heap out of memory"*. The dashboard itself was innocent: React's development
139
+ build files a `performance.measure()` entry for every render, commit and
140
+ `setState` — the performance track it publishes for devtools — and Node
141
+ buffers user-timing entries forever with nothing to drain them. The status tab
142
+ repaints about three times a second (a one-second data tick plus the
143
+ 450ms logo animation), each repaint left roughly 40 entries behind, and a
144
+ night of that walks into Node's 2GB heap ceiling. Measured at ~12kB retained
145
+ per render, surviving a forced garbage collection. The dashboard now drains
146
+ that buffer while it is mounted; heap use went from climbing without limit to
147
+ flat. Nothing else in unsnooze records user timing, and no other command
148
+ renders long enough to have been affected.
149
+
150
+ - **Every release now creates its GitHub release automatically.** They were
151
+ written by hand afterwards, so 1.16.2 shipped to npm and to the website while
152
+ the repository's Releases tab still showed 1.16.1. The publish workflow now
153
+ creates the release from that version's CHANGELOG section, and waits for the
154
+ npm registry to actually serve the new version before rebuilding the website —
155
+ `npm publish` returns before the registry does, which is why 1.16.2's own
156
+ changelog entry was briefly invisible on unsnooze.dev.
157
+
3
158
  ## 1.16.2 — 2026-08-22
4
159
 
5
160
  - **A herdr revival now opens a tab in your project's workspace, not a new
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unsnooze",
3
- "version": "1.16.2",
3
+ "version": "1.17.0",
4
4
  "description": "Automatically resume Claude Code, Codex CLI, Grok, Qwen Code, Kimi CLI, OpenCode, and Antigravity sessions when 5-hour or weekly usage limits reset—on macOS, Linux and Windows, with or without a terminal multiplexer.",
5
5
  "keywords": [
6
6
  "claude",
@@ -54,7 +54,15 @@ function sessionsDirFor(cwd, kimiDir) {
54
54
  return join(kimiDir, 'sessions', createHash('md5').update(cwd).digest('hex'));
55
55
  }
56
56
 
57
+ // sessionId comes from a hook payload and is used as a path component here.
58
+ // The result only picks `-r <id>` over `--continue` and the id travels as
59
+ // argv with no shell, so the exposure is an existence oracle rather than a
60
+ // traversal — but it is the same shape as transcriptPath's, and the same
61
+ // filename-shape gate closes it.
62
+ const SAFE_SESSION_ID_RE = /^[A-Za-z0-9._-]{1,128}$/;
63
+
57
64
  function sessionExists(sessionId, kimiDir) {
65
+ if (!SAFE_SESSION_ID_RE.test(String(sessionId ?? ''))) return false;
58
66
  let hashes;
59
67
  try { hashes = readdirSync(join(kimiDir, 'sessions')); } catch { return false; }
60
68
  return hashes.some(h => existsSync(join(kimiDir, 'sessions', h, sessionId)));
package/src/askpass.js CHANGED
@@ -3,7 +3,8 @@
3
3
  // anywhere. The SERVICE/ACCOUNT/VAR *names* may appear on argv; the secret
4
4
  // itself never does — it only ever comes back on stdout/stdin.
5
5
  import { execFileSync, spawnSync } from 'node:child_process';
6
- import { writeFileSync, chmodSync, mkdirSync, renameSync } from 'node:fs';
6
+ import { writeFileSync, chmodSync, renameSync } from 'node:fs';
7
+ import { ensureStateDir } from './config.js';
7
8
  import { join } from 'node:path';
8
9
 
9
10
  export class AuthError extends Error {
@@ -163,7 +164,7 @@ export async function cmdAskpass(args = []) {
163
164
  // by Task 5) because ssh controls the helper's argv — it passes only the
164
165
  // prompt text, never the host.
165
166
  export function ensureAskpassHelper({ platform = process.platform, stateDir, nodePath = process.execPath, scriptPath }) {
166
- mkdirSync(stateDir, { recursive: true });
167
+ ensureStateDir(stateDir);
167
168
  if (platform === 'win32') {
168
169
  // Native ssh.exe needs a real exe; unix-like win ssh (Git/WSL) accepts a script.
169
170
  // The resolution ladder (design §5c) is finalized in Task 7's cross-platform pass;
package/src/config.js CHANGED
@@ -1,5 +1,8 @@
1
1
  import { homedir } from 'node:os';
2
- import { join } from 'node:path';
2
+ import { join, resolve } from 'node:path';
3
+ import {
4
+ mkdirSync, chmodSync, writeFileSync, renameSync, lstatSync, statSync, readdirSync,
5
+ } from 'node:fs';
3
6
 
4
7
  function envInt(name, def) {
5
8
  const v = parseInt(process.env[name] ?? '', 10);
@@ -15,6 +18,172 @@ export const RESUMER_LOCK = join(STATE_DIR, 'resumer.lock');
15
18
  // High-frequency burn accumulator + warn-dedup (daemon single-writer).
16
19
  export const USAGE_FILE = join(STATE_DIR, 'usage.json');
17
20
 
21
+ // Everything under STATE_DIR is owner-only. The directory holds an ntfy
22
+ // bearer token (config.json), queued prompt text and cwd paths (state.json),
23
+ // ssh destinations and credential-source commands (hosts.json) — none of it
24
+ // another local user's business, and the dir mode is what protects the files
25
+ // that don't set their own. mkdir's `mode` only applies when it creates, so
26
+ // a 0755 dir left by a pre-1.17.0 install is repaired explicitly.
27
+ // No-op on Windows (node's chmod only carries the read-only bit there); the
28
+ // user profile ACL already restricts it.
29
+ export function ensureStateDir(dir = STATE_DIR) {
30
+ // Creating a directory owner-only is always right — we are about to put a
31
+ // 0600 file in it.
32
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
33
+ // Narrowing one that ALREADY exists is only ever done for our own state
34
+ // dir. ensureStateDir is also called with a caller-supplied path
35
+ // (writeUsageStore takes one), and re-permissioning someone else's
36
+ // directory — or the files inside it — is not this function's business.
37
+ if (isStateDir(dir)) {
38
+ try { chmodSync(dir, 0o700); } catch { /* shared/CI dir, not ours to fix */ }
39
+ repairModes(dir);
40
+ }
41
+ return dir;
42
+ }
43
+
44
+ // Repairing an install that predates 1.17.0. Three things the writers alone
45
+ // cannot fix: mkdir's `mode` is ignored for a directory that already exists,
46
+ // an append/open reuses an existing file's inode, and config.json/hosts.json
47
+ // are written only by `config set` / `hosts add` — on an install that never
48
+ // touches them again they would keep their old 0644 (and, for config.json,
49
+ // the ntfy token in it) indefinitely. So the modes are repaired directly.
50
+ //
51
+ // Re-checked on an interval rather than once per process: daemon.log is
52
+ // created by launchd/systemd redirecting the daemon's stdout, so it can
53
+ // appear at 0644 AFTER a pass has run — in a process that then lives for
54
+ // days. A walk is a readdir and a handful of lstats, so this is free at
55
+ // daemon cadence.
56
+ const MODE_REPAIR_INTERVAL_MS = envInt('UNSNOOZE_MODE_REPAIR_MS', 60_000);
57
+ const repairedAt = new Map();
58
+
59
+ // Compare resolved paths, not raw strings: callers reach ensureStateDir
60
+ // through dirname(join(STATE_DIR, 'config.json')), which normalizes away a
61
+ // trailing slash that STATE_DIR itself may carry from UNSNOOZE_STATE_DIR. A
62
+ // raw comparison silently answers "not our directory" and skips the repair.
63
+ const isStateDir = (dir) => resolve(dir) === resolve(STATE_DIR);
64
+
65
+ // ONE walk, exported so `unsnooze doctor` reports from exactly what the
66
+ // automatic repair acts on. They were separate before, and drifted: the
67
+ // repair worked from a fixed list of filenames while doctor walked the whole
68
+ // directory, so a quarantined state.json or a crashed writer's tmp file — the
69
+ // two leftovers that hold verbatim copies of the sensitive files — were
70
+ // reported forever and never fixed.
71
+ //
72
+ // Top level plus one level down is the entire layout: a handful of files and
73
+ // four flat subdirectories.
74
+ export function scanStateDir(dir = STATE_DIR, { platform = process.platform } = {}) {
75
+ const out = [];
76
+ // Windows has no POSIX mode bits. libuv synthesises them by mirroring the
77
+ // owner bits into group and other (win/fs.c `fs__stat_impl`), so a file is
78
+ // always 0666 (0444 read-only) and a directory 0777 — `mode & 0o077` is
79
+ // never zero and EVERY entry would look permanently exposed. Meanwhile
80
+ // uv_fs_chmod only toggles FILE_ATTRIBUTE_READONLY, so no repair could ever
81
+ // clear it: doctor would never report healthy, `--fix` would claim success
82
+ // while changing nothing, and the daemon would re-chmod the whole directory
83
+ // every interval forever. Access there is governed by the profile ACL.
84
+ if (platform === 'win32') return out;
85
+ // The root is stat'd, not lstat'd: a state dir that is itself a symlink is
86
+ // a legitimate setup (dotfiles, another volume) and must still be checked
87
+ // and repaired. Entries INSIDE are lstat'd and a link is skipped — chmod
88
+ // follows links, and following one there would reach outside the dir.
89
+ const inspect = (path, rel, isRoot = false) => {
90
+ try {
91
+ const st = isRoot ? statSync(path) : lstatSync(path);
92
+ if (!isRoot && st.isSymbolicLink()) return null;
93
+ const linked = !isRoot && !st.isDirectory() && st.nlink > 1;
94
+ // Strip group/other, but never the owner's execute bit: the state dir
95
+ // holds executables (askpass.sh, and whatever a test or a user drops
96
+ // beside it), and forcing a flat 0600 turns those into EACCES on spawn.
97
+ // Directories need their own traverse bit, hence 0700 rather than 0600.
98
+ const want = st.isDirectory() ? 0o700 : (0o600 | (st.mode & 0o100));
99
+ if ((st.mode & 0o077) !== 0) {
100
+ // `skipRepair` for a hardlink: lstat cannot distinguish one, so chmod
101
+ // would change an inode that also lives outside this directory.
102
+ // Reported anyway — an exposed file the repair will not touch is
103
+ // exactly what the doctor finding exists to surface.
104
+ out.push({ path, rel, mode: st.mode & 0o777, want, skipRepair: linked });
105
+ }
106
+ return st;
107
+ } catch { return null; }
108
+ };
109
+ if (!inspect(dir, '.', true)) return out;
110
+ let names = [];
111
+ try { names = readdirSync(dir); } catch { return out; }
112
+ for (const name of names) {
113
+ const path = join(dir, name);
114
+ const st = inspect(path, name);
115
+ if (!st?.isDirectory()) continue;
116
+ try {
117
+ for (const child of readdirSync(path)) inspect(join(path, child), `${name}/${child}`);
118
+ } catch { /* unreadable */ }
119
+ }
120
+ return out;
121
+ }
122
+
123
+ // Applies what scanStateDir found. `want` carries 0700 for directories and
124
+ // 0600 for files — a directory chmod'd 0600 would lose its traverse bit and
125
+ // nothing inside it could be opened.
126
+ // Returns { fixed, refused, ineffective }. The three outcomes are genuinely
127
+ // different and the caller needs to tell them apart:
128
+ // fixed — the bits moved.
129
+ // refused — chmod threw (EPERM, EROFS, an immutable flag). A real
130
+ // exposure that cannot be repaired; keep reporting it.
131
+ // ineffective — chmod SUCCEEDED and nothing changed. The filesystem does
132
+ // not implement POSIX modes at all (a CIFS/vfat/NTFS mount
133
+ // with `noperm`, WSL's drvfs, a Docker bind mount from a
134
+ // Windows host). Retrying forever would never converge, and
135
+ // `process.platform` does not identify this case — only
136
+ // attempting it does.
137
+ export function narrowStateDir(entries) {
138
+ let fixed = 0, refused = 0, ineffective = 0;
139
+ for (const e of entries) {
140
+ if (e.skipRepair) continue;
141
+ try {
142
+ chmodSync(e.path, e.want);
143
+ if ((lstatSync(e.path).mode & 0o077) === 0) fixed += 1;
144
+ else ineffective += 1;
145
+ } catch { refused += 1; }
146
+ }
147
+ return { fixed, refused, ineffective };
148
+ }
149
+
150
+ // Directories whose filesystem provably ignores chmod. Learned by attempting
151
+ // it, never guessed from the platform.
152
+ const modesUnenforced = new Set();
153
+
154
+ function repairModes(dir) {
155
+ // Key on the resolved path: callers reach this through
156
+ // dirname(join(STATE_DIR, 'x')), which normalizes a trailing slash away, so
157
+ // raw strings would give the same directory two throttle slots.
158
+ const key = resolve(dir);
159
+ if (modesUnenforced.has(key)) return;
160
+ const now = Date.now();
161
+ if (now - (repairedAt.get(key) ?? -Infinity) < MODE_REPAIR_INTERVAL_MS) return;
162
+ repairedAt.set(key, now);
163
+ const entries = scanStateDir(dir);
164
+ if (entries.length === 0) return;
165
+ const { fixed, refused, ineffective } = narrowStateDir(entries);
166
+ // Every attempt succeeded and not one bit moved: this filesystem has no
167
+ // POSIX modes to set. Stop walking it — the alternative is re-chmodding
168
+ // the whole directory every interval for the life of the daemon.
169
+ if (fixed === 0 && refused === 0 && ineffective > 0) modesUnenforced.add(key);
170
+ }
171
+
172
+ // tmp + rename + chmod. writeFileSync's `mode` lands only when it CREATES the
173
+ // file, so a tmp left behind by a crashed predecessor that happened to share
174
+ // this pid would otherwise carry its own 0644 onto the target through the
175
+ // rename — and a target left 0644 by an older version would never be fixed.
176
+ // The chmod after the rename closes both. Same pattern, and the same reason,
177
+ // as ensureAskpassHelper in askpass.js.
178
+ export function writePrivateFile(target, tmp, data) {
179
+ writeFileSync(tmp, data, { mode: 0o600 });
180
+ renameSync(tmp, target);
181
+ // A filesystem with no usable chmod (FAT, some network mounts) must not
182
+ // turn every state write into a crash — the 0700 directory still covers it.
183
+ try { chmodSync(target, 0o600); } catch { /* best-effort */ }
184
+ return target;
185
+ }
186
+
18
187
  export const CLAUDE_DIR = process.env.UNSNOOZE_CLAUDE_DIR
19
188
  || process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude');
20
189
  export const CLAUDE_SETTINGS = join(CLAUDE_DIR, 'settings.json');
@@ -62,6 +62,41 @@ export function installMouseCleanup(stdout = process.stdout) {
62
62
  };
63
63
  }
64
64
 
65
+ // React's development build records a `performance.measure()` entry for every
66
+ // render, commit and setState — the React Performance Track it publishes for
67
+ // devtools. Node buffers user-timing entries and nothing ever drains that
68
+ // buffer, so on a long-lived dashboard it IS the leak: the status tab repaints
69
+ // roughly three times a second (a 1s data tick plus the 450ms logo animation),
70
+ // each repaint leaves ~40 entries behind, and an overnight `unsnooze status`
71
+ // walked into Node's 2GB heap ceiling and aborted (#16). Measured at ~12 kB
72
+ // retained per render, surviving a forced GC.
73
+ //
74
+ // The entries cannot be capped (Node caps resource timing, not user timing) and
75
+ // switching React to its production build would mean setting NODE_ENV for the
76
+ // whole CLI before anything imports React. Draining is the narrow fix: nothing
77
+ // in unsnooze reads user timing, and the sweep lives exactly as long as the
78
+ // render does.
79
+ export const TIMING_SWEEP_MS = 5_000;
80
+
81
+ export function sweepReactTiming(perf = globalThis.performance) {
82
+ try {
83
+ perf?.clearMeasures?.();
84
+ perf?.clearMarks?.();
85
+ } catch { /* a runtime with no user timing has nothing to leak */ }
86
+ }
87
+
88
+ // Returns the stop function. The timer is unref'd: draining a buffer is never
89
+ // a reason to hold the process open.
90
+ export function startTimingSweep({ intervalMs = TIMING_SWEEP_MS, perf } = {}) {
91
+ sweepReactTiming(perf);
92
+ const timer = setInterval(() => sweepReactTiming(perf), intervalMs);
93
+ timer.unref?.();
94
+ return () => {
95
+ clearInterval(timer);
96
+ sweepReactTiming(perf);
97
+ };
98
+ }
99
+
65
100
  export function shouldUseDashboard({
66
101
  force = null,
67
102
  json = false,
@@ -87,13 +122,18 @@ export async function runDashboard({ tab = 'status' } = {}) {
87
122
  }
88
123
 
89
124
  installMouseCleanup(process.stdout);
90
- const instance = render(h(App, { initialTab: tab, mouseEnabled: getConfig('mouse') !== false }), {
91
- exitOnCtrlC: true,
92
- // Full-screen: separate buffer like vim / htop / less — original scrollback restored on quit
93
- alternateScreen: true,
94
- });
125
+ const stopTimingSweep = startTimingSweep();
126
+ try {
127
+ const instance = render(h(App, { initialTab: tab, mouseEnabled: getConfig('mouse') !== false }), {
128
+ exitOnCtrlC: true,
129
+ // Full-screen: separate buffer like vim / htop / less — original scrollback restored on quit
130
+ alternateScreen: true,
131
+ });
95
132
 
96
- await instance.waitUntilExit();
133
+ await instance.waitUntilExit();
134
+ } finally {
135
+ stopTimingSweep();
136
+ }
97
137
  // Belt and braces: normal quit also clears modes (provider already did).
98
138
  process.stdout.write(MOUSE_DISABLE_ALL);
99
139
  return 0;
package/src/doctor.js CHANGED
@@ -10,7 +10,7 @@ import { readFileSync, readdirSync, renameSync, existsSync, unlinkSync, lstatSyn
10
10
  import { spawnSync } from 'node:child_process';
11
11
  import { homedir } from 'node:os';
12
12
  import { join, dirname } from 'node:path';
13
- import { CLAUDE_SETTINGS, RESUMER_LOCK } from './config.js';
13
+ import { CLAUDE_SETTINGS, RESUMER_LOCK, STATE_DIR, scanStateDir, narrowStateDir } from './config.js';
14
14
  import { getMultiplexer } from './multiplexer.js';
15
15
  import { makeLogger } from './logger.js';
16
16
  import { shouldUseTui, formatDoctorTui } from './tui.js';
@@ -151,6 +151,26 @@ function daemonRunning() {
151
151
  * detail, fix? } — `fix` is a machine-actionable descriptor consumed by
152
152
  * applyFixes. healthy === no legacy and no health findings.
153
153
  */
154
+ // Both delegate to config.js's walk: the reporter and the automatic repair
155
+ // must never disagree about what counts as exposed, which is exactly what
156
+ // happened when each had its own idea of where to look.
157
+ export function findExposedStateFiles(dir = STATE_DIR, opts = {}) {
158
+ return scanStateDir(dir, opts).map(e => ({ ...e, mode: e.mode.toString(8) }));
159
+ }
160
+
161
+ // Deliberately NOT given runDoctor's `platform`. Every other check uses that
162
+ // to simulate an install target — "what would doctor say on Windows?" — but
163
+ // whether mode bits mean anything is a fact about the filesystem actually
164
+ // under the process, not about the platform being simulated. Passing the
165
+ // simulated one made a `platform: 'darwin'` test raise this finding while
166
+ // running on a Windows runner, where the modes are synthetic and no repair
167
+ // could ever clear it. scanStateDir keeps its own injectable platform for
168
+ // direct unit tests.
169
+
170
+ export function narrowStateModes(entries) {
171
+ return narrowStateDir(entries);
172
+ }
173
+
154
174
  export async function runDoctor({
155
175
  runner = defaultRunner,
156
176
  platform = process.platform,
@@ -164,6 +184,7 @@ export async function runDoctor({
164
184
  rcContent = undefined,
165
185
  profileContent = undefined,
166
186
  autostartDir = null,
187
+ stateDir = STATE_DIR,
167
188
  } = {}) {
168
189
  const findings = [];
169
190
 
@@ -220,6 +241,22 @@ export async function runDoctor({
220
241
  detail: ' run: unsnooze install --yes (then: exec $SHELL)',
221
242
  });
222
243
  }
244
+ // The state dir is repaired on every write, but chmod is swallowed there —
245
+ // a filesystem that cannot honour it (FAT, some network mounts) would
246
+ // otherwise leave everything world-readable with nothing ever saying so.
247
+ // This is the place that says so, and --fix retries the chmod.
248
+ const exposed = findExposedStateFiles(stateDir);
249
+ if (exposed.length) {
250
+ findings.push({
251
+ id: 'state-permissions', kind: 'health',
252
+ title: `${exposed.length} file(s) in ${stateDir} are readable by other users`,
253
+ detail: exposed.slice(0, 8).map(e => ` ${e.mode} ${e.rel}`).join('\n')
254
+ + (exposed.length > 8 ? `\n …and ${exposed.length - 8} more` : '')
255
+ + '\n run: unsnooze doctor --fix',
256
+ fix: { action: 'narrow-state-modes', entries: exposed },
257
+ });
258
+ }
259
+
223
260
  let muxOk = false;
224
261
  try { muxOk = !!mux.available(); } catch { muxOk = false; }
225
262
  if (!muxOk) {
@@ -321,6 +358,11 @@ export async function applyFixes(report, {
321
358
  } else {
322
359
  actions.push({ action: 'error', detail: 'could not determine a PATH that finds the multiplexer — run `unsnooze install --daemon` from your shell' });
323
360
  }
361
+ } else if (fix.action === 'narrow-state-modes') {
362
+ const r = narrowStateModes(fix.entries || []);
363
+ actions.push({ action: 'narrowed-modes', ...r });
364
+ log(`doctor: narrowed ${r.fixed} state-dir path(s) to owner-only `
365
+ + `(${r.refused} refused, ${r.ineffective} ignored by the filesystem)`);
324
366
  } else if (fix.action === 'archive-dir') {
325
367
  let target = `${fix.dir}.bak`;
326
368
  if (existsSync(target)) target = `${fix.dir}.bak.${now}`;
@@ -379,6 +421,16 @@ export async function cmdDoctor(rest = [], deps = {}) {
379
421
  if (a.action === 'killed') print(` ✓ stopped csg process ${a.pid}`);
380
422
  else if (a.action === 'removed-unit') print(` ✓ removed ${a.unit}`);
381
423
  else if (a.action === 'archived') print(` ✓ archived ${a.from} → ${a.to}`);
424
+ else if (a.action === 'narrowed-modes') {
425
+ if (a.fixed) print(` ✓ made ${a.fixed} state-dir path(s) owner-only`);
426
+ // Distinguish "cannot" from "did not": a filesystem with no POSIX
427
+ // modes reports success and changes nothing, and telling the user to
428
+ // try again would be advice that can never work.
429
+ if (a.ineffective) {
430
+ print(` · ${a.ineffective} path(s) unchanged — this filesystem does not enforce POSIX permissions`);
431
+ }
432
+ if (a.refused) print(` ✗ ${a.refused} path(s) could not be changed (permission denied)`);
433
+ }
382
434
  else if (a.action === 'error') print(` ! ${a.detail}`);
383
435
  }
384
436
  const pkg = legacy.find(f => f.id === 'csg-package');
@@ -386,8 +438,14 @@ export async function cmdDoctor(rest = [], deps = {}) {
386
438
  return 0;
387
439
  }
388
440
 
441
+ // Offer --fix whenever anything reported is actually machine-fixable, not
442
+ // only for csg leftovers: a health finding that carries a `fix` (state
443
+ // permissions) was otherwise a dead end for the reader.
444
+ const fixable = report.findings.some(f => f.fix);
389
445
  print(legacy.length
390
446
  ? '\nRun `unsnooze doctor --fix` to stop csg processes, remove its autostart, and archive its state.'
391
- : '\nSee the hints above to finish the install.');
447
+ : fixable
448
+ ? '\nRun `unsnooze doctor --fix` to apply the repairs above.'
449
+ : '\nSee the hints above to finish the install.');
392
450
  return 1;
393
451
  }
package/src/fleet.js CHANGED
@@ -5,9 +5,9 @@
5
5
  // allowlisted host tokens, closed verb set, sentinel-framed JSON, and
6
6
  // control-char-stripped ingest.
7
7
  import { join } from 'node:path';
8
- import { readFileSync, writeFileSync, renameSync, mkdirSync, existsSync as fsExistsSync } from 'node:fs';
8
+ import { readFileSync, existsSync as fsExistsSync } from 'node:fs';
9
9
  import { spawn, execFileSync, spawnSync } from 'node:child_process';
10
- import { STATE_DIR } from './config.js';
10
+ import { STATE_DIR, ensureStateDir, writePrivateFile } from './config.js';
11
11
  import { colors, shouldUseTui, makeTable, logoBlock, badge } from './tui.js';
12
12
  import { ensureAskpassHelper, resolveSecret, readSecret } from './askpass.js';
13
13
  import { UNSNOOZE_BIN } from './spawn.js';
@@ -288,10 +288,12 @@ export function readHosts() {
288
288
  }
289
289
 
290
290
  export function writeHosts(hosts) {
291
- mkdirSync(STATE_DIR, { recursive: true });
292
- const tmp = HOSTS_FILE + `.tmp.${process.pid}`;
293
- writeFileSync(tmp, JSON.stringify(hosts, null, 2) + '\n');
294
- renameSync(tmp, HOSTS_FILE);
291
+ ensureStateDir();
292
+ // Owner-only: a `command` source's --cmd string is whatever the user
293
+ // pointed at their secret store, and a keychain source names the
294
+ // service/account to look up.
295
+ writePrivateFile(HOSTS_FILE, HOSTS_FILE + `.tmp.${process.pid}`,
296
+ JSON.stringify(hosts, null, 2) + '\n');
295
297
  }
296
298
 
297
299
  function parseFlags(argv) {
@@ -740,10 +742,11 @@ export function readFleetCache() {
740
742
  }
741
743
 
742
744
  export function writeFleetCache(results) {
743
- mkdirSync(STATE_DIR, { recursive: true });
744
- const tmp = FLEET_CACHE_FILE + `.tmp.${process.pid}`;
745
- writeFileSync(tmp, JSON.stringify(results, null, 2) + '\n');
746
- renameSync(tmp, FLEET_CACHE_FILE);
745
+ ensureStateDir();
746
+ // Owner-only: the cache mirrors every remote host's cwd paths and
747
+ // queued-prompt previews.
748
+ writePrivateFile(FLEET_CACHE_FILE, FLEET_CACHE_FILE + `.tmp.${process.pid}`,
749
+ JSON.stringify(results, null, 2) + '\n');
747
750
  }
748
751
 
749
752
  // I1: a `prompt`-source host at a real interactive TTY is the one case
package/src/hook.js CHANGED
@@ -89,9 +89,10 @@ export async function runHook(rest = []) {
89
89
  // Seconds-scale problem — leave a marker for the pane's monitor, do NOT
90
90
  // record in state.json.
91
91
  if (pane) {
92
- mkdirSync(EVENTS_DIR, { recursive: true });
92
+ mkdirSync(EVENTS_DIR, { recursive: true, mode: 0o700 });
93
93
  writeFileSync(join(EVENTS_DIR, `${addressHash({ mux: muxName, paneOwner, pane })}.json`),
94
- JSON.stringify({ mux: muxName, paneOwner, pane, kind, at: Date.now(), payload: { error: payload.error ?? null } }));
94
+ JSON.stringify({ mux: muxName, paneOwner, pane, kind, at: Date.now(), payload: { error: payload.error ?? null } }),
95
+ { mode: 0o600 });
95
96
  }
96
97
  return 0;
97
98
  }
package/src/lease.js CHANGED
@@ -43,10 +43,10 @@ function leasePath(address, leaseId) {
43
43
  }
44
44
 
45
45
  export function writeLease(lease) {
46
- mkdirSync(LEASES_DIR, { recursive: true });
46
+ mkdirSync(LEASES_DIR, { recursive: true, mode: 0o700 });
47
47
  const path = leasePath(lease, lease.leaseId);
48
48
  const tmp = `${path}.tmp.${process.pid}.${randomUUID()}`;
49
- writeFileSync(tmp, JSON.stringify(lease));
49
+ writeFileSync(tmp, JSON.stringify(lease), { mode: 0o600 });
50
50
  renameSync(tmp, path);
51
51
  return lease;
52
52
  }
package/src/logger.js CHANGED
@@ -1,6 +1,6 @@
1
- import { appendFileSync, mkdirSync, statSync, renameSync, copyFileSync, truncateSync } from 'node:fs';
1
+ import { appendFileSync, statSync, renameSync, copyFileSync, truncateSync } from 'node:fs';
2
2
  import { dirname } from 'node:path';
3
- import { LOG_FILE } from './config.js';
3
+ import { LOG_FILE, ensureStateDir } from './config.js';
4
4
 
5
5
  // Logs must never grow unbounded (a single upgrade-window crash-loop once
6
6
  // produced a 10.3 MB daemon.log). One rotated generation is kept: file → .1.
@@ -46,11 +46,13 @@ export function log(component, message) {
46
46
  const line = `${new Date().toISOString()} [${component}] ${message}\n`;
47
47
  try {
48
48
  if (!dirReady) {
49
- mkdirSync(dirname(LOG_FILE), { recursive: true });
49
+ ensureStateDir(dirname(LOG_FILE));
50
50
  dirReady = true;
51
51
  }
52
52
  rotateIfLarge(LOG_FILE);
53
- appendFileSync(LOG_FILE, line);
53
+ // mode applies only when append CREATES the file — an existing log keeps
54
+ // whatever it has, and the 0700 dir is what covers it either way.
55
+ appendFileSync(LOG_FILE, line, { mode: 0o600 });
54
56
  } catch {
55
57
  // Logging must never crash the hook or monitor paths.
56
58
  }
@@ -96,9 +96,11 @@ export function createHeadless({
96
96
  // A revive is just a detached child. Its output goes to a per-session log
97
97
  // because there is no scrollback to read it out of later.
98
98
  async newWindow(sessionName, cwd, launchSpec) {
99
- mkdirSync(logDir, { recursive: true });
99
+ // 0700/0600: this log is the entire stdout+stderr of an unattended
100
+ // agent run — the most revealing thing under the state dir.
101
+ mkdirSync(logDir, { recursive: true, mode: 0o700 });
100
102
  const logPath = join(logDir, `${sessionName}.log`);
101
- const fd = openSync(logPath, 'a');
103
+ const fd = openSync(logPath, 'a', 0o600);
102
104
  const child = spawner(launchSpec.file, launchSpec.args || [], {
103
105
  cwd,
104
106
  detached: true,
@@ -29,10 +29,10 @@ export function recordOwnedSession({ mux, name }) {
29
29
  if (!mux || !name) return null;
30
30
  const record = { mux, name, createdAt: Date.now(), pid: process.pid };
31
31
  try {
32
- mkdirSync(SESSIONS_DIR(), { recursive: true });
32
+ mkdirSync(SESSIONS_DIR(), { recursive: true, mode: 0o700 });
33
33
  const path = recordPath(mux, name);
34
34
  const tmp = `${path}.tmp.${process.pid}.${randomUUID()}`;
35
- writeFileSync(tmp, JSON.stringify(record));
35
+ writeFileSync(tmp, JSON.stringify(record), { mode: 0o600 });
36
36
  renameSync(tmp, path);
37
37
  return record;
38
38
  } catch {
package/src/resumer.js CHANGED
@@ -4,12 +4,12 @@
4
4
  // fires on the next tick), then re-opens/continues every due session.
5
5
  // Exits when no non-terminal records remain; the next limit event respawns it.
6
6
 
7
- import { writeFileSync, readFileSync, unlinkSync, mkdirSync } from 'node:fs';
7
+ import { writeFileSync, readFileSync, unlinkSync } from 'node:fs';
8
8
  import { homedir } from 'node:os';
9
9
  import { spawnSync } from 'node:child_process';
10
10
  import { getMultiplexer, backendCanType } from './multiplexer.js';
11
11
  import {
12
- RESUMER_LOCK, STATE_DIR, POLL_INTERVAL_MS, STAGGER_MS, VERIFY_DELAY_MS,
12
+ RESUMER_LOCK, POLL_INTERVAL_MS, STAGGER_MS, VERIFY_DELAY_MS, ensureStateDir,
13
13
  BUSY_DEFER_MS, MAX_BUSY_DEFERS, MAX_RESUME_ATTEMPTS, READY_TIMEOUT_MS,
14
14
  CAPTURE_LINES, PANE_SCAN_LINES, RESUME_SESSION_NAME,
15
15
  RESET_MARGIN_MS, FALLBACK_RESET_MS, PROBE_INTERVAL_MS, PROBE_MAX_MS,
@@ -64,10 +64,10 @@ function defaultIsResumer(pid) {
64
64
  // read-check-write window. A held lock is honored only when its pid is alive
65
65
  // AND looks like a resumer; stale/garbage/recycled locks are replaced.
66
66
  export function acquireSingleton({ isResumer = defaultIsResumer } = {}) {
67
- mkdirSync(STATE_DIR, { recursive: true });
67
+ ensureStateDir();
68
68
  for (let attempt = 0; attempt < 2; attempt++) {
69
69
  try {
70
- writeFileSync(RESUMER_LOCK, String(process.pid), { flag: 'wx' });
70
+ writeFileSync(RESUMER_LOCK, String(process.pid), { flag: 'wx', mode: 0o600 });
71
71
  return true;
72
72
  } catch { /* lock exists — inspect the holder below */ }
73
73
  let pid = NaN;
package/src/sessions.js CHANGED
@@ -40,8 +40,26 @@ export function latestSessionId(cwd, aroundTs = null) {
40
40
  return best ? best.id : null;
41
41
  }
42
42
 
43
+ // sessionId lands here straight off a hook payload / transcript record and is
44
+ // used as a path component — the same untrusted-value-as-filename shape the
45
+ // statusline shim had, on the channel everyone has enabled. dashCwd already
46
+ // sanitizes the cwd half; a '/' or a '..' in the id half would walk this read
47
+ // out of the projects tree.
48
+ //
49
+ // Deliberately a filename-shape check rather than UUID_RE: that regex is the
50
+ // right gate for *discovering* transcripts (it reads Claude's own directory
51
+ // listing) but the wrong one here, where the id arrives from an agent whose
52
+ // naming we do not own. Requiring a uuid would silently turn off the context
53
+ // guard for any session named otherwise; requiring a safe component closes
54
+ // the traversal either way. '.' and '..' are harmless: the '.jsonl' suffix
55
+ // makes them ordinary filenames. Returns null for anything else, which every
56
+ // caller already reports as "no transcript".
57
+ const SAFE_SESSION_ID_RE = /^[A-Za-z0-9._-]{1,128}$/;
58
+
43
59
  export function transcriptPath(cwd, sessionId, { claudeDir = CLAUDE_DIR } = {}) {
44
- return join(claudeDir, 'projects', dashCwd(cwd), `${sessionId}.jsonl`);
60
+ const id = String(sessionId ?? '');
61
+ if (!SAFE_SESSION_ID_RE.test(id)) return null;
62
+ return join(claudeDir, 'projects', dashCwd(cwd), `${id}.jsonl`);
45
63
  }
46
64
 
47
65
  export function claudeRecordEnv(env = process.env) {
package/src/settings.js CHANGED
@@ -6,9 +6,9 @@
6
6
  // the global resumeMessage even when the global came from an env var (see
7
7
  // resolveResumeMessage).
8
8
 
9
- import { readFileSync, writeFileSync, renameSync, mkdirSync } from 'node:fs';
9
+ import { readFileSync } from 'node:fs';
10
10
  import { join, dirname } from 'node:path';
11
- import { MUX_NAMES, STATE_DIR } from './config.js';
11
+ import { MUX_NAMES, STATE_DIR, ensureStateDir, writePrivateFile } from './config.js';
12
12
 
13
13
  export const CONFIG_FILE = () => join(process.env.UNSNOOZE_STATE_DIR || STATE_DIR, 'config.json');
14
14
 
@@ -267,10 +267,11 @@ export function setConfigValue(key, rawValue) {
267
267
 
268
268
  export function writeConfig(config) {
269
269
  const path = CONFIG_FILE();
270
- mkdirSync(dirname(path), { recursive: true });
271
- const tmp = join(dirname(path), `.config.tmp.${process.pid}`);
272
- writeFileSync(tmp, JSON.stringify(config, null, 2) + '\n');
273
- renameSync(tmp, path);
270
+ ensureStateDir(dirname(path));
271
+ // Owner-only: ntfyToken is a Bearer credential, and config.json was
272
+ // world-readable before 1.17.0.
273
+ writePrivateFile(path, join(dirname(path), `.config.tmp.${process.pid}`),
274
+ JSON.stringify(config, null, 2) + '\n');
274
275
  }
275
276
 
276
277
  export function configFileExists() {
package/src/state.js CHANGED
@@ -10,7 +10,8 @@ import {
10
10
  } from 'node:fs';
11
11
  import { join } from 'node:path';
12
12
  import {
13
- STATE_DIR, STATE_FILE, LOCK_DIR, STALE_LOCK_MS, PRUNE_AFTER_MS,
13
+ STATE_DIR, STATE_FILE, LOCK_DIR, STALE_LOCK_MS, PRUNE_AFTER_MS, ensureStateDir,
14
+ writePrivateFile,
14
15
  DEDUPE_WINDOW_MS, STALE_AFTER_MS, PROBE_INTERVAL_MS, PROBE_MAX_MS,
15
16
  RESET_MARGIN_MS,
16
17
  } from './config.js';
@@ -48,22 +49,78 @@ function lockHolderAlive() {
48
49
  }
49
50
  }
50
51
 
52
+ // lockHolderAlive() can only ask "is this pid alive", never "is it still
53
+ // unsnooze". A leaked lock whose pid is later recycled onto some unrelated
54
+ // long-lived process therefore looks live forever, and every writer — daemon,
55
+ // CLI, in-agent hook — wedges permanently with no way out but deleting the
56
+ // directory by hand. Past this ceiling, age wins regardless.
57
+ //
58
+ // This is a deliberate trade, not a free win: stealing from a holder that IS
59
+ // still working puts two writers in the critical section, and the robbed one's
60
+ // rename then clobbers the thief's write — a lost update. It is chosen because
61
+ // a permanent unrecoverable wedge is the worse failure. The exposure is kept
62
+ // small by holding this lock only for in-memory work plus one write:
63
+ // upsertSession's git call is hoisted out for exactly this reason. Five
64
+ // minutes is ~200x the worst measured critical section.
65
+ //
66
+ // Known limit: both sides of the age are wall-clock, so a forward clock step
67
+ // (VM resume, laptop wake, a large NTP correction) can age a fresh lock past
68
+ // the ceiling at once. There is no cross-process monotonic clock to use
69
+ // instead, which is part of why the ceiling is generous rather than tight.
70
+ const HARD_STALE_LOCK_MS = Math.max(STALE_LOCK_MS * 30, 300_000);
71
+
51
72
  function acquireLock() {
52
73
  const deadline = Date.now() + LOCK_TIMEOUT_MS;
74
+ let parentRepaired = false;
53
75
  for (;;) {
54
76
  try {
55
- mkdirSync(LOCK_DIR);
77
+ mkdirSync(LOCK_DIR, { mode: 0o700 });
56
78
  // Record the holder so a slow-but-alive writer is never robbed — age
57
79
  // alone can't tell a hung process from a busy one.
58
- try { writeFileSync(join(LOCK_DIR, 'pid'), String(process.pid)); } catch { /* best-effort */ }
80
+ try { writeFileSync(join(LOCK_DIR, 'pid'), String(process.pid), { mode: 0o600 }); } catch { /* best-effort */ }
59
81
  return;
60
82
  } catch (err) {
61
- if (err.code !== 'EEXIST') { mkdirSync(STATE_DIR, { recursive: true }); continue; }
83
+ if (err.code !== 'EEXIST') {
84
+ // NOT contention. A missing STATE_DIR is worth exactly one immediate
85
+ // repair-and-retry; every other errno (EACCES on an unwritable
86
+ // ~/.unsnooze, EROFS, ENOSPC) must fall through to the same backoff
87
+ // and deadline as any other failure. This branch used to `continue`
88
+ // unconditionally, skipping both — and since mkdir -p on an existing
89
+ // STATE_DIR succeeds as a no-op, an unwritable state dir spun here
90
+ // forever at ~70% of a core, ignoring LOCK_TIMEOUT_MS, in every writer:
91
+ // the daemon, the CLI, and the StopFailure hook inside the agent.
92
+ if (!parentRepaired) {
93
+ parentRepaired = true;
94
+ try { ensureStateDir(); } catch { /* fall through to the backoff */ }
95
+ continue;
96
+ }
97
+ if (Date.now() > deadline) {
98
+ throw new Error(`unsnooze: cannot create state lock at ${LOCK_DIR}: ${err.code || err.message}`);
99
+ }
100
+ sleepSync(50);
101
+ // A *recurring* ENOENT means the directory keeps going away under us
102
+ // (an uninstall racing a write), not that the one-shot repair failed.
103
+ // Keep repairing it, but from the backoff — never in a tight loop.
104
+ if (err.code === 'ENOENT') { try { ensureStateDir(); } catch { /* next pass */ } }
105
+ continue;
106
+ }
62
107
  try {
63
- const age = Date.now() - statSync(LOCK_DIR).mtimeMs;
64
- if (age > STALE_LOCK_MS && lockHolderAlive() !== true) {
65
- rmSync(LOCK_DIR, { recursive: true, force: true }); // steal from a dead/unknown holder
66
- log(`stole stale lock (age ${Math.round(age)}ms)`);
108
+ const before = statSync(LOCK_DIR);
109
+ const age = Date.now() - before.mtimeMs;
110
+ if (age > STALE_LOCK_MS && (age > HARD_STALE_LOCK_MS || lockHolderAlive() !== true)) {
111
+ // Re-stat immediately before removing. The staleness verdict above
112
+ // costs a readFileSync + kill(), and in that gap another contender
113
+ // can have stolen this same lock and created a fresh one — removing
114
+ // that would drop a live holder's lock and break mutual exclusion.
115
+ // A matching inode means it is still the dir we judged stale. This
116
+ // narrows the window rather than closing it: a filesystem that
117
+ // recycles a just-freed directory inode can hand the same number
118
+ // back, and Windows may report 0 for both — in either case this
119
+ // degrades to the old unconditional behaviour, never to a worse one.
120
+ if (statSync(LOCK_DIR).ino === before.ino) {
121
+ rmSync(LOCK_DIR, { recursive: true, force: true }); // steal from a dead/unknown holder
122
+ log(`stole stale lock (age ${Math.round(age)}ms)`);
123
+ }
67
124
  continue;
68
125
  }
69
126
  } catch { /* lock vanished between check and stat — retry */ }
@@ -74,6 +131,14 @@ function acquireLock() {
74
131
  }
75
132
 
76
133
  function releaseLock() {
134
+ try {
135
+ // Only drop a lock we still hold. A stale-lock steal can rob a live
136
+ // holder (see acquireLock); if that happened, the dir now belongs to the
137
+ // thief, and removing it would hand a third writer a lock the thief still
138
+ // believes is theirs. An unreadable/absent pid file means the lock is
139
+ // ours-but-unstamped or already gone — both fall through to the remove.
140
+ if (readFileSync(join(LOCK_DIR, 'pid'), 'utf-8') !== String(process.pid)) return;
141
+ } catch { /* no pid file: our own best-effort stamp failed, or it's gone */ }
77
142
  try { rmSync(LOCK_DIR, { recursive: true, force: true }); } catch { /* already gone */ }
78
143
  }
79
144
 
@@ -86,7 +151,12 @@ export function readState() {
86
151
  try {
87
152
  const quarantine = `${STATE_FILE}.corrupt.${Date.now()}`;
88
153
  renameSync(STATE_FILE, quarantine);
89
- log(`CORRUPT state.json quarantined to ${quarantine}: ${err.message}`);
154
+ // err.message, NOT logged: V8 embeds the first bytes of the input in
155
+ // its JSON.parse message ("Unexpected token 'S', \"SUPER SECR\"..."),
156
+ // so a state.json that is really a symlink to something else would
157
+ // print that file's opening bytes into unsnooze.log. The quarantined
158
+ // file is right there on disk for anyone who needs to see the content.
159
+ log(`CORRUPT state.json quarantined to ${quarantine} (${err.name || 'parse error'})`);
90
160
  } catch { /* someone else quarantined it first */ }
91
161
  return EMPTY();
92
162
  }
@@ -137,14 +207,14 @@ function normalizeRecord(rec) {
137
207
  // Locked read-modify-write. mutator receives the state object and mutates it
138
208
  // (or returns a replacement). Returns the final state.
139
209
  export function updateState(mutator) {
140
- mkdirSync(STATE_DIR, { recursive: true });
210
+ ensureStateDir();
141
211
  acquireLock();
142
212
  try {
143
213
  const state = readState();
144
214
  const result = mutator(state) ?? state;
145
- const tmp = join(STATE_DIR, `.state.tmp.${process.pid}`);
146
- writeFileSync(tmp, JSON.stringify(result, null, 2));
147
- renameSync(tmp, STATE_FILE);
215
+ // Owner-only: session records embed cwd paths and queued prompt text.
216
+ writePrivateFile(STATE_FILE, join(STATE_DIR, `.state.tmp.${process.pid}`),
217
+ JSON.stringify(result, null, 2));
148
218
  return result;
149
219
  } finally {
150
220
  releaseLock();
@@ -159,6 +229,24 @@ export function updateState(mutator) {
159
229
  // calibration snapshots so stop + ceiling sample never race (1.13).
160
230
  export function upsertSession(record, { after = null } = {}) {
161
231
  record = normalizeRecord({ ...record });
232
+ // Computed BEFORE the lock, never inside the mutator. workspaceFingerprint
233
+ // shells out to git, and execFileSync's `timeout` is a soft bound — it
234
+ // sends SIGTERM at the deadline and then waits for the child to actually
235
+ // exit, which a git wedged in uninterruptible I/O on a hung network mount
236
+ // never does. That made it the one unbounded operation under the state
237
+ // lock, and the only way a critical section could outlive
238
+ // HARD_STALE_LOCK_MS and be stolen from a writer that is still working.
239
+ // The cost of hoisting is one extra git call when the record turns out to
240
+ // be a duplicate; the benefit is that the lock is only ever held for
241
+ // in-memory work plus one write.
242
+ // 'resuming' is included deliberately: the `closing` branch inside the
243
+ // mutator can flip such a record to 'stopped', and the apply site below
244
+ // would then want a baseline this precompute never made. No caller passes
245
+ // 'resuming' today — this keeps the hoist from becoming a trap for one that
246
+ // later does.
247
+ const needsFingerprint = ['stopped', 'resuming'].includes(record.status)
248
+ && record.workspace === undefined;
249
+ const fingerprint = needsFingerprint ? workspaceFingerprint(record.cwd) : undefined;
162
250
  return updateState(state => {
163
251
  prune(state);
164
252
  const closing = state.paneClosures.find(c => c.pane && c.leaseId
@@ -228,9 +316,12 @@ export function upsertSession(record, { after = null } = {}) {
228
316
  log(`merged duplicate detection for pane ${record.pane} into ${existingKey}`);
229
317
  } else {
230
318
  // Baseline for the stale-workspace guard, captured once at stop time.
231
- // (Merged duplicates above keep the ORIGINAL baseline — spread semantics.)
319
+ // (Merged duplicates above keep the ORIGINAL baseline — spread
320
+ // semantics — so this is applied on the non-duplicate branch ONLY.
321
+ // Assigning it before the branch would let the merge spread it over the
322
+ // existing record's baseline and silently reset it.)
232
323
  if (record.status === 'stopped' && record.workspace === undefined) {
233
- record.workspace = workspaceFingerprint(record.cwd);
324
+ record.workspace = fingerprint;
234
325
  }
235
326
  const key = record.sessionId || `pane:${addressHash(record)}:${record.detectedAt}`;
236
327
  applied = { ...record, key };
@@ -7,13 +7,14 @@
7
7
  // All of it is gated on the `updateCheck` setting / UNSNOOZE_UPDATE_CHECK.
8
8
  // The check is a plain GET to registry.npmjs.org — nothing identifying.
9
9
 
10
- import { readFileSync, writeFileSync, renameSync, mkdirSync, existsSync } from 'node:fs';
10
+ import { readFileSync, existsSync } from 'node:fs';
11
11
  import { spawnSync } from 'node:child_process';
12
12
  import { join, dirname } from 'node:path';
13
13
  import { fileURLToPath } from 'node:url';
14
14
  import { getConfig } from './settings.js';
15
15
  import { notify } from './notify.js';
16
16
  import { UNSNOOZE_BIN, pidAlive } from './spawn.js';
17
+ import { ensureStateDir, writePrivateFile } from './config.js';
17
18
 
18
19
  const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
19
20
  export const PKG_VERSION = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf-8')).version;
@@ -31,10 +32,11 @@ export function readCache() {
31
32
  export function writeCache(patch) {
32
33
  const merged = { ...readCache(), ...patch };
33
34
  const path = CACHE_FILE();
34
- mkdirSync(dirname(path), { recursive: true });
35
- const tmp = join(dirname(path), `.update-check.tmp.${process.pid}`);
36
- writeFileSync(tmp, JSON.stringify(merged, null, 2) + '\n');
37
- renameSync(tmp, path);
35
+ // _update-check runs as a detached child, so on a fresh install this can be
36
+ // the process that creates ~/.unsnooze — it must create it owner-only.
37
+ ensureStateDir(dirname(path));
38
+ writePrivateFile(path, join(dirname(path), `.update-check.tmp.${process.pid}`),
39
+ JSON.stringify(merged, null, 2) + '\n');
38
40
  return merged;
39
41
  }
40
42
 
package/src/usage.js CHANGED
@@ -11,7 +11,7 @@ import {
11
11
  CLAUDE_DIR, CODEX_DIR, USAGE_FILE, USAGE_ETA_WARN_MIN,
12
12
  USAGE_BURN_LOOKBACK_MS, USAGE_BURN_MIN_COVERAGE_MS, USAGE_IDLE_GAP_MS,
13
13
  USAGE_WINDOW_IDLE_MS, USAGE_CALIBRATION_RING, USAGE_CALIBRATION_MEDIAN_N,
14
- USAGE_STATUSLINE_DIR,
14
+ USAGE_STATUSLINE_DIR, ensureStateDir, writePrivateFile,
15
15
  } from './config.js';
16
16
  import { readState, updateState } from './state.js';
17
17
  import { getConfig } from './settings.js';
@@ -535,10 +535,9 @@ export function readUsageStore(path = USAGE_FILE) {
535
535
  }
536
536
 
537
537
  export function writeUsageStore(store, path = USAGE_FILE) {
538
- mkdirSync(dirname(path), { recursive: true });
539
- const tmp = join(dirname(path), `.usage.tmp.${process.pid}`);
540
- writeFileSync(tmp, JSON.stringify(store));
541
- renameSync(tmp, path);
538
+ ensureStateDir(dirname(path));
539
+ writePrivateFile(path, join(dirname(path), `.usage.tmp.${process.pid}`),
540
+ JSON.stringify(store));
542
541
  return store;
543
542
  }
544
543
 
@@ -1239,15 +1238,30 @@ let data = {};
1239
1238
  try { data = JSON.parse(raw || '{}'); } catch { /* ignore */ }
1240
1239
 
1241
1240
  const dir = path.join(os.homedir(), '.claude', 'unsnooze');
1242
- try { fs.mkdirSync(dir, { recursive: true }); } catch { /* ignore */ }
1243
- const sid = data.session_id || data.sessionId || 'unknown';
1241
+ // 0700/0600 like the state dir: these drops carry your live rate-limit
1242
+ // numbers, and the shim is the only thing that creates them. mkdir's mode is
1243
+ // ignored for a directory that already exists, so an install that predates
1244
+ // this is repaired explicitly — the same reasoning ensureStateDir applies one
1245
+ // directory over. Skipped on Windows, where the POSIX bits are synthetic.
1246
+ try { fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); } catch { /* ignore */ }
1247
+ if (process.platform !== 'win32') {
1248
+ try { fs.chmodSync(dir, 0o700); } catch { /* not ours */ }
1249
+ }
1250
+ // The id becomes a filename. Claude Code sends a uuid, but nothing here is
1251
+ // in a position to trust that: a '/' or a '..' would walk the drop file out
1252
+ // of ~/.claude/unsnooze and overwrite any .json the user can write.
1253
+ const rawSid = String(data.session_id || data.sessionId || 'unknown');
1254
+ const sid = /^[A-Za-z0-9._-]{1,128}$/.test(rawSid) ? rawSid : 'unknown';
1244
1255
  const drop = path.join(dir, 'usage-' + sid + '.json');
1245
1256
  try {
1246
1257
  fs.writeFileSync(drop, JSON.stringify({
1247
1258
  rate_limits: data.rate_limits || null,
1248
1259
  at: Date.now(),
1249
1260
  sessionId: sid,
1250
- }));
1261
+ }), { mode: 0o600 });
1262
+ // mode only lands when writeFileSync CREATES the file; an existing drop
1263
+ // from before this change keeps its own until chmod'd.
1264
+ if (process.platform !== 'win32') fs.chmodSync(drop, 0o600);
1251
1265
  } catch { /* ignore */ }
1252
1266
 
1253
1267
  const orig = process.env.UNSNOOZE_STATUSLINE_ORIG || '';
package/src/watcher.js CHANGED
@@ -293,9 +293,9 @@ export function createWatcher({
293
293
  }
294
294
  if (!dirty) return;
295
295
  try {
296
- mkdirSync(dirname(offsetsPath), { recursive: true });
296
+ mkdirSync(dirname(offsetsPath), { recursive: true, mode: 0o700 });
297
297
  const tmp = join(dirname(offsetsPath), `.offsets.tmp.${process.pid}`);
298
- writeFileSync(tmp, JSON.stringify(offsets));
298
+ writeFileSync(tmp, JSON.stringify(offsets), { mode: 0o600 });
299
299
  renameSync(tmp, offsetsPath);
300
300
  dirty = false;
301
301
  } catch (err) {