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 {
|
|
14
|
-
|
|
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
|
-
|
|
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
|