clearhand 0.1.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/LICENSE +202 -0
- package/README.md +146 -0
- package/SECURITY.md +77 -0
- package/bin/clearhand.js +230 -0
- package/dist/mcp/index.js +159 -0
- package/dist/server/connectors/crawl.js +52 -0
- package/dist/server/connectors/github.js +45 -0
- package/dist/server/connectors/mailbox.js +257 -0
- package/dist/server/connectors/stripe.js +252 -0
- package/dist/server/context.js +83 -0
- package/dist/server/db/index.js +324 -0
- package/dist/server/demo.js +194 -0
- package/dist/server/index.js +51 -0
- package/dist/server/routes/api.js +704 -0
- package/dist/server/secrets/redaction.js +62 -0
- package/dist/server/secrets/store.js +238 -0
- package/dist/server/services/approval.js +206 -0
- package/dist/server/services/connections.js +99 -0
- package/dist/server/services/executor.js +417 -0
- package/dist/server/services/lint.js +129 -0
- package/dist/server/services/onboarding.js +168 -0
- package/dist/server/services/overlay.js +65 -0
- package/dist/server/trust/egress.js +12 -0
- package/dist/shared/actions.js +136 -0
- package/dist/shared/config.js +33 -0
- package/dist/shared/paths.js +34 -0
- package/package.json +73 -0
- package/public/assets/index-BicJ8AKo.css +1 -0
- package/public/assets/index-DZSNZW8-.js +43 -0
- package/public/index.html +17 -0
- package/templates/CLEARHAND.md +79 -0
- package/templates/overlay/escalation.md +21 -0
- package/templates/overlay/policies.md +30 -0
- package/templates/overlay/products.md +25 -0
- package/templates/overlay/tone-lint.json +14 -0
- package/templates/overlay/voice.md +24 -0
- package/templates/skills/clearhand-cs/SKILL.md +75 -0
- package/templates/skills/clearhand-onboard/SKILL.md +126 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Defense-in-depth: no route or MCP tool is supposed to return a credential,
|
|
3
|
+
* but if a bug ever leaks one, this middleware turns the leak into a visible
|
|
4
|
+
* non-event. It scrubs:
|
|
5
|
+
*
|
|
6
|
+
* 1. any secret value that has actually been decrypted in this process
|
|
7
|
+
* (SecretStore.redactableValues), and
|
|
8
|
+
* 2. generic secret-shaped tokens (Stripe keys, GitHub tokens, app passwords
|
|
9
|
+
* in obvious key=value shapes), whether or not ClearHand stored them.
|
|
10
|
+
*/
|
|
11
|
+
const PATTERN_REDACTIONS = [
|
|
12
|
+
{ name: 'stripe_secret_key', re: /\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{8,}\b/g },
|
|
13
|
+
{ name: 'stripe_webhook_secret', re: /\bwhsec_[A-Za-z0-9]{8,}\b/g },
|
|
14
|
+
{ name: 'github_token', re: /\bgh[pousr]_[A-Za-z0-9]{20,}\b/g },
|
|
15
|
+
{ name: 'bearer_token', re: /\b(Authorization:\s*Bearer\s+)[A-Za-z0-9._~+/-]{12,}=*/gi },
|
|
16
|
+
];
|
|
17
|
+
function escapeRegExp(s) {
|
|
18
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
19
|
+
}
|
|
20
|
+
export function redactText(body, store) {
|
|
21
|
+
let out = body;
|
|
22
|
+
for (const [key, value] of store.redactableValues()) {
|
|
23
|
+
if (value.length < 6)
|
|
24
|
+
continue; // too short to scrub without mangling text
|
|
25
|
+
out = out.replaceAll(value, `[REDACTED:${key}]`);
|
|
26
|
+
// JSON-encoded occurrences (e.g. value embedded inside a serialized string)
|
|
27
|
+
const jsonEncoded = JSON.stringify(value).slice(1, -1);
|
|
28
|
+
if (jsonEncoded !== value) {
|
|
29
|
+
out = out.replaceAll(jsonEncoded, `[REDACTED:${key}]`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
for (const { name, re } of PATTERN_REDACTIONS) {
|
|
33
|
+
out = out.replace(re, (m, prefix) => typeof prefix === 'string' ? `${prefix}[REDACTED:${name}]` : `[REDACTED:${name}]`);
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Express middleware. Buffers JSON/text responses and scrubs them before they
|
|
39
|
+
* leave the server. Static assets and streams pass through untouched.
|
|
40
|
+
*/
|
|
41
|
+
export function redactionMiddleware(store) {
|
|
42
|
+
return (_req, res, next) => {
|
|
43
|
+
const originalJson = res.json.bind(res);
|
|
44
|
+
const originalSend = res.send.bind(res);
|
|
45
|
+
res.json = (payload) => {
|
|
46
|
+
const scrubbed = JSON.parse(redactText(JSON.stringify(payload), store));
|
|
47
|
+
return originalJson(scrubbed);
|
|
48
|
+
};
|
|
49
|
+
res.send = (payload) => {
|
|
50
|
+
if (typeof payload === 'string')
|
|
51
|
+
return originalSend(redactText(payload, store));
|
|
52
|
+
if (Buffer.isBuffer(payload)) {
|
|
53
|
+
const type = res.getHeader('content-type');
|
|
54
|
+
if (typeof type === 'string' && /json|text/i.test(type)) {
|
|
55
|
+
return originalSend(redactText(payload.toString('utf8'), store));
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return originalSend(payload);
|
|
59
|
+
};
|
|
60
|
+
next();
|
|
61
|
+
};
|
|
62
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import { userHome, ensureDir } from '../../shared/paths.js';
|
|
5
|
+
const KEYRING_SERVICE = 'clearhand';
|
|
6
|
+
class KeychainBackend {
|
|
7
|
+
name = 'keychain';
|
|
8
|
+
// Loaded lazily so environments without the native module can still run
|
|
9
|
+
// with other backends.
|
|
10
|
+
EntryCtor;
|
|
11
|
+
constructor(entryCtor) {
|
|
12
|
+
this.EntryCtor = entryCtor;
|
|
13
|
+
}
|
|
14
|
+
get(key) {
|
|
15
|
+
try {
|
|
16
|
+
return new this.EntryCtor(KEYRING_SERVICE, key).getPassword();
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
set(key, value) {
|
|
23
|
+
new this.EntryCtor(KEYRING_SERVICE, key).setPassword(value);
|
|
24
|
+
}
|
|
25
|
+
delete(key) {
|
|
26
|
+
try {
|
|
27
|
+
new this.EntryCtor(KEYRING_SERVICE, key).deletePassword();
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
// deleting a missing entry is fine
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
class EncryptedFileBackend {
|
|
35
|
+
name = 'encrypted-file';
|
|
36
|
+
file;
|
|
37
|
+
masterKey;
|
|
38
|
+
constructor(masterKey) {
|
|
39
|
+
this.file = path.join(ensureDir(userHome(), 0o700), 'credentials.enc');
|
|
40
|
+
this.masterKey = masterKey;
|
|
41
|
+
}
|
|
42
|
+
load() {
|
|
43
|
+
try {
|
|
44
|
+
return JSON.parse(fs.readFileSync(this.file, 'utf8'));
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
const salt = crypto.randomBytes(16).toString('base64');
|
|
48
|
+
return { version: 1, kdf: { algo: 'scrypt', salt, N: 16384, r: 8, p: 1 }, entries: {} };
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
save(shape) {
|
|
52
|
+
fs.writeFileSync(this.file, JSON.stringify(shape, null, 2), { mode: 0o600 });
|
|
53
|
+
}
|
|
54
|
+
deriveKey(shape) {
|
|
55
|
+
const { salt, N, r, p } = shape.kdf;
|
|
56
|
+
return crypto.scryptSync(this.masterKey, Buffer.from(salt, 'base64'), 32, { N, r, p });
|
|
57
|
+
}
|
|
58
|
+
get(key) {
|
|
59
|
+
const shape = this.load();
|
|
60
|
+
const entry = shape.entries[key];
|
|
61
|
+
if (!entry)
|
|
62
|
+
return null;
|
|
63
|
+
const k = this.deriveKey(shape);
|
|
64
|
+
const decipher = crypto.createDecipheriv('aes-256-gcm', k, Buffer.from(entry.iv, 'base64'));
|
|
65
|
+
decipher.setAuthTag(Buffer.from(entry.tag, 'base64'));
|
|
66
|
+
const out = Buffer.concat([decipher.update(Buffer.from(entry.data, 'base64')), decipher.final()]);
|
|
67
|
+
return out.toString('utf8');
|
|
68
|
+
}
|
|
69
|
+
set(key, value) {
|
|
70
|
+
const shape = this.load();
|
|
71
|
+
const k = this.deriveKey(shape);
|
|
72
|
+
const iv = crypto.randomBytes(12);
|
|
73
|
+
const cipher = crypto.createCipheriv('aes-256-gcm', k, iv);
|
|
74
|
+
const data = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()]);
|
|
75
|
+
shape.entries[key] = {
|
|
76
|
+
iv: iv.toString('base64'),
|
|
77
|
+
tag: cipher.getAuthTag().toString('base64'),
|
|
78
|
+
data: data.toString('base64'),
|
|
79
|
+
};
|
|
80
|
+
this.save(shape);
|
|
81
|
+
}
|
|
82
|
+
delete(key) {
|
|
83
|
+
const shape = this.load();
|
|
84
|
+
delete shape.entries[key];
|
|
85
|
+
this.save(shape);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
class PlainFileBackend {
|
|
89
|
+
name = 'plain';
|
|
90
|
+
file;
|
|
91
|
+
constructor() {
|
|
92
|
+
this.file = path.join(ensureDir(userHome(), 0o700), 'credentials.json');
|
|
93
|
+
}
|
|
94
|
+
load() {
|
|
95
|
+
try {
|
|
96
|
+
return JSON.parse(fs.readFileSync(this.file, 'utf8'));
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return {};
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
get(key) {
|
|
103
|
+
return this.load()[key] ?? null;
|
|
104
|
+
}
|
|
105
|
+
set(key, value) {
|
|
106
|
+
const all = this.load();
|
|
107
|
+
all[key] = value;
|
|
108
|
+
fs.writeFileSync(this.file, JSON.stringify(all, null, 2), { mode: 0o600 });
|
|
109
|
+
}
|
|
110
|
+
delete(key) {
|
|
111
|
+
const all = this.load();
|
|
112
|
+
delete all[key];
|
|
113
|
+
fs.writeFileSync(this.file, JSON.stringify(all, null, 2), { mode: 0o600 });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
async function probeKeychain() {
|
|
117
|
+
try {
|
|
118
|
+
const mod = await import('@napi-rs/keyring');
|
|
119
|
+
const backend = new KeychainBackend(mod.Entry);
|
|
120
|
+
const probeKey = `__probe_${process.pid}`;
|
|
121
|
+
backend.set(probeKey, 'ok');
|
|
122
|
+
const ok = backend.get(probeKey) === 'ok';
|
|
123
|
+
backend.delete(probeKey);
|
|
124
|
+
return ok ? backend : null;
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
export class SecretStore {
|
|
131
|
+
backend;
|
|
132
|
+
metaFile;
|
|
133
|
+
onAccess;
|
|
134
|
+
/**
|
|
135
|
+
* Values decrypted during this process lifetime. Used by the redaction
|
|
136
|
+
* middleware: anything that has actually been in memory gets scrubbed from
|
|
137
|
+
* every outgoing response as defense-in-depth.
|
|
138
|
+
*/
|
|
139
|
+
hotValues = new Map();
|
|
140
|
+
constructor(backend, onAccess) {
|
|
141
|
+
this.backend = backend;
|
|
142
|
+
this.metaFile = path.join(ensureDir(userHome(), 0o700), 'secrets-meta.json');
|
|
143
|
+
this.onAccess = onAccess;
|
|
144
|
+
}
|
|
145
|
+
static async create(opts) {
|
|
146
|
+
const masterKey = process.env.CLEARHAND_MASTER_KEY;
|
|
147
|
+
let backend = null;
|
|
148
|
+
if (opts.storage === 'keychain' || opts.storage === 'auto') {
|
|
149
|
+
backend = await probeKeychain();
|
|
150
|
+
if (!backend && opts.storage === 'keychain') {
|
|
151
|
+
throw new Error('Keychain storage requested but no OS keychain is available.');
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
if (!backend && (opts.storage === 'encrypted-file' || opts.storage === 'auto')) {
|
|
155
|
+
if (masterKey)
|
|
156
|
+
backend = new EncryptedFileBackend(masterKey);
|
|
157
|
+
else if (opts.storage === 'encrypted-file') {
|
|
158
|
+
throw new Error('encrypted-file storage requires CLEARHAND_MASTER_KEY to be set.');
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if (!backend && opts.storage === 'plain') {
|
|
162
|
+
backend = new PlainFileBackend();
|
|
163
|
+
}
|
|
164
|
+
if (!backend) {
|
|
165
|
+
throw new Error('No secure secret storage available. Options: run on a machine with an OS keychain, ' +
|
|
166
|
+
'set CLEARHAND_MASTER_KEY for encrypted-file storage, or explicitly opt in to plaintext ' +
|
|
167
|
+
'with `clearhand init --storage plain` (not recommended).');
|
|
168
|
+
}
|
|
169
|
+
return new SecretStore(backend, opts.onAccess);
|
|
170
|
+
}
|
|
171
|
+
get backendName() {
|
|
172
|
+
return this.backend.name;
|
|
173
|
+
}
|
|
174
|
+
loadMeta() {
|
|
175
|
+
try {
|
|
176
|
+
return JSON.parse(fs.readFileSync(this.metaFile, 'utf8'));
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
return {};
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
saveMeta(meta) {
|
|
183
|
+
fs.writeFileSync(this.metaFile, JSON.stringify(meta, null, 2), { mode: 0o600 });
|
|
184
|
+
}
|
|
185
|
+
set(key, value) {
|
|
186
|
+
this.backend.set(key, value);
|
|
187
|
+
const meta = this.loadMeta();
|
|
188
|
+
const now = new Date().toISOString();
|
|
189
|
+
const entry = {
|
|
190
|
+
key,
|
|
191
|
+
backend: this.backend.name,
|
|
192
|
+
hint: value.length > 4 ? `…${value.slice(-4)}` : '…',
|
|
193
|
+
createdAt: meta[key]?.createdAt ?? now,
|
|
194
|
+
updatedAt: now,
|
|
195
|
+
};
|
|
196
|
+
meta[key] = entry;
|
|
197
|
+
this.saveMeta(meta);
|
|
198
|
+
// A set implies the value transited memory; make it redactable immediately.
|
|
199
|
+
this.hotValues.set(key, value);
|
|
200
|
+
return entry;
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Just-in-time access. The ONLY way to read a secret value. Callers must
|
|
204
|
+
* state a purpose; every call is reported to the access log.
|
|
205
|
+
*/
|
|
206
|
+
use(key, purpose, ref) {
|
|
207
|
+
const value = this.backend.get(key);
|
|
208
|
+
if (value === null)
|
|
209
|
+
throw new Error(`Secret not configured: ${key}`);
|
|
210
|
+
this.onAccess?.({
|
|
211
|
+
key,
|
|
212
|
+
purpose,
|
|
213
|
+
ref,
|
|
214
|
+
backend: this.backend.name,
|
|
215
|
+
at: new Date().toISOString(),
|
|
216
|
+
});
|
|
217
|
+
this.hotValues.set(key, value);
|
|
218
|
+
return value;
|
|
219
|
+
}
|
|
220
|
+
has(key) {
|
|
221
|
+
return this.loadMeta()[key] !== undefined;
|
|
222
|
+
}
|
|
223
|
+
delete(key) {
|
|
224
|
+
this.backend.delete(key);
|
|
225
|
+
const meta = this.loadMeta();
|
|
226
|
+
delete meta[key];
|
|
227
|
+
this.saveMeta(meta);
|
|
228
|
+
this.hotValues.delete(key);
|
|
229
|
+
}
|
|
230
|
+
/** Metadata only — safe for status endpoints. Never returns values. */
|
|
231
|
+
list() {
|
|
232
|
+
return Object.values(this.loadMeta());
|
|
233
|
+
}
|
|
234
|
+
/** Secret values currently in process memory, for the redaction middleware. */
|
|
235
|
+
redactableValues() {
|
|
236
|
+
return this.hotValues;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { audit, countPendingFinancialActions, now } from '../db/index.js';
|
|
2
|
+
import { effectiveProposal, FINANCIAL_ACTION_TYPES, } from '../../shared/actions.js';
|
|
3
|
+
import { lintAndNormalizeAction } from './lint.js';
|
|
4
|
+
import { executeAction } from './executor.js';
|
|
5
|
+
// One in-flight enrichment per case: concurrent approvals of the same case
|
|
6
|
+
// share one run (a duplicate-run race saturated the single-threaded server
|
|
7
|
+
// in the ancestor system).
|
|
8
|
+
const inflightEnrich = new Map();
|
|
9
|
+
/**
|
|
10
|
+
* Enrichment freshness — NOT time-based. Staleness = thinness: re-enrich when
|
|
11
|
+
* the prepared context has no Stripe customer data. Fails OPEN on hard
|
|
12
|
+
* failure (better an approval on slightly-stale data than a wedged queue),
|
|
13
|
+
* logged loudly.
|
|
14
|
+
*/
|
|
15
|
+
export async function ensureFreshEnrichmentForCase(ctx, caseId) {
|
|
16
|
+
if (!ctx.enrichCase)
|
|
17
|
+
return;
|
|
18
|
+
const row = ctx.db
|
|
19
|
+
.prepare(`SELECT prepared_context_json FROM cases WHERE id = ?`)
|
|
20
|
+
.get(caseId);
|
|
21
|
+
let thin = true;
|
|
22
|
+
if (row?.prepared_context_json) {
|
|
23
|
+
try {
|
|
24
|
+
const c = JSON.parse(row.prepared_context_json);
|
|
25
|
+
thin = !Array.isArray(c.stripe?.customers) || c.stripe.customers.length === 0;
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
thin = true;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
if (!thin)
|
|
32
|
+
return;
|
|
33
|
+
let p = inflightEnrich.get(caseId);
|
|
34
|
+
if (!p) {
|
|
35
|
+
p = ctx.enrichCase(caseId).finally(() => inflightEnrich.delete(caseId));
|
|
36
|
+
inflightEnrich.set(caseId, p);
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
await p;
|
|
40
|
+
}
|
|
41
|
+
catch (err) {
|
|
42
|
+
console.error(`[clearhand] freshness re-enrich FAILED for case ${caseId} — proceeding on possibly-stale data:`, err);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Lint-on-approve. Re-linting here (creation already linted) catches legacy
|
|
47
|
+
* proposals created before a rule existed — the last gate before money.
|
|
48
|
+
* Lint ERRORS fail closed; a lint EXCEPTION fails open (deliberate
|
|
49
|
+
* availability choice, logged loudly).
|
|
50
|
+
*/
|
|
51
|
+
export function lintAndApproveAction(db, actionId, modifiedParams, approvedBy = 'human') {
|
|
52
|
+
const action = db.prepare(`SELECT * FROM actions WHERE id = ?`).get(actionId);
|
|
53
|
+
if (!action)
|
|
54
|
+
return { ok: false, reason: 'not_found', errors: [`action ${actionId} not found`] };
|
|
55
|
+
if (!['proposed', 'approved', 'failed'].includes(action.status)) {
|
|
56
|
+
return {
|
|
57
|
+
ok: false,
|
|
58
|
+
reason: 'not_approvable',
|
|
59
|
+
errors: [`action ${actionId} is '${action.status}' — not approvable`],
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
const original = effectiveProposal(action);
|
|
63
|
+
const candidate = modifiedParams ?? original;
|
|
64
|
+
let normalized = candidate;
|
|
65
|
+
let warnings = [];
|
|
66
|
+
try {
|
|
67
|
+
const res = lintAndNormalizeAction(action.action_type, candidate);
|
|
68
|
+
if (res.errors.length > 0)
|
|
69
|
+
return { ok: false, reason: 'lint', errors: res.errors };
|
|
70
|
+
normalized = res.proposal;
|
|
71
|
+
warnings = res.warnings;
|
|
72
|
+
}
|
|
73
|
+
catch (err) {
|
|
74
|
+
console.error(`[clearhand] lint EXCEPTION on action ${actionId} — failing open:`, err);
|
|
75
|
+
}
|
|
76
|
+
const modifiedJson = JSON.stringify(normalized) !== action.proposal_json ? JSON.stringify(normalized) : null;
|
|
77
|
+
db.prepare(`UPDATE actions SET status = 'approved', approved_by = ?, approved_at = ?, modified_json = ?
|
|
78
|
+
WHERE id = ?`).run(approvedBy, now(), modifiedJson ?? action.modified_json, actionId);
|
|
79
|
+
if (modifiedParams) {
|
|
80
|
+
const diff = Object.keys({ ...original, ...modifiedParams })
|
|
81
|
+
.filter((k) => JSON.stringify(original[k]) !== JSON.stringify(modifiedParams[k]))
|
|
82
|
+
.map((k) => `${k}: ${JSON.stringify(original[k])} → ${JSON.stringify(modifiedParams[k])}`)
|
|
83
|
+
.join('; ');
|
|
84
|
+
db.prepare(`INSERT INTO feedback_events (case_id, action_id, event_type, original_json, modified_json, diff_summary, created_at)
|
|
85
|
+
VALUES (?, ?, 'action_modify', ?, ?, ?, ?)`).run(action.case_id, actionId, JSON.stringify(original), JSON.stringify(modifiedParams), diff, now());
|
|
86
|
+
}
|
|
87
|
+
audit(db, 'human', 'action_approved', { caseId: action.case_id, actionId, detail: { warnings } });
|
|
88
|
+
const updated = db.prepare(`SELECT * FROM actions WHERE id = ?`).get(actionId);
|
|
89
|
+
return { ok: true, action: updated, warnings };
|
|
90
|
+
}
|
|
91
|
+
export function rejectAction(db, actionId, reason) {
|
|
92
|
+
const action = db.prepare(`SELECT * FROM actions WHERE id = ?`).get(actionId);
|
|
93
|
+
if (!action)
|
|
94
|
+
return false;
|
|
95
|
+
db.prepare(`UPDATE actions SET status = 'rejected', error_message = ? WHERE id = ?`).run(reason ?? 'rejected by operator', actionId);
|
|
96
|
+
db.prepare(`INSERT INTO feedback_events (case_id, action_id, event_type, original_json, diff_summary, created_at)
|
|
97
|
+
VALUES (?, ?, 'action_reject', ?, ?, ?)`).run(action.case_id, actionId, action.proposal_json, reason ?? null, now());
|
|
98
|
+
audit(db, 'human', 'action_rejected', { caseId: action.case_id, actionId, detail: { reason } });
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
export function setCaseStatus(db, caseId, status) {
|
|
102
|
+
db.prepare(`UPDATE cases SET status = ?, updated_at = ? WHERE id = ?`).run(status, now(), caseId);
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Batch approve + ordered execute.
|
|
106
|
+
*
|
|
107
|
+
* Phase 0: freshness (financial actions only).
|
|
108
|
+
* Phase 1: lint+approve every id; failures don't strand the rest.
|
|
109
|
+
* Phase 2: sort by execution_order ASC and execute STRICTLY SEQUENTIALLY so
|
|
110
|
+
* refund/cancel run before send_reply/set_case_status on the same case.
|
|
111
|
+
* `Promise.all` here would be a money bug, not an optimization: status
|
|
112
|
+
* actions participate in the same sorted pass so the resolve-guard sees
|
|
113
|
+
* the true post-execution state.
|
|
114
|
+
*/
|
|
115
|
+
export async function approveAndExecuteActions(ctx, caseId, actionIds, opts = {}) {
|
|
116
|
+
const { db } = ctx;
|
|
117
|
+
const rows = actionIds
|
|
118
|
+
.map((id) => db.prepare(`SELECT * FROM actions WHERE id = ?`).get(id))
|
|
119
|
+
.filter((a) => a !== undefined);
|
|
120
|
+
// Defense in depth: every id must belong to this case.
|
|
121
|
+
const foreign = rows.filter((a) => a.case_id !== caseId);
|
|
122
|
+
if (foreign.length > 0) {
|
|
123
|
+
return {
|
|
124
|
+
approvedIds: [],
|
|
125
|
+
lintFailures: foreign.map((a) => ({
|
|
126
|
+
actionId: a.id,
|
|
127
|
+
errors: [`action ${a.id} belongs to case ${a.case_id}, not ${caseId}`],
|
|
128
|
+
})),
|
|
129
|
+
results: [],
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
// Phase 0 — freshness, once per batch, only when money is involved.
|
|
133
|
+
if (rows.some((a) => FINANCIAL_ACTION_TYPES.has(a.action_type))) {
|
|
134
|
+
await ensureFreshEnrichmentForCase(ctx, caseId);
|
|
135
|
+
}
|
|
136
|
+
// Phase 1 — lint + approve.
|
|
137
|
+
const lintFailures = [];
|
|
138
|
+
const approved = [];
|
|
139
|
+
for (const row of rows) {
|
|
140
|
+
const outcome = lintAndApproveAction(db, row.id, opts.perActionParams?.[row.id], opts.approvedBy ?? 'human');
|
|
141
|
+
if (outcome.ok)
|
|
142
|
+
approved.push(outcome.action);
|
|
143
|
+
else
|
|
144
|
+
lintFailures.push({ actionId: row.id, errors: outcome.errors });
|
|
145
|
+
}
|
|
146
|
+
// Phase 2 — ordered, strictly sequential execution.
|
|
147
|
+
approved.sort((a, b) => a.execution_order - b.execution_order || a.id - b.id);
|
|
148
|
+
const results = [];
|
|
149
|
+
for (const action of approved) {
|
|
150
|
+
try {
|
|
151
|
+
if (action.action_type === 'escalate' || action.action_type === 'set_case_status') {
|
|
152
|
+
results.push({ actionId: action.id, ...runInlineStatusAction(db, action, opts.allowPendingFinancial) });
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
const res = await executeAction(ctx.executorDeps, action.id, { sendMode: opts.sendMode });
|
|
156
|
+
results.push({ actionId: action.id, ...res });
|
|
157
|
+
}
|
|
158
|
+
catch (err) {
|
|
159
|
+
// One failure must not strand the rest of the batch.
|
|
160
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
161
|
+
console.error(`[clearhand] execution exception for action ${action.id}:`, err);
|
|
162
|
+
results.push({ actionId: action.id, success: false, message });
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return { approvedIds: approved.map((a) => a.id), lintFailures, results };
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* escalate / set_case_status execute inline (never via the external executor)
|
|
169
|
+
* so they honor execution_order within the same sequential pass — and so the
|
|
170
|
+
* RESOLVE-GUARD evaluates after the money actions in the batch have run.
|
|
171
|
+
*
|
|
172
|
+
* The guard: a case must never be marked resolved while a refund/cancel is
|
|
173
|
+
* still owed (proposed, approved, executing, or FAILED — an approved-then-
|
|
174
|
+
* failed refund is still owed; see countPendingFinancialActions).
|
|
175
|
+
*/
|
|
176
|
+
function runInlineStatusAction(db, action, allowPendingFinancial) {
|
|
177
|
+
const proposal = effectiveProposal(action);
|
|
178
|
+
const targetStatus = action.action_type === 'escalate' ? 'escalated' : String(proposal.target_status ?? '');
|
|
179
|
+
if (!['resolved', 'pending', 'ignored', 'escalated'].includes(targetStatus)) {
|
|
180
|
+
const message = `invalid target_status "${targetStatus}"`;
|
|
181
|
+
db.prepare(`UPDATE actions SET status = 'failed', error_message = ? WHERE id = ?`).run(message, action.id);
|
|
182
|
+
return { success: false, message };
|
|
183
|
+
}
|
|
184
|
+
if (targetStatus === 'resolved' && !allowPendingFinancial) {
|
|
185
|
+
const pending = countPendingFinancialActions(db, action.case_id);
|
|
186
|
+
if (pending > 0) {
|
|
187
|
+
const message = `refusing to resolve case ${action.case_id}: ${pending} financial action(s) not yet executed. ` +
|
|
188
|
+
`Execute or reject them first (this guard exists because a case was once closed while its refund silently never ran).`;
|
|
189
|
+
db.prepare(`UPDATE actions SET status = 'failed', error_message = ? WHERE id = ?`).run(message, action.id);
|
|
190
|
+
audit(db, 'system', 'resolve_guard_blocked', {
|
|
191
|
+
caseId: action.case_id,
|
|
192
|
+
actionId: action.id,
|
|
193
|
+
detail: { pending_financial: pending },
|
|
194
|
+
});
|
|
195
|
+
return { success: false, message, details: { pending_financial: pending } };
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
db.prepare(`UPDATE actions SET status = 'executed', executed_at = ? WHERE id = ?`).run(now(), action.id);
|
|
199
|
+
setCaseStatus(db, action.case_id, targetStatus);
|
|
200
|
+
audit(db, 'system', 'case_status_changed', {
|
|
201
|
+
caseId: action.case_id,
|
|
202
|
+
actionId: action.id,
|
|
203
|
+
detail: { status: targetStatus, reason: proposal.reason },
|
|
204
|
+
});
|
|
205
|
+
return { success: true, message: `case status → ${targetStatus}`, details: { status: targetStatus } };
|
|
206
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { now } from '../db/index.js';
|
|
4
|
+
import { projectDir } from '../../shared/paths.js';
|
|
5
|
+
export function getConnection(db, id) {
|
|
6
|
+
return db.prepare(`SELECT * FROM connections WHERE id = ?`).get(id);
|
|
7
|
+
}
|
|
8
|
+
export function setConnection(db, id, status, config, error) {
|
|
9
|
+
db.prepare(`INSERT INTO connections (id, status, config, last_verified, last_error, updated_at)
|
|
10
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
11
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
12
|
+
status = excluded.status,
|
|
13
|
+
config = COALESCE(excluded.config, connections.config),
|
|
14
|
+
last_verified = excluded.last_verified,
|
|
15
|
+
last_error = excluded.last_error,
|
|
16
|
+
updated_at = excluded.updated_at`).run(id, status, config ? JSON.stringify(config) : null, status === 'connected' ? now() : null, error ?? null, now());
|
|
17
|
+
}
|
|
18
|
+
export function mailboxConfig(db) {
|
|
19
|
+
const row = getConnection(db, 'mailbox');
|
|
20
|
+
if (!row?.config || row.status !== 'connected')
|
|
21
|
+
return null;
|
|
22
|
+
try {
|
|
23
|
+
return JSON.parse(row.config);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export function githubConfig(db) {
|
|
30
|
+
const row = getConnection(db, 'github');
|
|
31
|
+
if (!row?.config || row.status !== 'connected')
|
|
32
|
+
return null;
|
|
33
|
+
try {
|
|
34
|
+
return JSON.parse(row.config);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* The agent-facing capability map: metadata only, NEVER values. Also written
|
|
42
|
+
* to .clearhand/connections.json (server-owned manifest) so file-greppable
|
|
43
|
+
* agent docs stay truthful.
|
|
44
|
+
*/
|
|
45
|
+
export function connectionsStatus(db, secrets) {
|
|
46
|
+
const dashboard = 'http://localhost:' + (process.env.CLEARHAND_PORT ?? '3117');
|
|
47
|
+
const rows = Object.fromEntries(db.prepare(`SELECT * FROM connections`).all().map((r) => [r.id, r]));
|
|
48
|
+
const meta = Object.fromEntries(secrets.list().map((m) => [m.key, m]));
|
|
49
|
+
const shape = (id, secretKey, extra) => {
|
|
50
|
+
const row = rows[id];
|
|
51
|
+
const connected = row?.status === 'connected' && meta[secretKey] !== undefined;
|
|
52
|
+
let cfg = {};
|
|
53
|
+
try {
|
|
54
|
+
cfg = row?.config ? JSON.parse(row.config) : {};
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
// ignore malformed config
|
|
58
|
+
}
|
|
59
|
+
return connected
|
|
60
|
+
? {
|
|
61
|
+
connected: true,
|
|
62
|
+
storage: meta[secretKey]?.backend,
|
|
63
|
+
secret_hint: meta[secretKey]?.hint,
|
|
64
|
+
last_verified: row?.last_verified,
|
|
65
|
+
last_used: row?.last_used,
|
|
66
|
+
health: row?.status === 'connected' ? 'ok' : row?.status,
|
|
67
|
+
...extra(cfg),
|
|
68
|
+
}
|
|
69
|
+
: {
|
|
70
|
+
connected: false,
|
|
71
|
+
connect_url: `${dashboard}/connections#${id}`,
|
|
72
|
+
...(row?.last_error ? { last_error: row.last_error } : {}),
|
|
73
|
+
};
|
|
74
|
+
};
|
|
75
|
+
return {
|
|
76
|
+
stripe: shape('stripe', 'stripe_api_key', (cfg) => ({
|
|
77
|
+
key_type: cfg.keyType ?? 'unknown',
|
|
78
|
+
account_hint: cfg.accountHint,
|
|
79
|
+
})),
|
|
80
|
+
mailbox: shape('mailbox', 'mailbox_password', (cfg) => ({
|
|
81
|
+
protocol: 'imap',
|
|
82
|
+
address: cfg.address,
|
|
83
|
+
})),
|
|
84
|
+
github: shape('github', 'github_token', (cfg) => ({ repo: cfg.repo })),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
export function writeConnectionsManifest(db, secrets) {
|
|
88
|
+
const manifest = {
|
|
89
|
+
_note: 'Server-written metadata manifest. Contains NO secret values, ever.',
|
|
90
|
+
updated_at: now(),
|
|
91
|
+
connections: connectionsStatus(db, secrets),
|
|
92
|
+
};
|
|
93
|
+
try {
|
|
94
|
+
fs.writeFileSync(path.join(projectDir(), 'connections.json'), JSON.stringify(manifest, null, 2) + '\n');
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
// manifest is best-effort
|
|
98
|
+
}
|
|
99
|
+
}
|