clementine-agent 1.1.12 → 1.1.13

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/dist/cli/index.js CHANGED
@@ -899,7 +899,10 @@ function cmdConfigSet(key, value) {
899
899
  else {
900
900
  content = content.trimEnd() + `\n${upperKey}=${value}\n`;
901
901
  }
902
- writeFileSync(ENV_PATH, content);
902
+ // Mode 0o600 — credentials live here; never world-readable. Phase 12
903
+ // hardening also fixes pre-existing .env files via `clementine config
904
+ // harden-permissions`.
905
+ writeFileSync(ENV_PATH, content, { mode: 0o600 });
903
906
  console.log(` Set ${upperKey}=${value}`);
904
907
  }
905
908
  function cmdConfigGet(key) {
@@ -1217,6 +1220,65 @@ async function cmdConfigMigrateFromKeychain(opts) {
1217
1220
  console.log(` ${GREEN}Migrated ${result.migrated.length} key${result.migrated.length === 1 ? '' : 's'} out of keychain.${RESET}`);
1218
1221
  console.log();
1219
1222
  }
1223
+ // ── Config harden-permissions ────────────────────────────────────────
1224
+ async function cmdConfigHardenPermissions(opts) {
1225
+ const { hardenPermissions } = await import('../config/harden-permissions.js');
1226
+ const report = hardenPermissions(BASE_DIR, { dryRun: opts.dryRun });
1227
+ if (opts.json) {
1228
+ console.log(JSON.stringify(report, null, 2));
1229
+ process.exit(report.failed > 0 ? 1 : 0);
1230
+ }
1231
+ const DIM = '\x1b[0;90m';
1232
+ const BOLD = '\x1b[1m';
1233
+ const GREEN = '\x1b[0;32m';
1234
+ const YELLOW = '\x1b[0;33m';
1235
+ const RED = '\x1b[0;31m';
1236
+ const CYAN = '\x1b[0;36m';
1237
+ const RESET = '\x1b[0m';
1238
+ console.log();
1239
+ console.log(` ${BOLD}Data home:${RESET} ${report.baseDir}`);
1240
+ console.log(` ${BOLD}Mode:${RESET} ${opts.dryRun ? `${YELLOW}dry run${RESET}` : `${GREEN}apply${RESET}`}`);
1241
+ console.log();
1242
+ console.log(` ${BOLD}Scanned:${RESET} ${report.scanned}`);
1243
+ console.log(` ${GREEN}Tightened:${RESET} ${report.tightened}`);
1244
+ console.log(` ${DIM}Already correct:${RESET} ${report.alreadyCorrect}`);
1245
+ console.log(` ${DIM}Skipped:${RESET} ${report.skipped}`);
1246
+ if (report.failed > 0) {
1247
+ console.log(` ${RED}Failed:${RESET} ${report.failed}`);
1248
+ }
1249
+ console.log();
1250
+ // Show first ~20 tightened entries — enough to see the shape without flooding
1251
+ const tightened = report.entries.filter(e => e.status === 'tightened').slice(0, 20);
1252
+ if (tightened.length > 0) {
1253
+ console.log(` ${BOLD}${opts.dryRun ? 'Would tighten' : 'Tightened'} (showing first ${tightened.length}):${RESET}`);
1254
+ for (const e of tightened) {
1255
+ const rel = e.path.startsWith(report.baseDir + '/') ? e.path.slice(report.baseDir.length + 1) : e.path;
1256
+ const kindTag = e.kind === 'directory' ? `${CYAN}d${RESET}` : `${DIM}-${RESET}`;
1257
+ console.log(` ${kindTag} ${e.beforeMode} ${DIM}→${RESET} ${GREEN}${e.afterMode}${RESET} ${rel}`);
1258
+ }
1259
+ if (report.tightened > tightened.length) {
1260
+ console.log(` ${DIM}... ${report.tightened - tightened.length} more${RESET}`);
1261
+ }
1262
+ console.log();
1263
+ }
1264
+ const failed = report.entries.filter(e => e.status === 'failed').slice(0, 10);
1265
+ if (failed.length > 0) {
1266
+ console.log(` ${RED}${BOLD}Failed:${RESET}`);
1267
+ for (const e of failed) {
1268
+ console.log(` ${RED}✗${RESET} ${e.path} — ${e.error ?? 'unknown'}`);
1269
+ }
1270
+ console.log();
1271
+ }
1272
+ if (opts.dryRun) {
1273
+ console.log(` ${DIM}Re-run without --dry-run to apply.${RESET}`);
1274
+ console.log();
1275
+ }
1276
+ else if (report.tightened > 0) {
1277
+ console.log(` ${GREEN}Done.${RESET} ${DIM}Re-run \`clementine config doctor\` to confirm permissions are now correct.${RESET}`);
1278
+ console.log();
1279
+ }
1280
+ process.exit(report.failed > 0 ? 1 : 0);
1281
+ }
1220
1282
  // ── Config keychain-fix-acl ─────────────────────────────────────────
1221
1283
  async function cmdConfigKeychainFixAcl(opts) {
1222
1284
  const { listClementineKeychainEntries, fixAllClementineEntries } = await import('../config/keychain-fix-acl.js');
@@ -2004,6 +2066,14 @@ configCmd
2004
2066
  .action(async (opts) => {
2005
2067
  await cmdConfigKeychainFixAcl(opts);
2006
2068
  });
2069
+ configCmd
2070
+ .command('harden-permissions')
2071
+ .description('Tighten file modes in ~/.clementine/ — files to 0600, directories to 0700')
2072
+ .option('--dry-run', 'Preview without changing anything')
2073
+ .option('--json', 'Emit machine-readable JSON')
2074
+ .action(async (opts) => {
2075
+ await cmdConfigHardenPermissions(opts);
2076
+ });
2007
2077
  configCmd
2008
2078
  .command('edit')
2009
2079
  .description('Open .env in your editor')
@@ -2229,10 +2299,10 @@ async function cmdUpdate(options) {
2229
2299
  console.log(` ${S()} Backing up config...`);
2230
2300
  if (!options.dryRun) {
2231
2301
  mkdirSync(backupDir, { recursive: true });
2232
- // .env
2302
+ // .env (mode 0o600 — backup carries credentials, same protection as live file)
2233
2303
  if (existsSync(ENV_PATH)) {
2234
2304
  const envContent = readFileSync(ENV_PATH, 'utf-8');
2235
- writeFileSync(path.join(backupDir, '.env'), envContent);
2305
+ writeFileSync(path.join(backupDir, '.env'), envContent, { mode: 0o600 });
2236
2306
  }
2237
2307
  // Cron state
2238
2308
  const cronStateFile = path.join(BASE_DIR, '.cron_last_run.json');
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Tighten file modes on the Clementine data home.
3
+ *
4
+ * Walks ~/.clementine/ (or any baseDir) and:
5
+ * - Sets every regular file to mode 0600 (owner read/write only)
6
+ * - Sets every directory to mode 0700 (owner traverse only)
7
+ *
8
+ * Why: state files live alongside `credentials.json` and `.env`. Even
9
+ * after Phase 9b's outbound redaction, contents on disk include
10
+ * conversation transcripts, advisor LLM outputs, cron run logs, full
11
+ * Discord/Slack message snippets, etc. Default umask 022 leaves all of
12
+ * those world-readable; on a single-user laptop the practical risk is
13
+ * Time Machine backup exposure and any process running as the user.
14
+ *
15
+ * Pure: returns a result describing what was tightened, what was already
16
+ * correct, and what failed (e.g. permission denied if user wasn't owner).
17
+ * Caller decides whether to print, JSON-stringify, or act on the result.
18
+ *
19
+ * Idempotent: re-running on an already-hardened tree is a no-op (every
20
+ * entry returns "already correct"). chmod is the cheapest syscall —
21
+ * even a 1000-file tree completes in milliseconds.
22
+ *
23
+ * Skip list: a few entries are intentionally NOT touched:
24
+ * - Symlinks (chmod follows symlinks; could escalate elsewhere). We
25
+ * check via lstat and skip non-files/non-dirs.
26
+ * - Files we don't own (chmod will fail; we capture the failure in
27
+ * the report rather than crashing).
28
+ */
29
+ export type HardenStatus = 'tightened' | 'already-correct' | 'skipped' | 'failed';
30
+ export interface HardenEntry {
31
+ path: string;
32
+ kind: 'file' | 'directory' | 'other';
33
+ beforeMode: string;
34
+ afterMode: string;
35
+ status: HardenStatus;
36
+ error?: string;
37
+ }
38
+ export interface HardenReport {
39
+ baseDir: string;
40
+ scanned: number;
41
+ tightened: number;
42
+ alreadyCorrect: number;
43
+ skipped: number;
44
+ failed: number;
45
+ /** Per-entry detail. Cap at 50 in printable form to keep output sane;
46
+ * full list available in the JSON output. */
47
+ entries: HardenEntry[];
48
+ }
49
+ export declare function hardenPermissions(baseDir: string, opts?: {
50
+ dryRun?: boolean;
51
+ }): HardenReport;
52
+ //# sourceMappingURL=harden-permissions.d.ts.map
@@ -0,0 +1,181 @@
1
+ /**
2
+ * Tighten file modes on the Clementine data home.
3
+ *
4
+ * Walks ~/.clementine/ (or any baseDir) and:
5
+ * - Sets every regular file to mode 0600 (owner read/write only)
6
+ * - Sets every directory to mode 0700 (owner traverse only)
7
+ *
8
+ * Why: state files live alongside `credentials.json` and `.env`. Even
9
+ * after Phase 9b's outbound redaction, contents on disk include
10
+ * conversation transcripts, advisor LLM outputs, cron run logs, full
11
+ * Discord/Slack message snippets, etc. Default umask 022 leaves all of
12
+ * those world-readable; on a single-user laptop the practical risk is
13
+ * Time Machine backup exposure and any process running as the user.
14
+ *
15
+ * Pure: returns a result describing what was tightened, what was already
16
+ * correct, and what failed (e.g. permission denied if user wasn't owner).
17
+ * Caller decides whether to print, JSON-stringify, or act on the result.
18
+ *
19
+ * Idempotent: re-running on an already-hardened tree is a no-op (every
20
+ * entry returns "already correct"). chmod is the cheapest syscall —
21
+ * even a 1000-file tree completes in milliseconds.
22
+ *
23
+ * Skip list: a few entries are intentionally NOT touched:
24
+ * - Symlinks (chmod follows symlinks; could escalate elsewhere). We
25
+ * check via lstat and skip non-files/non-dirs.
26
+ * - Files we don't own (chmod will fail; we capture the failure in
27
+ * the report rather than crashing).
28
+ */
29
+ import { readdirSync, lstatSync, chmodSync } from 'node:fs';
30
+ import path from 'node:path';
31
+ const FILE_TARGET = 0o600;
32
+ const DIR_TARGET = 0o700;
33
+ function octal(mode) {
34
+ return (mode & 0o777).toString(8).padStart(3, '0');
35
+ }
36
+ /**
37
+ * Walk a directory tree iteratively (not recursively — no stack-blow risk
38
+ * on deep vault trees). Returns absolute paths.
39
+ */
40
+ function walk(root) {
41
+ const out = [];
42
+ const stack = [root];
43
+ while (stack.length > 0) {
44
+ const dir = stack.pop();
45
+ let entries;
46
+ try {
47
+ entries = readdirSync(dir);
48
+ }
49
+ catch {
50
+ continue; // unreadable dir — caller will see it as skipped
51
+ }
52
+ for (const name of entries) {
53
+ const full = path.join(dir, name);
54
+ try {
55
+ const st = lstatSync(full);
56
+ if (st.isSymbolicLink())
57
+ continue; // never follow
58
+ if (st.isDirectory()) {
59
+ out.push(full);
60
+ stack.push(full);
61
+ }
62
+ else if (st.isFile()) {
63
+ out.push(full);
64
+ }
65
+ }
66
+ catch {
67
+ // stat failed — skip
68
+ }
69
+ }
70
+ }
71
+ return out;
72
+ }
73
+ export function hardenPermissions(baseDir, opts = {}) {
74
+ const report = {
75
+ baseDir,
76
+ scanned: 0,
77
+ tightened: 0,
78
+ alreadyCorrect: 0,
79
+ skipped: 0,
80
+ failed: 0,
81
+ entries: [],
82
+ };
83
+ // Always include baseDir itself in the walk
84
+ let baseSt;
85
+ try {
86
+ baseSt = lstatSync(baseDir);
87
+ }
88
+ catch (err) {
89
+ report.entries.push({
90
+ path: baseDir,
91
+ kind: 'other',
92
+ beforeMode: '???',
93
+ afterMode: '???',
94
+ status: 'failed',
95
+ error: `baseDir not accessible: ${String(err).slice(0, 100)}`,
96
+ });
97
+ report.failed++;
98
+ return report;
99
+ }
100
+ if (!baseSt.isDirectory()) {
101
+ report.entries.push({
102
+ path: baseDir,
103
+ kind: 'other',
104
+ beforeMode: octal(baseSt.mode),
105
+ afterMode: octal(baseSt.mode),
106
+ status: 'skipped',
107
+ error: 'baseDir is not a directory',
108
+ });
109
+ report.skipped++;
110
+ return report;
111
+ }
112
+ const all = [baseDir, ...walk(baseDir)];
113
+ for (const p of all) {
114
+ let st;
115
+ try {
116
+ st = lstatSync(p);
117
+ }
118
+ catch (err) {
119
+ report.entries.push({
120
+ path: p,
121
+ kind: 'other',
122
+ beforeMode: '???',
123
+ afterMode: '???',
124
+ status: 'failed',
125
+ error: String(err).slice(0, 100),
126
+ });
127
+ report.failed++;
128
+ continue;
129
+ }
130
+ report.scanned++;
131
+ const beforeMode = octal(st.mode);
132
+ let kind = 'other';
133
+ let target = null;
134
+ if (st.isDirectory()) {
135
+ kind = 'directory';
136
+ target = DIR_TARGET;
137
+ }
138
+ else if (st.isFile()) {
139
+ kind = 'file';
140
+ target = FILE_TARGET;
141
+ }
142
+ if (target === null) {
143
+ // Sockets, FIFOs, devices, etc. — leave alone.
144
+ report.entries.push({
145
+ path: p, kind, beforeMode, afterMode: beforeMode, status: 'skipped',
146
+ });
147
+ report.skipped++;
148
+ continue;
149
+ }
150
+ if ((st.mode & 0o777) === target) {
151
+ report.entries.push({
152
+ path: p, kind, beforeMode, afterMode: beforeMode, status: 'already-correct',
153
+ });
154
+ report.alreadyCorrect++;
155
+ continue;
156
+ }
157
+ if (opts.dryRun) {
158
+ report.entries.push({
159
+ path: p, kind, beforeMode, afterMode: octal(target), status: 'tightened',
160
+ });
161
+ report.tightened++;
162
+ continue;
163
+ }
164
+ try {
165
+ chmodSync(p, target);
166
+ report.entries.push({
167
+ path: p, kind, beforeMode, afterMode: octal(target), status: 'tightened',
168
+ });
169
+ report.tightened++;
170
+ }
171
+ catch (err) {
172
+ report.entries.push({
173
+ path: p, kind, beforeMode, afterMode: beforeMode, status: 'failed',
174
+ error: String(err).slice(0, 100),
175
+ });
176
+ report.failed++;
177
+ }
178
+ }
179
+ return report;
180
+ }
181
+ //# sourceMappingURL=harden-permissions.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clementine-agent",
3
- "version": "1.1.12",
3
+ "version": "1.1.13",
4
4
  "description": "Clementine — Personal AI Assistant (TypeScript)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",