unsnooze 1.16.3 → 1.18.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/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');
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/install.js CHANGED
@@ -16,6 +16,7 @@ import { CLAUDE_SETTINGS, STATE_DIR } from './config.js';
16
16
  import { getConfig, configFileExists } from './settings.js';
17
17
  import { xmlEscape } from './notify.js';
18
18
  import { installGrokHooks, uninstallGrokHooks } from './agents/grok.js';
19
+ import { getAgent } from './agents/index.js';
19
20
  import { findCsgProcesses, findCsgAutostarts } from './doctor.js';
20
21
  import { UNSNOOZE_BIN, stopResumer } from './spawn.js';
21
22
  import { uninstallStatuslineShim } from './usage.js';
@@ -115,18 +116,29 @@ export function removeHookFromSettings(settingsJson) {
115
116
 
116
117
  // --- zshrc block management ---
117
118
 
119
+ // Which shell commands an agent's wrappers shadow. Normally exactly the id,
120
+ // because the id IS the command users type. Cursor breaks that: its id is
121
+ // `cursor` but its command is `cursor-agent`, and the bare `cursor` belongs to
122
+ // the IDE launcher (`cursor .`) which must never be shadowed. An adapter names
123
+ // the commands to wrap via `wrapperNames`; the `_run` argument stays the id.
124
+ export function wrapperNamesFor(id) {
125
+ const agent = getAgent(id);
126
+ // getAgent falls back to claude for unknown ids — never borrow its names.
127
+ return agent.id === id && agent.wrapperNames?.length ? agent.wrapperNames : [id];
128
+ }
129
+
118
130
  export function wrapperBlock(agents = ['claude']) {
119
131
  // The missing-file guard is load-bearing: if unsnooze is ever uninstalled,
120
132
  // moved, or renamed without cleaning the rc file, the wrapper must degrade
121
133
  // to the plain CLI — never brick the user's `claude`/`codex` command.
122
- const fns = agents.map(id => `unalias ${id} 2>/dev/null || true
123
- ${id}() {
134
+ const fns = agents.flatMap(id => wrapperNamesFor(id).map(name => `unalias ${name} 2>/dev/null || true
135
+ ${name}() {
124
136
  if [ "\${UNSNOOZE_ACTIVE}" = "1" ] || [ ! -f "${UNSNOOZE_BIN}" ]; then
125
- command ${id} "$@"
137
+ command ${name} "$@"
126
138
  return $?
127
139
  fi
128
140
  node "${UNSNOOZE_BIN}" _run ${id} "$@"
129
- }`).join('\n');
141
+ }`)).join('\n');
130
142
  return `${FENCE_OPEN}
131
143
  # unsnooze wrappers: route every interactive launch of the CLIs below through
132
144
  # unsnooze so limit stops are recorded and auto-resumed.
@@ -142,16 +154,16 @@ ${FENCE_CLOSE}`;
142
154
  // PowerShell comments are `#`, so the same fence markers work and
143
155
  // stripFencedBlock() removes it unchanged.
144
156
  export function powershellWrapperBlock(agents = ['claude'], bin = UNSNOOZE_BIN) {
145
- const fns = agents.map(id => `Remove-Item -Path Alias:${id} -Force -ErrorAction SilentlyContinue
146
- function ${id} {
157
+ const fns = agents.flatMap(id => wrapperNamesFor(id).map(name => `Remove-Item -Path Alias:${name} -Force -ErrorAction SilentlyContinue
158
+ function ${name} {
147
159
  if ($env:UNSNOOZE_ACTIVE -eq '1' -or -not (Test-Path -LiteralPath '${bin}')) {
148
- $real = Get-Command ${id} -CommandType Application -ErrorAction SilentlyContinue |
160
+ $real = Get-Command ${name} -CommandType Application -ErrorAction SilentlyContinue |
149
161
  Select-Object -First 1
150
- if ($real) { & $real.Source @args } else { Write-Error '${id}: not found' }
162
+ if ($real) { & $real.Source @args } else { Write-Error '${name}: not found' }
151
163
  return
152
164
  }
153
165
  & node '${bin}' _run ${id} @args
154
- }`).join('\n');
166
+ }`)).join('\n');
155
167
  return `${FENCE_OPEN}
156
168
  # unsnooze wrappers: route every interactive launch of the CLIs below through
157
169
  # unsnooze so limit stops are recorded and auto-resumed.
@@ -502,7 +514,7 @@ export function uninstallDaemonAutostart({ platform = process.platform, dir = nu
502
514
  // --- commands ---
503
515
 
504
516
  export function enabledAgents() {
505
- return ['claude', 'codex', 'grok', 'qwen', 'kimi', 'opencode', 'agy'].filter(id => getConfig(`agents.${id}`));
517
+ return ['claude', 'codex', 'grok', 'qwen', 'kimi', 'opencode', 'agy', 'cursor'].filter(id => getConfig(`agents.${id}`));
506
518
  }
507
519
 
508
520
  // Qwen keeps Claude-shaped hooks in its own settings.json — reuse the same
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
  }
package/src/monitor.js CHANGED
@@ -16,7 +16,7 @@ import {
16
16
  EVENT_MARKER_TTL_MS, OVERLOAD_BACKOFF_S, OVERLOAD_JITTER,
17
17
  PROBE_INTERVAL_MS, RESET_MARGIN_MS, LEASE_GRACE_MS,
18
18
  } from './config.js';
19
- import { detectLimit, isBusy, overloadMatch } from './patterns.js';
19
+ import { detectLimit, isBusy, overloadMatch, modelRemedy } from './patterns.js';
20
20
  import { getAgent } from './agents/index.js';
21
21
  import { getConfig } from './settings.js';
22
22
  import { notify } from './notify.js';
@@ -208,7 +208,7 @@ export function createMonitor({
208
208
  log(`pane ${pane}: limit recorded (${resolved.limitType}, via ${detectedVia}), resets ${new Date(at).toISOString()} (${source})`);
209
209
  if (resolved.limitType === 'model') {
210
210
  notifier(`${agent.name} hit a model limit — needs you`,
211
- `${cwd} — switch models (/model) or add credits (/usage-credits); unsnooze wakes it once the banner clears`,
211
+ `${cwd} — ${modelRemedy(agent)}; unsnooze wakes it once the banner clears`,
212
212
  { context: notifyCtx });
213
213
  } else {
214
214
  notifier(`${agent.name} hit a usage limit`, `${cwd} — auto-resume at ${new Date(at).toLocaleTimeString()}`, { context: notifyCtx });
@@ -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/patterns.js CHANGED
@@ -148,6 +148,13 @@ export function detectLimit(text, tailLines = 12, sets = claudePatterns) {
148
148
  return { hit: true, limitType, resetLine };
149
149
  }
150
150
 
151
+ // What a user can actually do about a model limit. There is no reset time to
152
+ // wait for, so the notification must name a real remedy — and the remedy is
153
+ // per-CLI (claude has /usage-credits, cursor does not).
154
+ export function modelRemedy(agent) {
155
+ return agent?.modelRemedy || 'switch models or add credits';
156
+ }
157
+
151
158
  function tail(text, n = 12) {
152
159
  return contentLines(text, n);
153
160
  }
package/src/resumer.js CHANGED
@@ -4,17 +4,17 @@
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,
16
16
  } from './config.js';
17
- import { detectLimit, isBusy } from './patterns.js';
17
+ import { detectLimit, isBusy, modelRemedy } from './patterns.js';
18
18
  import { getAgent } from './agents/index.js';
19
19
  import { parseResetTime, resetAtMs, nextProbeDelayMs } from './time-parser.js';
20
20
  import {
@@ -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;
@@ -372,7 +372,7 @@ function rescheduleProbe(rec, now = Date.now()) {
372
372
  }, { expect: ['stopped'] });
373
373
  log(`${key}: model limit still active at probe ceiling — needs a human`);
374
374
  notify('unsnooze: model limit needs you ⚠️',
375
- `${rec.cwd}: still limited after probing — /model to switch or /usage-credits`,
375
+ `${rec.cwd}: still limited after probing — ${modelRemedy(getAgent(rec.agent))}`,
376
376
  { context: ctxOf(rec), priority: 4 });
377
377
  return 'held';
378
378
  }
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) {