@treenity/mods 3.0.2 → 3.0.3
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/agent/client.ts +2 -0
- package/agent/guardian.ts +492 -0
- package/agent/seed.ts +74 -0
- package/agent/server.ts +4 -0
- package/agent/service.ts +644 -0
- package/agent/types.ts +184 -0
- package/agent/view.tsx +431 -0
- package/mcp/mcp-server.ts +393 -0
- package/mcp/server.ts +2 -0
- package/mcp/service.ts +18 -0
- package/mcp/types.ts +6 -0
- package/package.json +5 -2
- package/dist/mindmap/radial-tree.d.ts +0 -14
- package/dist/mindmap/radial-tree.d.ts.map +0 -1
- package/dist/mindmap/radial-tree.js +0 -184
- package/dist/mindmap/radial-tree.js.map +0 -1
- package/dist/mindmap/use-tree-data.d.ts +0 -14
- package/dist/mindmap/use-tree-data.d.ts.map +0 -1
- package/dist/mindmap/use-tree-data.js +0 -95
- package/dist/mindmap/use-tree-data.js.map +0 -1
package/agent/client.ts
ADDED
|
@@ -0,0 +1,492 @@
|
|
|
1
|
+
// Guardian — extensible tool policy for AI agents.
|
|
2
|
+
// Cascade: agent ai.policy → global ai.policy (/agents/guardian) → hardcoded fallback.
|
|
3
|
+
// Escalation: creates ai.approval node → Promise resolution via pendingPermissions.
|
|
4
|
+
|
|
5
|
+
import { pendingPermissions, type PermissionMeta, type PermissionRule, resolvePermission } from '#metatron/permissions';
|
|
6
|
+
import { MetatronConfig } from '#metatron/types';
|
|
7
|
+
import type { PermissionResult } from '@anthropic-ai/claude-agent-sdk';
|
|
8
|
+
import { createNode, getComponent } from '@treenity/core';
|
|
9
|
+
import { registerType, setComponent } from '@treenity/core/comp';
|
|
10
|
+
import { matchesAny } from '@treenity/core/glob';
|
|
11
|
+
import type { Tree } from '@treenity/core/tree';
|
|
12
|
+
import { AiPolicy } from './types';
|
|
13
|
+
|
|
14
|
+
// ── Approval type — lives in /agents/approvals/{id} ──
|
|
15
|
+
|
|
16
|
+
export class AiApproval {
|
|
17
|
+
agentPath = '';
|
|
18
|
+
agentRole = '';
|
|
19
|
+
tool = '';
|
|
20
|
+
/** @format textarea */
|
|
21
|
+
input = '';
|
|
22
|
+
status: 'pending' | 'approved' | 'denied' = 'pending';
|
|
23
|
+
reason = '';
|
|
24
|
+
createdAt = 0;
|
|
25
|
+
resolvedAt = 0;
|
|
26
|
+
|
|
27
|
+
/** @description Approve this tool usage */
|
|
28
|
+
approve(data?: {
|
|
29
|
+
/** Remember this decision for future calls */
|
|
30
|
+
remember?: 'agent' | 'global'
|
|
31
|
+
}) {
|
|
32
|
+
if (this.status !== 'pending') throw new Error('already resolved');
|
|
33
|
+
this.status = 'approved';
|
|
34
|
+
this.resolvedAt = Date.now();
|
|
35
|
+
const id = (this as any).$path?.split('/').pop();
|
|
36
|
+
if (id) resolvePermission(id, true, {
|
|
37
|
+
tool: this.tool,
|
|
38
|
+
input: this.input,
|
|
39
|
+
agentPath: this.agentPath,
|
|
40
|
+
scope: data?.remember,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** @description Deny this tool usage */
|
|
45
|
+
deny(data?: {
|
|
46
|
+
/** Remember this decision for future calls */
|
|
47
|
+
remember?: 'agent' | 'global'
|
|
48
|
+
}) {
|
|
49
|
+
if (this.status !== 'pending') throw new Error('already resolved');
|
|
50
|
+
this.status = 'denied';
|
|
51
|
+
this.resolvedAt = Date.now();
|
|
52
|
+
const id = (this as any).$path?.split('/').pop();
|
|
53
|
+
if (id) resolvePermission(id, false, {
|
|
54
|
+
tool: this.tool,
|
|
55
|
+
input: this.input,
|
|
56
|
+
agentPath: this.agentPath,
|
|
57
|
+
scope: data?.remember,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
registerType('ai.approval', AiApproval);
|
|
63
|
+
|
|
64
|
+
// ── ToolPolicy shape (runtime, with RegExp) ──
|
|
65
|
+
|
|
66
|
+
export type ToolPolicy = {
|
|
67
|
+
allow: string[];
|
|
68
|
+
deny: string[];
|
|
69
|
+
escalate: string[];
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// ── Minimal fallback — read-only, everything else escalated ──
|
|
73
|
+
|
|
74
|
+
const FALLBACK_POLICY: ToolPolicy = {
|
|
75
|
+
allow: [
|
|
76
|
+
'mcp__treenity__get_node', 'mcp__treenity__list_children',
|
|
77
|
+
'mcp__treenity__catalog', 'mcp__treenity__describe_type',
|
|
78
|
+
'mcp__treenity__search_types',
|
|
79
|
+
],
|
|
80
|
+
deny: [],
|
|
81
|
+
escalate: ['mcp__treenity__set_node', 'mcp__treenity__execute', 'mcp__treenity__remove_node'],
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
// ── Convert ai.policy node data → runtime ToolPolicy ──
|
|
85
|
+
|
|
86
|
+
function policyFromNode(p: AiPolicy): ToolPolicy {
|
|
87
|
+
return {
|
|
88
|
+
allow: [...p.allow],
|
|
89
|
+
deny: [...p.deny],
|
|
90
|
+
escalate: [...p.escalate],
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Merge two policies: b overrides a (more specific wins) */
|
|
95
|
+
function mergePolicies(base: ToolPolicy, override: ToolPolicy): ToolPolicy {
|
|
96
|
+
const allow = [...new Set([...base.allow, ...override.allow])];
|
|
97
|
+
const deny = [...new Set([...base.deny, ...override.deny])];
|
|
98
|
+
const escalate = [...new Set([...base.escalate, ...override.escalate])];
|
|
99
|
+
// Remove from escalate/deny if explicitly allowed in override
|
|
100
|
+
const cleanEscalate = escalate.filter(t => !override.allow.includes(t));
|
|
101
|
+
const cleanDeny = deny.filter(t => !override.allow.includes(t));
|
|
102
|
+
return {
|
|
103
|
+
allow,
|
|
104
|
+
deny: cleanDeny,
|
|
105
|
+
escalate: cleanEscalate,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const GUARDIAN_PATH = '/agents/guardian';
|
|
110
|
+
|
|
111
|
+
/** Resolve policy cascade: agent → global → fallback */
|
|
112
|
+
async function resolvePolicy(store: Tree, agentPath: string): Promise<ToolPolicy> {
|
|
113
|
+
const hardcoded = FALLBACK_POLICY;
|
|
114
|
+
|
|
115
|
+
// Global policy from /agents/guardian
|
|
116
|
+
let base = hardcoded;
|
|
117
|
+
try {
|
|
118
|
+
const guardianNode = await store.get(GUARDIAN_PATH);
|
|
119
|
+
if (guardianNode) {
|
|
120
|
+
const globalPolicy = getComponent(guardianNode, AiPolicy);
|
|
121
|
+
if (globalPolicy && (globalPolicy.allow.length || globalPolicy.deny.length || globalPolicy.escalate.length)) {
|
|
122
|
+
base = mergePolicies(hardcoded, policyFromNode(globalPolicy));
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
} catch { /* no guardian node yet */ }
|
|
126
|
+
|
|
127
|
+
// Agent-level policy
|
|
128
|
+
try {
|
|
129
|
+
const agentNode = await store.get(agentPath);
|
|
130
|
+
if (agentNode) {
|
|
131
|
+
const agentPolicy = getComponent(agentNode, AiPolicy);
|
|
132
|
+
if (agentPolicy && (agentPolicy.allow.length || agentPolicy.deny.length || agentPolicy.escalate.length)) {
|
|
133
|
+
return mergePolicies(base, policyFromNode(agentPolicy));
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
} catch { /* agent has no policy component */ }
|
|
137
|
+
|
|
138
|
+
return base;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ── Build metatron-compatible PermissionRule[] ──
|
|
142
|
+
|
|
143
|
+
/** Static fallback rules for SDK upfront hints. Real enforcement in canUseTool. */
|
|
144
|
+
export function buildPermissionRules(_role: string): PermissionRule[] {
|
|
145
|
+
const policy = FALLBACK_POLICY;
|
|
146
|
+
const rules: PermissionRule[] = [];
|
|
147
|
+
|
|
148
|
+
for (const tool of policy.deny) rules.push({ tool, pathPattern: '', policy: 'deny' });
|
|
149
|
+
for (const tool of policy.escalate) rules.push({ tool, pathPattern: '', policy: 'ask-once' });
|
|
150
|
+
for (const tool of policy.allow) rules.push({ tool, pathPattern: '', policy: 'allow' });
|
|
151
|
+
|
|
152
|
+
return rules;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
// ── Always-deny bash patterns ──
|
|
157
|
+
|
|
158
|
+
const DANGEROUS_BASH = [
|
|
159
|
+
/rm\s+-rf/, /push\s+--force/, /reset\s+--hard/, /--no-verify/,
|
|
160
|
+
/curl\b.*\|\s*(?:ba)?sh/, /wget\b.*\|\s*(?:ba)?sh/, // pipe-to-shell
|
|
161
|
+
/\beval\s+/, // arbitrary eval
|
|
162
|
+
/chmod\s+777/, /chmod\s+\+s/, // permission escalation
|
|
163
|
+
/\bdd\s+.*of=\/dev\//, /\bmkfs\b/, // disk destruction
|
|
164
|
+
];
|
|
165
|
+
|
|
166
|
+
// ── Split bash command by operators, respecting quotes ──
|
|
167
|
+
|
|
168
|
+
export function splitBashParts(cmd: string): string[] {
|
|
169
|
+
const parts: string[] = [];
|
|
170
|
+
let cur = '';
|
|
171
|
+
let inSingle = false;
|
|
172
|
+
let inDouble = false;
|
|
173
|
+
let escaped = false;
|
|
174
|
+
|
|
175
|
+
for (let i = 0; i < cmd.length; i++) {
|
|
176
|
+
const ch = cmd[i];
|
|
177
|
+
|
|
178
|
+
if (escaped) { cur += ch; escaped = false; continue; }
|
|
179
|
+
if (ch === '\\' && !inSingle) { cur += ch; escaped = true; continue; }
|
|
180
|
+
if (ch === "'" && !inDouble) { cur += ch; inSingle = !inSingle; continue; }
|
|
181
|
+
if (ch === '"' && !inSingle) { cur += ch; inDouble = !inDouble; continue; }
|
|
182
|
+
|
|
183
|
+
if (!inSingle && !inDouble) {
|
|
184
|
+
if (ch === '|' && cmd[i + 1] === '|') { parts.push(cur); cur = ''; i++; continue; }
|
|
185
|
+
if (ch === '|') { parts.push(cur); cur = ''; continue; }
|
|
186
|
+
if (ch === '&' && cmd[i + 1] === '&') { parts.push(cur); cur = ''; i++; continue; }
|
|
187
|
+
if (ch === ';') { parts.push(cur); cur = ''; continue; }
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
cur += ch;
|
|
191
|
+
}
|
|
192
|
+
if (cur) parts.push(cur);
|
|
193
|
+
return parts.map(p => p.trim()).filter(Boolean);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ── Escalation via tree nodes + Promise resolution ──
|
|
197
|
+
|
|
198
|
+
const APPROVAL_TIMEOUT = 60 * 60 * 1000; // 1 hour
|
|
199
|
+
|
|
200
|
+
export async function requestApproval(
|
|
201
|
+
store: Tree,
|
|
202
|
+
opts: { agentPath: string; role: string; tool: string; input: string; reason: string },
|
|
203
|
+
): Promise<boolean> {
|
|
204
|
+
const id = `a-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
|
205
|
+
const path = `/agents/approvals/${id}`;
|
|
206
|
+
|
|
207
|
+
await store.set(createNode(path, 'ai.approval', {
|
|
208
|
+
agentPath: opts.agentPath,
|
|
209
|
+
agentRole: opts.role,
|
|
210
|
+
tool: opts.tool,
|
|
211
|
+
input: opts.input.slice(0, 1000),
|
|
212
|
+
status: 'pending',
|
|
213
|
+
reason: opts.reason,
|
|
214
|
+
createdAt: Date.now(),
|
|
215
|
+
}));
|
|
216
|
+
|
|
217
|
+
console.log(`[guardian] escalation: ${opts.role} wants ${opts.tool} → ${path}`);
|
|
218
|
+
|
|
219
|
+
return new Promise<boolean>((resolve) => {
|
|
220
|
+
const timer = setTimeout(() => {
|
|
221
|
+
pendingPermissions.delete(id);
|
|
222
|
+
console.log(`[guardian] escalation timed out: ${path}`);
|
|
223
|
+
resolve(false);
|
|
224
|
+
}, APPROVAL_TIMEOUT);
|
|
225
|
+
|
|
226
|
+
pendingPermissions.set(id, async (allow: boolean, meta?: PermissionMeta) => {
|
|
227
|
+
clearTimeout(timer);
|
|
228
|
+
|
|
229
|
+
// "Remember" — persist rule to tree policy
|
|
230
|
+
if (meta?.scope && meta.tool) {
|
|
231
|
+
try {
|
|
232
|
+
await rememberRule(store, meta.tool, meta.input ?? '', allow, meta.agentPath ?? '', meta.scope);
|
|
233
|
+
} catch (err) {
|
|
234
|
+
console.error(`[guardian] failed to persist rule: ${err}`);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
resolve(allow);
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Write a persistent rule to agent or global policy (with OCC retry) */
|
|
244
|
+
async function rememberRule(store: Tree, tool: string, _input: string, allow: boolean, agentPath: string, scope: string) {
|
|
245
|
+
const targetPath = scope === 'agent' ? agentPath : GUARDIAN_PATH;
|
|
246
|
+
const MAX_RETRIES = 3;
|
|
247
|
+
|
|
248
|
+
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
|
249
|
+
const node = await store.get(targetPath);
|
|
250
|
+
if (!node) return;
|
|
251
|
+
|
|
252
|
+
const policy = getComponent(node, AiPolicy) ?? Object.assign(new AiPolicy(), { $type: 'ai.policy' });
|
|
253
|
+
|
|
254
|
+
if (allow) {
|
|
255
|
+
if (!policy.allow.includes(tool)) policy.allow.push(tool);
|
|
256
|
+
policy.deny = policy.deny.filter(d => d !== tool);
|
|
257
|
+
policy.escalate = policy.escalate.filter(e => e !== tool);
|
|
258
|
+
} else {
|
|
259
|
+
if (!policy.deny.includes(tool)) policy.deny.push(tool);
|
|
260
|
+
policy.allow = policy.allow.filter(a => a !== tool);
|
|
261
|
+
policy.escalate = policy.escalate.filter(e => e !== tool);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
setComponent(node, AiPolicy, policy);
|
|
265
|
+
try {
|
|
266
|
+
await store.set(node);
|
|
267
|
+
console.log(`[guardian] remembered: ${allow ? 'allow' : 'deny'} ${tool} → ${targetPath}`);
|
|
268
|
+
return;
|
|
269
|
+
} catch (err) {
|
|
270
|
+
if (err instanceof Error && err.message.startsWith('OptimisticConcurrencyError') && attempt < MAX_RETRIES - 1) {
|
|
271
|
+
console.warn(`[guardian] OCC conflict on ${targetPath}, retry ${attempt + 1}`);
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
throw err;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ── Startup reconciliation ──
|
|
280
|
+
|
|
281
|
+
/** Result of reconciliation — agents that should be resumed */
|
|
282
|
+
export type ResumableAgent = {
|
|
283
|
+
agentPath: string;
|
|
284
|
+
taskPath: string;
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
export async function reconcileOnStartup(store: Tree): Promise<ResumableAgent[]> {
|
|
288
|
+
const resumable: ResumableAgent[] = [];
|
|
289
|
+
|
|
290
|
+
// Expire orphaned approvals
|
|
291
|
+
try {
|
|
292
|
+
const { items } = await store.getChildren('/agents/approvals');
|
|
293
|
+
for (const approval of items) {
|
|
294
|
+
if (approval.$type !== 'ai.approval' || approval.status !== 'pending') continue;
|
|
295
|
+
await store.set({ ...approval, status: 'denied' as const, reason: 'expired: server restart', resolvedAt: Date.now() });
|
|
296
|
+
console.log(`[guardian] expired orphaned approval: ${approval.$path}`);
|
|
297
|
+
}
|
|
298
|
+
} catch { /* no approvals dir */ }
|
|
299
|
+
|
|
300
|
+
// Reconcile agents — resume those with sessionId, reset the rest
|
|
301
|
+
try {
|
|
302
|
+
const { items } = await store.getChildren('/agents');
|
|
303
|
+
const resumablePaths: string[] = [];
|
|
304
|
+
|
|
305
|
+
for (const node of items) {
|
|
306
|
+
if (node.$type !== 'ai.agent') continue;
|
|
307
|
+
if (node.status !== 'working' && node.status !== 'blocked') continue;
|
|
308
|
+
|
|
309
|
+
// Check if agent has a session to resume
|
|
310
|
+
const config = getComponent(node, MetatronConfig);
|
|
311
|
+
const hasSession = config && typeof config.sessionId === 'string' && config.sessionId.length > 0;
|
|
312
|
+
const taskPath = typeof node.currentTask === 'string' ? node.currentTask : '';
|
|
313
|
+
|
|
314
|
+
if (hasSession && taskPath) {
|
|
315
|
+
// Agent can resume — keep working status, collect for re-launch
|
|
316
|
+
resumable.push({ agentPath: node.$path, taskPath });
|
|
317
|
+
resumablePaths.push(node.$path);
|
|
318
|
+
console.log(`[guardian] resumable agent: ${node.$path} → ${taskPath} (session ${config.sessionId.slice(0, 8)}...)`);
|
|
319
|
+
} else {
|
|
320
|
+
// No session — reset to idle
|
|
321
|
+
await store.set({ ...node, status: 'idle', currentTask: '', taskRef: '' });
|
|
322
|
+
console.log(`[guardian] reset stuck agent: ${node.$path}`);
|
|
323
|
+
|
|
324
|
+
// Reset stuck metatron.tasks under this agent
|
|
325
|
+
try {
|
|
326
|
+
const { items: tasks } = await store.getChildren(`${node.$path}/tasks`);
|
|
327
|
+
for (const task of tasks) {
|
|
328
|
+
if (task.$type !== 'metatron.task' || task.status !== 'running') continue;
|
|
329
|
+
await store.set({ ...task, status: 'error', result: 'interrupted: server restart' });
|
|
330
|
+
console.log(`[guardian] reset stuck agent task: ${task.$path}`);
|
|
331
|
+
}
|
|
332
|
+
} catch { /* no tasks dir yet */ }
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Update pool — keep resumable agents in active, clear the rest
|
|
337
|
+
const poolNode = await store.get('/agents');
|
|
338
|
+
if (poolNode && poolNode.$type === 'ai.pool') {
|
|
339
|
+
await store.set({ ...poolNode, active: resumablePaths, queue: [] });
|
|
340
|
+
if (resumablePaths.length) {
|
|
341
|
+
console.log(`[guardian] pool active: [${resumablePaths.join(', ')}]`);
|
|
342
|
+
} else {
|
|
343
|
+
console.log(`[guardian] cleared pool active/queue`);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
} catch { /* */ }
|
|
347
|
+
|
|
348
|
+
// Reset stuck task aiStatus — but skip tasks being resumed
|
|
349
|
+
const resumedTaskPaths = new Set(resumable.map(r => r.taskPath));
|
|
350
|
+
try {
|
|
351
|
+
const { items } = await store.getChildren('/board/data');
|
|
352
|
+
for (const task of items) {
|
|
353
|
+
if (task.$type !== 'board.task') continue;
|
|
354
|
+
if (resumedTaskPaths.has(task.$path)) continue;
|
|
355
|
+
if (typeof task.aiStatus === 'string' && task.aiStatus) {
|
|
356
|
+
await store.set({ ...task, aiStatus: '' });
|
|
357
|
+
console.log(`[guardian] cleared aiStatus on: ${task.$path}`);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
} catch { /* no board */ }
|
|
361
|
+
|
|
362
|
+
return resumable;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// ── canUseTool callback for Agent SDK ──
|
|
366
|
+
|
|
367
|
+
export function createCanUseTool(
|
|
368
|
+
role: string,
|
|
369
|
+
agentPath: string,
|
|
370
|
+
store?: Tree,
|
|
371
|
+
) {
|
|
372
|
+
const allow = (): PermissionResult => ({ behavior: 'allow' });
|
|
373
|
+
const deny = (message: string): PermissionResult => ({ behavior: 'deny', message });
|
|
374
|
+
|
|
375
|
+
// Cache resolved policy (per agent run)
|
|
376
|
+
let cachedPolicy: ToolPolicy | null = null;
|
|
377
|
+
|
|
378
|
+
return async (
|
|
379
|
+
toolName: string,
|
|
380
|
+
input: Record<string, unknown>,
|
|
381
|
+
): Promise<PermissionResult> => {
|
|
382
|
+
|
|
383
|
+
// Lazy-resolve policy from tree on first call
|
|
384
|
+
if (!cachedPolicy) {
|
|
385
|
+
cachedPolicy = store
|
|
386
|
+
? await resolvePolicy(store, agentPath)
|
|
387
|
+
: FALLBACK_POLICY;
|
|
388
|
+
}
|
|
389
|
+
const policy = cachedPolicy;
|
|
390
|
+
|
|
391
|
+
// Deny-list check first (bare tool name)
|
|
392
|
+
if (matchesAny(policy.deny, toolName)) {
|
|
393
|
+
return deny(`${role}: denied: ${toolName}`);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Bash → split by pipes/operators, check each sub-command independently
|
|
397
|
+
if (toolName === 'Bash') {
|
|
398
|
+
const cmd = typeof input.command === 'string' ? input.command.trim() : '';
|
|
399
|
+
|
|
400
|
+
// C14: normalize backslash escapes before safety check — prevents bypass via `git\ reset\ --hard`
|
|
401
|
+
const normalized = cmd.replace(/\\(.)/g, '$1');
|
|
402
|
+
|
|
403
|
+
// Hardcoded safety net — check full command (both raw and normalized)
|
|
404
|
+
for (const pattern of DANGEROUS_BASH) {
|
|
405
|
+
if (pattern.test(cmd) || pattern.test(normalized)) return deny(`blocked: ${cmd.slice(0, 60)}`);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Split by newlines first, then by pipes/operators
|
|
409
|
+
const lines = cmd.split(/\n/).map(l => l.trim()).filter(Boolean);
|
|
410
|
+
|
|
411
|
+
// Check each line against safety net (belt-and-suspenders with full-string check above)
|
|
412
|
+
for (const line of lines) {
|
|
413
|
+
const normLine = line.replace(/\\(.)/g, '$1');
|
|
414
|
+
for (const pattern of DANGEROUS_BASH) {
|
|
415
|
+
if (pattern.test(line) || pattern.test(normLine)) return deny(`blocked: ${line.slice(0, 60)}`);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// Split into sub-commands and check each
|
|
420
|
+
const parts = lines.length > 0 ? lines.flatMap(l => splitBashParts(l)) : [];
|
|
421
|
+
const effectiveNames = parts.map(p => `Bash:${p}`);
|
|
422
|
+
|
|
423
|
+
// Any sub-command denied → deny entire command
|
|
424
|
+
for (const eName of effectiveNames) {
|
|
425
|
+
if (matchesAny(policy.deny, eName)) {
|
|
426
|
+
return deny(`${role}: denied: ${eName}`);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// Escalate BEFORE allow: explicit escalate beats wildcard allow
|
|
431
|
+
const escalated = effectiveNames.filter(e => matchesAny(policy.escalate, e));
|
|
432
|
+
if (escalated.length > 0) {
|
|
433
|
+
if (store) {
|
|
434
|
+
const approved = await requestApproval(store, {
|
|
435
|
+
agentPath, role, tool: escalated[0], input: cmd.slice(0, 200),
|
|
436
|
+
reason: 'requires approval',
|
|
437
|
+
});
|
|
438
|
+
return approved ? allow() : deny('denied by human');
|
|
439
|
+
}
|
|
440
|
+
return deny(`${role}: escalated but no store: ${escalated[0]}`);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// All sub-commands must be allowed — if any isn't, escalate as unknown
|
|
444
|
+
const notAllowed = effectiveNames.filter(e => !matchesAny(policy.allow, e));
|
|
445
|
+
if (notAllowed.length === 0 && effectiveNames.length > 0) {
|
|
446
|
+
return allow();
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// Unknown sub-commands → escalate
|
|
450
|
+
const unknownName = notAllowed[0] ?? `Bash:${cmd}`;
|
|
451
|
+
if (store) {
|
|
452
|
+
const approved = await requestApproval(store, {
|
|
453
|
+
agentPath, role, tool: unknownName, input: cmd.slice(0, 200),
|
|
454
|
+
reason: 'unknown tool',
|
|
455
|
+
});
|
|
456
|
+
return approved ? allow() : deny('denied by human');
|
|
457
|
+
}
|
|
458
|
+
return deny(`${role}: not allowed: ${unknownName}`);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// Non-Bash tools — deny → escalate → allow → unknown
|
|
462
|
+
// Escalate BEFORE allow: explicit "requires approval" beats wildcard allow.
|
|
463
|
+
if (matchesAny(policy.deny, toolName)) {
|
|
464
|
+
return deny(`${role}: denied: ${toolName}`);
|
|
465
|
+
}
|
|
466
|
+
if (matchesAny(policy.escalate, toolName)) {
|
|
467
|
+
if (store) {
|
|
468
|
+
const inputStr = JSON.stringify(input).slice(0, 500);
|
|
469
|
+
const approved = await requestApproval(store, {
|
|
470
|
+
agentPath, role, tool: toolName, input: inputStr,
|
|
471
|
+
reason: 'requires approval',
|
|
472
|
+
});
|
|
473
|
+
return approved ? allow() : deny('denied by human');
|
|
474
|
+
}
|
|
475
|
+
return deny(`${role}: escalated but no store: ${toolName}`);
|
|
476
|
+
}
|
|
477
|
+
if (matchesAny(policy.allow, toolName)) {
|
|
478
|
+
return allow();
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// Unknown tool — escalate to human
|
|
482
|
+
if (store) {
|
|
483
|
+
const inputStr = JSON.stringify(input).slice(0, 500);
|
|
484
|
+
const approved = await requestApproval(store, {
|
|
485
|
+
agentPath, role, tool: toolName, input: inputStr,
|
|
486
|
+
reason: 'unknown tool',
|
|
487
|
+
});
|
|
488
|
+
return approved ? allow() : deny('denied by human');
|
|
489
|
+
}
|
|
490
|
+
return deny(`${role}: not allowed: ${toolName}`);
|
|
491
|
+
};
|
|
492
|
+
}
|
package/agent/seed.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Agent Office seed — /agents pool + agents + guardian policies
|
|
2
|
+
|
|
3
|
+
import { type NodeData } from '@treenity/core';
|
|
4
|
+
import { registerPrefab } from '@treenity/core/mod';
|
|
5
|
+
|
|
6
|
+
registerPrefab('agent', 'seed', [
|
|
7
|
+
// Pool node — orchestrator service lives here
|
|
8
|
+
{ $path: 'agents', $type: 'ai.pool',
|
|
9
|
+
maxConcurrent: 2, active: [], queue: [] },
|
|
10
|
+
|
|
11
|
+
// Guardian — global base policy (applies to ALL agents)
|
|
12
|
+
// Read-only by default. Destructive ops denied. Writes require approval.
|
|
13
|
+
{ $path: 'agents/guardian', $type: 'dir',
|
|
14
|
+
policy: {
|
|
15
|
+
$type: 'ai.policy',
|
|
16
|
+
allow: [
|
|
17
|
+
'mcp__treenity__get_node', 'mcp__treenity__list_children',
|
|
18
|
+
'mcp__treenity__catalog', 'mcp__treenity__describe_type',
|
|
19
|
+
'mcp__treenity__search_types', 'mcp__treenity__compile_view',
|
|
20
|
+
],
|
|
21
|
+
deny: [
|
|
22
|
+
'mcp__treenity__remove_node',
|
|
23
|
+
'Bash:git checkout *', 'Bash:git checkout -- *',
|
|
24
|
+
'Bash:git reset --hard*', 'Bash:git push --force*', 'Bash:git clean*',
|
|
25
|
+
'Bash:rm -rf *', 'Bash:rm -r *', 'Bash:cat *.env*',
|
|
26
|
+
],
|
|
27
|
+
escalate: [
|
|
28
|
+
'mcp__treenity__set_node', 'mcp__treenity__execute', 'mcp__treenity__deploy_prefab',
|
|
29
|
+
'Bash:git add *', 'Bash:git commit *', 'Bash:git push *',
|
|
30
|
+
'Bash:sed *', 'Bash:mv *', 'Bash:cp *',
|
|
31
|
+
],
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
|
|
35
|
+
// Approvals queue
|
|
36
|
+
{ $path: 'agents/approvals', $type: 'ai.approvals' },
|
|
37
|
+
|
|
38
|
+
// ── QA agent (ECS: ai.agent + metatron.config) ──
|
|
39
|
+
{ $path: 'agents/qa', $type: 'ai.agent',
|
|
40
|
+
role: 'qa',
|
|
41
|
+
status: 'idle',
|
|
42
|
+
currentTask: '',
|
|
43
|
+
taskRef: '',
|
|
44
|
+
lastRunAt: 0,
|
|
45
|
+
totalTokens: 0,
|
|
46
|
+
// LLM runtime config — metatron.config component (D29)
|
|
47
|
+
config: {
|
|
48
|
+
$type: 'metatron.config',
|
|
49
|
+
model: 'claude-opus-4-6',
|
|
50
|
+
systemPrompt: `You are a QA agent for the Treenity project.
|
|
51
|
+
Your job: run tests, check for errors, verify code quality.
|
|
52
|
+
|
|
53
|
+
## QA Checklist
|
|
54
|
+
1. Run \`npm test\` and report results (use Bash tool)
|
|
55
|
+
2. Check for TypeScript errors
|
|
56
|
+
3. Report any failing tests with details
|
|
57
|
+
4. Summarize: PASS (all green) or FAIL (with specifics)
|
|
58
|
+
|
|
59
|
+
Be concise. Facts only.`,
|
|
60
|
+
sessionId: '',
|
|
61
|
+
},
|
|
62
|
+
// Agent-level policy: only ALLOW overrides (deny/escalate inherited from global guardian)
|
|
63
|
+
policy: {
|
|
64
|
+
$type: 'ai.policy',
|
|
65
|
+
allow: ['Bash:npm *', 'Bash:ls *', 'Bash:cat *', 'Bash:git status*', 'Bash:git diff*', 'Bash:git log*'],
|
|
66
|
+
deny: [],
|
|
67
|
+
escalate: [],
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
{ $path: 'agents/qa/tasks', $type: 'dir' },
|
|
71
|
+
|
|
72
|
+
// Autostart — orchestrator starts on server boot
|
|
73
|
+
{ $path: '/sys/autostart/agents', $type: 'ref', $ref: '/agents' },
|
|
74
|
+
] as NodeData[]);
|
package/agent/server.ts
ADDED