clementine-agent 1.1.12 → 1.1.14

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.
@@ -10,8 +10,17 @@
10
10
  * data pipeline is identical and any divergence is purely rule-evaluation.
11
11
  */
12
12
  import { CronRunLog } from '../../gateway/cron-scheduler.js';
13
- import { CIRCUIT_BREAKER_COOLDOWN_MS as _COOLDOWN_MS, DEFAULT_MAX_TURNS_FALLBACK, DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS, TIER_MAX_TURNS, getInterventionStats, readReflections, } from '../execution-advisor.js';
14
- void _COOLDOWN_MS; // currently encoded as a literal in builtin YAMLs; re-export hook
13
+ import { DEFAULT_MAX_TURNS_FALLBACK, DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS, TIER_MAX_TURNS, getInterventionStats, readReflections, } from '../execution-advisor.js';
14
+ // NOTE: Phase 9c (commit 4451f36) made execution-advisor.ts static-import
15
+ // THIS module, creating a circular dep. The previous module-init line
16
+ // `void CIRCUIT_BREAKER_COOLDOWN_MS as _COOLDOWN_MS` was deferring access
17
+ // in source comment terms but actually FORCED a TDZ access at context.ts
18
+ // module-init — which is BEFORE execution-advisor.ts has reached line 38
19
+ // where the const is declared. That produced "Cannot access '_COOLDOWN_MS'
20
+ // before initialization" errors on every cron run after Phase 9c shipped.
21
+ // Removed the import + the void line. The cooldown duration is encoded as
22
+ // a literal in the builtin YAML rules, so this module never actually
23
+ // needed the constant — the import was documentation noise.
15
24
  /**
16
25
  * Build a fresh RuleContext for a job. Pass an existing `advice` if you want
17
26
  * to mutate it (e.g. shadow mode passes a clone so the TS path's advice is
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.14",
4
4
  "description": "Clementine — Personal AI Assistant (TypeScript)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",