lazyclaw 5.4.3 → 6.0.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.
Files changed (46) hide show
  1. package/channels/handoff.mjs +36 -0
  2. package/cli.mjs +73 -7399
  3. package/daemon.mjs +23 -2085
  4. package/mas/agent_turn.mjs +2 -1
  5. package/mas/index_db.mjs +82 -0
  6. package/mas/learning.mjs +17 -1
  7. package/mas/mention_router.mjs +38 -10
  8. package/mas/provider_adapters.mjs +28 -4
  9. package/mas/scrub_env.mjs +34 -0
  10. package/mas/tool_runner.mjs +23 -7
  11. package/mas/tools/bash.mjs +10 -5
  12. package/mas/tools/browser.mjs +18 -0
  13. package/mas/tools/learning.mjs +24 -14
  14. package/mas/tools/recall.mjs +5 -1
  15. package/mas/tools/web.mjs +47 -11
  16. package/mas/trajectory_store.mjs +7 -4
  17. package/package.json +3 -2
  18. package/providers/auth_store.mjs +22 -0
  19. package/providers/claude_cli.mjs +28 -2
  20. package/providers/claude_cli_detect.mjs +46 -0
  21. package/providers/custom_provider.mjs +70 -0
  22. package/providers/model_catalogue.mjs +86 -0
  23. package/providers/orchestrator.mjs +30 -9
  24. package/providers/rates.mjs +12 -2
  25. package/providers/registry.mjs +10 -7
  26. package/sandbox/confiners/landlock.mjs +14 -8
  27. package/sandbox/confiners/seatbelt.mjs +18 -2
  28. package/scripts/loop-worker.mjs +18 -7
  29. package/scripts/migrate-v5.mjs +5 -61
  30. package/sessions.mjs +0 -0
  31. package/tui/editor.mjs +44 -0
  32. package/tui/modal_filter.mjs +59 -0
  33. package/tui/modal_picker.mjs +12 -37
  34. package/tui/pickers.mjs +917 -0
  35. package/tui/provider_families.mjs +41 -0
  36. package/tui/repl.mjs +67 -36
  37. package/tui/slash_commands.mjs +8 -7
  38. package/tui/slash_dispatcher.mjs +923 -118
  39. package/tui/splash.mjs +5 -12
  40. package/tui/subcommands.mjs +17 -0
  41. package/tui/terminal_approve.mjs +37 -0
  42. package/web/dashboard.css +275 -0
  43. package/web/dashboard.html +2 -1685
  44. package/web/dashboard.js +1406 -0
  45. package/workflow/persistent.mjs +13 -6
  46. package/mas/tools/skill_view.mjs +0 -43
@@ -94,6 +94,7 @@ export async function runAgentTurn({
94
94
  maxIterations = DEFAULT_MAX_ITERATIONS,
95
95
  signal,
96
96
  approve,
97
+ security,
97
98
  // v5 (Group A — C3): trajectoryRef is OPT-OUT, not opt-in. A caller
98
99
  // that doesn't pass one but DOES pass a configDir gets a
99
100
  // default-stamped record so every production agent turn lands in
@@ -218,7 +219,7 @@ export async function runAgentTurn({
218
219
  try {
219
220
  result = await runTool({
220
221
  agent, tool: call.name, args: call.input,
221
- taskId, configDir, cwd, approve,
222
+ taskId, configDir, cwd, approve, security,
222
223
  });
223
224
  if (result && result.ok === false) ok = false;
224
225
  } catch (err) {
package/mas/index_db.mjs CHANGED
@@ -85,6 +85,16 @@ function prepareStatements(db) {
85
85
  insertMemory: db.prepare(
86
86
  `INSERT INTO fts_memories(content, topic, kind)
87
87
  VALUES (?, ?, ?)`),
88
+ // Upsert-by-natural-key deletes (skills/trajectories/memories only —
89
+ // these get re-indexed for the same key on every save/put, so a bare
90
+ // INSERT would accumulate duplicate FTS rows that skew bm25 and eat the
91
+ // recall k-budget). Sessions are NOT deduped this way: each turn has a
92
+ // unique (session_id,turn_idx) in normal flow, and a per-turn full-table
93
+ // DELETE would reintroduce O(n^2) on the hot write path; session dedup is
94
+ // handled by reindexAll rebuilding from scratch.
95
+ deleteSkill: db.prepare(`DELETE FROM fts_skills WHERE skill_name = ?`),
96
+ deleteTrajectory: db.prepare(`DELETE FROM fts_trajectories WHERE trajectory_id = ?`),
97
+ deleteMemory: db.prepare(`DELETE FROM fts_memories WHERE topic = ? AND kind = ?`),
88
98
  queries: {
89
99
  sessions: db.prepare(
90
100
  `SELECT 'sessions' AS scope, bm25(fts_sessions) AS bm25,
@@ -160,6 +170,7 @@ export function indexSessionTurn(row, configDir = defaultConfigDir()) {
160
170
  export function indexSkill(row, configDir = defaultConfigDir()) {
161
171
  try {
162
172
  const s = _stmts(configDir);
173
+ s.deleteSkill.run(String(row.skill_name || '')); // upsert by skill_name
163
174
  s.insertSkill.run(
164
175
  redactSecrets(String(row.content || '')),
165
176
  String(row.skill_name || ''), String(row.trained_by || ''),
@@ -175,6 +186,7 @@ export function indexSkill(row, configDir = defaultConfigDir()) {
175
186
  export function indexTrajectory(row, configDir = defaultConfigDir()) {
176
187
  try {
177
188
  const s = _stmts(configDir);
189
+ s.deleteTrajectory.run(String(row.trajectory_id || '')); // upsert by trajectory_id
178
190
  s.insertTrajectory.run(
179
191
  redactSecrets(String(row.content || '')),
180
192
  String(row.trajectory_id || ''), String(row.agent || ''),
@@ -190,6 +202,7 @@ export function indexTrajectory(row, configDir = defaultConfigDir()) {
190
202
  export function indexMemory(row, configDir = defaultConfigDir()) {
191
203
  try {
192
204
  const s = _stmts(configDir);
205
+ s.deleteMemory.run(String(row.topic || ''), String(row.kind || '')); // upsert by (topic,kind)
193
206
  s.insertMemory.run(
194
207
  redactSecrets(String(row.content || '')),
195
208
  String(row.topic || ''), String(row.kind || ''),
@@ -287,6 +300,9 @@ export function integrityCheck(configDir = defaultConfigDir()) {
287
300
  return { ok: r === 'ok', result: r };
288
301
  }
289
302
 
303
+ // Destructive primitive: drop the db (+ WAL sidecars) and recreate the empty
304
+ // schema. Callers that want a *populated* index must use reindexAll — a bare
305
+ // rebuild leaves recall returning zero hits.
290
306
  export function rebuild(configDir = defaultConfigDir()) {
291
307
  closeIndex(configDir);
292
308
  const p = dbPath(configDir);
@@ -300,3 +316,69 @@ export function rebuild(configDir = defaultConfigDir()) {
300
316
  }
301
317
  openIndex(configDir);
302
318
  }
319
+
320
+ // Minimal frontmatter splitter (trained_by / group are the only keys reindex
321
+ // needs); avoids importing skills.mjs and risking an import cycle.
322
+ function _miniFrontmatter(raw) {
323
+ const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(String(raw || ''));
324
+ if (!m) return { meta: {}, body: String(raw || '') };
325
+ const meta = {};
326
+ for (const line of m[1].split('\n')) {
327
+ const i = line.indexOf(':');
328
+ if (i > 0) meta[line.slice(0, i).trim()] = line.slice(i + 1).trim();
329
+ }
330
+ return { meta, body: m[2] };
331
+ }
332
+
333
+ // Rebuild AND repopulate the FTS index from the on-disk source of truth
334
+ // (sessions JSONL, flat skill .md, memory core/episodic). This is what
335
+ // `index rebuild` / doctor recovery must call — a bare rebuild() zeroes recall.
336
+ // Shared by scripts/migrate-v5 and the daemon POST /index/rebuild route.
337
+ export function reindexAll(configDir = defaultConfigDir()) {
338
+ rebuild(configDir);
339
+ // Sessions — flat <configDir>/sessions/<id>.jsonl, one turn per line.
340
+ const sessDir = path.join(configDir, 'sessions');
341
+ if (fs.existsSync(sessDir)) {
342
+ for (const f of fs.readdirSync(sessDir)) {
343
+ if (!f.endsWith('.jsonl')) continue;
344
+ const id = f.slice(0, -'.jsonl'.length);
345
+ let idx = 0;
346
+ const raw = fs.readFileSync(path.join(sessDir, f), 'utf8');
347
+ for (const line of raw.split('\n')) {
348
+ if (!line) continue;
349
+ try {
350
+ const o = JSON.parse(line);
351
+ indexSessionTurn({ session_id: id, turn_idx: idx++, role: o.role || 'user', ts: o.ts || 0, content: o.content || '' }, configDir);
352
+ } catch { /* skip malformed line */ }
353
+ }
354
+ }
355
+ }
356
+ // Skills — canonical flat <configDir>/skills/<name>.md (skip the .archive dir).
357
+ const skillsDir = path.join(configDir, 'skills');
358
+ if (fs.existsSync(skillsDir)) {
359
+ for (const f of fs.readdirSync(skillsDir)) {
360
+ if (!f.endsWith('.md')) continue;
361
+ const name = f.slice(0, -'.md'.length);
362
+ const { meta, body } = _miniFrontmatter(fs.readFileSync(path.join(skillsDir, f), 'utf8'));
363
+ indexSkill({
364
+ skill_name: name,
365
+ trained_by: meta.trained_by || 'legacy',
366
+ group_name: meta.group || (name.includes('-') ? name.split('-')[0] : 'legacy'),
367
+ content: body,
368
+ }, configDir);
369
+ }
370
+ }
371
+ // Memory — core.md + episodic/*.md.
372
+ const memDir = path.join(configDir, 'memory');
373
+ if (fs.existsSync(memDir)) {
374
+ const core = path.join(memDir, 'core.md');
375
+ if (fs.existsSync(core)) indexMemory({ topic: 'core', kind: 'core', content: fs.readFileSync(core, 'utf8') }, configDir);
376
+ const epi = path.join(memDir, 'episodic');
377
+ if (fs.existsSync(epi)) {
378
+ for (const f of fs.readdirSync(epi)) {
379
+ if (!f.endsWith('.md')) continue;
380
+ indexMemory({ topic: f.slice(0, -'.md'.length), kind: 'episodic', content: fs.readFileSync(path.join(epi, f), 'utf8') }, configDir);
381
+ }
382
+ }
383
+ }
384
+ }
package/mas/learning.mjs CHANGED
@@ -34,6 +34,7 @@ import * as userModeler from './user_modeler.mjs';
34
34
  import * as confidence from './confidence.mjs';
35
35
  import * as skills from '../skills.mjs';
36
36
  import { resolveTrainer } from '../providers/registry.mjs';
37
+ import { hasClaudeCliSession } from '../providers/claude_cli_detect.mjs';
37
38
 
38
39
  export const TRIGGERS = Object.freeze([
39
40
  'post-task',
@@ -330,9 +331,24 @@ export async function _runPeriodicCuration(_ctx, logger) {
330
331
 
331
332
  // ── helpers ──────────────────────────────────────────────────────────
332
333
 
334
+ let _trainerNoticed = false;
335
+
333
336
  function _safeResolveTrainer(cfg, agent) {
334
337
  try {
335
- return resolveTrainer(cfg || { provider: agent?.provider, model: agent?.model });
338
+ const c = cfg || { provider: agent?.provider, model: agent?.model };
339
+ // Use real claude-cli session detection in production (the resolver's own
340
+ // default stub only checked an env var that `claude login` never sets).
341
+ const resolved = resolveTrainer(c, { detectClaudeCli: hasClaudeCliSession });
342
+ // Honesty: if the user asked for trainer.provider:'auto' but no claude-cli
343
+ // session was found, the learning loop bills the chat provider — say so once
344
+ // so the "$0 on Claude Pro" promise never fails silently.
345
+ if (!_trainerNoticed && c && c.trainer && c.trainer.provider === 'auto' && resolved.provider !== 'claude-cli') {
346
+ _trainerNoticed = true;
347
+ try {
348
+ process.stderr.write(`[trainer] no claude-cli session detected → learning will use "${resolved.provider || 'the chat provider'}" (billed per token). Run 'claude login', or set trainer.provider explicitly, to control cost.\n`);
349
+ } catch { /* stderr closed */ }
350
+ }
351
+ return resolved;
336
352
  } catch {
337
353
  return { provider: agent?.provider || '', model: agent?.model || '' };
338
354
  }
@@ -280,11 +280,11 @@ async function postTypingPlaceholder({ task, agentRecord, logger = () => {}, sen
280
280
  if (agentRecord?.iconEmoji) sendOpts.icon_emoji = agentRecord.iconEmoji;
281
281
  try {
282
282
  const res = await slack.send(threadId, `_:hourglass_flowing_sand: thinking…_`, sendOpts);
283
- return { ts: res?.ts || null, sender: owned ? slack : null, channel: task.slackChannel };
283
+ return { ts: res?.ts || null, sender: slack, owned, channel: task.slackChannel };
284
284
  } catch (err) {
285
285
  logger(`[router] slack typing post failed: ${err?.message || err}\n`);
286
286
  if (owned) await slack.stop().catch(() => {});
287
- return { ts: null, sender: null };
287
+ return { ts: null, sender: null, owned: false };
288
288
  }
289
289
  }
290
290
 
@@ -360,12 +360,12 @@ async function autoSynthSkills({ task, agentsById, apiKey, baseUrl, fetchImpl, c
360
360
  }
361
361
 
362
362
  async function clearTypingPlaceholder(placeholder, logger) {
363
- if (!placeholder?.ts || !placeholder?.channel) return;
363
+ if (!placeholder?.ts || !placeholder?.channel || !placeholder?.sender) return;
364
364
  const slack = placeholder.sender;
365
- if (!slack) return; // sender wasn't owned by us; skip
366
365
  try { await slack.deleteMessage(placeholder.channel, placeholder.ts); }
367
366
  catch (err) { logger(`[router] slack typing delete failed: ${err?.message || err}\n`); }
368
- finally { await slack.stop().catch(() => {}); }
367
+ // Only stop a client we created here; a shared sender is closed once by run().
368
+ finally { if (placeholder.owned) await slack.stop().catch(() => {}); }
369
369
  }
370
370
 
371
371
  // Run agents in this team until the queue empties or budget runs out.
@@ -388,6 +388,11 @@ export async function runTaskTurn({
388
388
  maxAgentTurns = DEFAULT_MAX_AGENT_TURNS,
389
389
  signal,
390
390
  approve,
391
+ security,
392
+ // E3 — a long-lived caller (e.g. the daemon) can pass a pre-started
393
+ // SlackChannel to reuse across many task turns; run() then neither
394
+ // creates nor stops it. When omitted, run() opens + closes its own.
395
+ slackSender: providedSender,
391
396
  } = {}) {
392
397
  if (!task || !team || !agentsById) {
393
398
  throw new MentionRouterError('task, team, agentsById are required', 'ROUTER_BAD_INPUT');
@@ -410,11 +415,30 @@ export async function runTaskTurn({
410
415
  current = tasksMod.patchTask(current.id, { status: 'running' }, configDir);
411
416
  }
412
417
 
418
+ // E3 — open ONE Slack client for the whole run and reuse it for every
419
+ // thread post + typing placeholder, instead of constructing + starting +
420
+ // stopping a fresh SlackChannel (a network auth handshake) on each of the
421
+ // ~3-4 posts per agent turn. Posts fall back to a per-call owned client
422
+ // when no shared sender is available (no thread, or start failure).
423
+ let slackSender = providedSender || null;
424
+ let ownSlackSender = false;
425
+ if (!slackSender && current.slackChannel && current.slackThreadTs) {
426
+ try {
427
+ const { SlackChannel } = await import('../channels/slack.mjs');
428
+ slackSender = new SlackChannel({ requireInbound: false });
429
+ await slackSender.start(async () => '', {});
430
+ ownSlackSender = true;
431
+ } catch (err) {
432
+ logger(`[router] slack start failed, falling back to per-post clients: ${err?.message || err}\n`);
433
+ slackSender = null;
434
+ }
435
+ }
436
+
413
437
  // Seed: append the user message if provided, and (also) push it to
414
438
  // Slack so anyone reading the thread sees the prompt.
415
439
  if (userMessage && String(userMessage).trim()) {
416
440
  current = tasksMod.appendTurn(current.id, { agent: 'user', text: String(userMessage), ts: new Date().toISOString() }, configDir);
417
- await postToThread({ task: current, agentRecord: null, text: `*User*: ${userMessage}`, logger });
441
+ await postToThread({ task: current, agentRecord: null, text: `*User*: ${userMessage}`, logger, sender: slackSender });
418
442
  }
419
443
 
420
444
  const queue = [team.lead];
@@ -437,7 +461,7 @@ export async function runTaskTurn({
437
461
  // up the turn before the LLM finishes. Cleared right after the
438
462
  // real reply lands so we never leave a stale placeholder in the
439
463
  // thread.
440
- const typing = await postTypingPlaceholder({ task: current, agentRecord, logger });
464
+ const typing = await postTypingPlaceholder({ task: current, agentRecord, logger, sender: slackSender });
441
465
 
442
466
  let result;
443
467
  try {
@@ -450,7 +474,7 @@ export async function runTaskTurn({
450
474
  userMessage: '',
451
475
  history: ctx.history,
452
476
  taskId: current.id,
453
- configDir, cwd, apiKey, fetchImpl, baseUrl, signal, approve,
477
+ configDir, cwd, apiKey, fetchImpl, baseUrl, signal, approve, security,
454
478
  // C9 — enable Anthropic prompt caching for the static system
455
479
  // prefix + tool definitions. Non-anthropic adapters ignore
456
480
  // the flag (it's a no-op for OpenAI/Gemini/claude-cli).
@@ -470,11 +494,11 @@ export async function runTaskTurn({
470
494
 
471
495
  // Slack mirror — only the user-visible text, with the agent name
472
496
  // prefixed so a human reader can follow who said what.
473
- if (replyText) await postToThread({ task: current, agentRecord, text: replyText, logger });
497
+ if (replyText) await postToThread({ task: current, agentRecord, text: replyText, logger, sender: slackSender });
474
498
 
475
499
  if (replyText.includes(DONE_MARKER)) {
476
500
  current = tasksMod.patchTask(current.id, { status: 'done' }, configDir);
477
- await postToThread({ task: current, agentRecord: null, text: `:white_check_mark: ${DONE_MARKER} — task closed by *${agentRecord.displayName || speaker}*.`, logger });
501
+ await postToThread({ task: current, agentRecord: null, text: `:white_check_mark: ${DONE_MARKER} — task closed by *${agentRecord.displayName || speaker}*.`, logger, sender: slackSender });
478
502
  stoppedBy = 'done';
479
503
  // Phase 18: fire one reflection LLM call per participating agent
480
504
  // whose memoryWrite is 'auto'. We pick "participating" off the
@@ -508,5 +532,9 @@ export async function runTaskTurn({
508
532
  }
509
533
 
510
534
  if (iterations >= maxAgentTurns) stoppedBy = 'budget';
535
+ // Close the Slack client once for the whole run — but only if WE opened
536
+ // it. A caller-provided sender is left running for the caller to reuse.
537
+ // All exit paths (done/budget/abort/idle) fall through to this return.
538
+ if (ownSlackSender && slackSender) await slackSender.stop().catch(() => {});
511
539
  return { task: current, iterations, stoppedBy };
512
540
  }
@@ -35,13 +35,37 @@ export async function resolveToolUseAdapter(provider) {
35
35
  case 'gemini': return await import('../providers/tool_use/gemini.mjs');
36
36
  case 'claude-cli': return await import('../providers/tool_use/claude_cli.mjs');
37
37
  default:
38
- throw new ProviderAdapterError(
39
- `provider "${provider}" does not support text completion`,
40
- 'PROVIDER_ADAPTER_UNKNOWN',
41
- );
38
+ return await _openAICompatAdapter(provider);
42
39
  }
43
40
  }
44
41
 
42
+ // Any OpenAI-wire-compatible provider — the built-in compat vendors
43
+ // (nim/openrouter/groq/together/xai/deepseek/mistral/fireworks) and custom
44
+ // providers — can drive tool-use through the OpenAI adapter, just at a
45
+ // different base URL. They advertise as first-class providers, so agents,
46
+ // teams, and the trainer must work for them too (previously they threw
47
+ // "does not support text completion"). We bind the provider's baseUrl so the
48
+ // caller doesn't have to know it; an explicit baseUrl in the call still wins.
49
+ async function _openAICompatAdapter(provider) {
50
+ let info;
51
+ try {
52
+ const reg = await import('../providers/registry.mjs');
53
+ info = reg.PROVIDER_INFO && reg.PROVIDER_INFO[provider];
54
+ } catch { info = null; }
55
+ if (!info || !(info.builtinOpenAICompat || info.custom || info.baseUrl)) {
56
+ throw new ProviderAdapterError(
57
+ `provider "${provider}" does not support text completion`,
58
+ 'PROVIDER_ADAPTER_UNKNOWN',
59
+ );
60
+ }
61
+ const base = await import('../providers/tool_use/openai.mjs');
62
+ if (!info.baseUrl) return base; // custom without a stored baseUrl — caller supplies it
63
+ return {
64
+ ...base,
65
+ callOnce: (opts = {}) => base.callOnce({ baseUrl: info.baseUrl, ...opts }),
66
+ };
67
+ }
68
+
45
69
  // Run one no-tools text completion through the provider's tool-use
46
70
  // adapter and return the model's text (or '' when it produced none).
47
71
  //
@@ -0,0 +1,34 @@
1
+ // scrub_env.mjs — strip secret-bearing variables from an environment copy.
2
+ //
3
+ // The bash tool inherits the parent process environment so commands behave
4
+ // like a normal shell (PATH, HOME, locale, proxy settings, …). But the parent
5
+ // env also carries provider API keys and channel tokens — including anything
6
+ // loaded from <configDir>/.env by dotenv_min — and an agent's shell command is
7
+ // model-controlled (and steerable via prompt injection). Passing the raw env
8
+ // to a child lets a single `env | curl …` exfiltrate every credential.
9
+ //
10
+ // scrubEnv returns a COPY of `env` with keys that look like secrets removed,
11
+ // while keeping the operational variables a command legitimately needs. An
12
+ // explicit `allow` list opts specific keys back in for the rare command that
13
+ // genuinely needs one.
14
+
15
+ // Matches keys whose final _-segment is a secret-ish noun: FOO_API_KEY,
16
+ // ANTHROPIC_API_KEY, *_TOKEN (incl. CLAUDE_CODE_OAUTH_TOKEN, GITHUB_TOKEN),
17
+ // *_SECRET, *_PASSWORD/PASSWD, *_CREDENTIAL(S), *_PRIVATE_KEY, *_ACCESS_KEY.
18
+ const SECRET_KEY_RE =
19
+ /(^|_)(API_?KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|CREDENTIALS|PRIVATE_KEY|ACCESS_KEY|AUTH_TOKEN)$/i;
20
+
21
+ export function isSecretKey(name) {
22
+ return SECRET_KEY_RE.test(String(name || ''));
23
+ }
24
+
25
+ export function scrubEnv(env = process.env, { allow = [] } = {}) {
26
+ const allowSet = new Set(allow);
27
+ const out = {};
28
+ for (const [k, v] of Object.entries(env || {})) {
29
+ if (allowSet.has(k)) { out[k] = v; continue; }
30
+ if (isSecretKey(k)) continue; // drop secrets
31
+ out[k] = v;
32
+ }
33
+ return out;
34
+ }
@@ -35,7 +35,7 @@ export function listToolSchemas(names) {
35
35
  export function isImplemented(name) { return registry.lookup(name) !== null; }
36
36
  export function knownTool(name) { return registry.lookup(name) !== null; }
37
37
 
38
- export async function runTool({ agent, tool, args, taskId, configDir, cwd, approve } = {}) {
38
+ export async function runTool({ agent, tool, args, taskId, configDir, cwd, approve, security } = {}) {
39
39
  if (!agent || !Array.isArray(agent.tools)) {
40
40
  throw new ToolError('agent record with .tools[] is required', 'TOOL_BAD_AGENT');
41
41
  }
@@ -44,12 +44,28 @@ export async function runTool({ agent, tool, args, taskId, configDir, cwd, appro
44
44
  if (!agent.tools.includes(tool)) {
45
45
  throw new ToolError(`agent "${agent.name}" is not allowed to call tool "${tool}" (whitelist=[${agent.tools.join(', ')}])`, 'TOOL_DENIED');
46
46
  }
47
- if (typeof approve === 'function' && impl.sensitive) {
48
- let verdict;
49
- try { verdict = await approve({ tool, args, agent: agent.name }); }
50
- catch (err) { verdict = { approved: false, reason: `approval error: ${err?.message || err}` }; }
51
- if (!verdict || !verdict.approved) {
52
- const result = { ok: false, error: `tool "${tool}" denied by operator${verdict?.reason ? `: ${verdict.reason}` : ''}`, code: 'TOOL_DENIED_APPROVAL' };
47
+ // Sensitive tools (shell exec, file writes, network egress, delegation)
48
+ // are fail-closed: they run only behind an approval hook, OR when the
49
+ // operator has explicitly opted into unattended execution. A missing
50
+ // approve hook used to mean "ungated" that made a fresh interactive
51
+ // install run bash/write with no confirmation, i.e. remote-prompt-
52
+ // injection-to-RCE. The default is now deny.
53
+ if (impl.sensitive) {
54
+ if (typeof approve === 'function') {
55
+ let verdict;
56
+ try { verdict = await approve({ tool, args, agent: agent.name }); }
57
+ catch (err) { verdict = { approved: false, reason: `approval error: ${err?.message || err}` }; }
58
+ if (!verdict || !verdict.approved) {
59
+ const result = { ok: false, error: `tool "${tool}" denied by operator${verdict?.reason ? `: ${verdict.reason}` : ''}`, code: 'TOOL_DENIED_APPROVAL' };
60
+ audit.append({ taskId, agent: agent.name, tool, args, result, ok: false, configDir });
61
+ return result;
62
+ }
63
+ } else if (security && security.allowUnattendedSensitive === true) {
64
+ // Explicit, persisted opt-in to unattended sensitive execution.
65
+ // Never silent — record that the gate was bypassed by config.
66
+ audit.append({ taskId, agent: agent.name, tool, args, result: { ok: true, note: 'sensitive tool ran unattended (security.allowUnattendedSensitive)' }, ok: true, configDir });
67
+ } else {
68
+ const result = { ok: false, error: `tool "${tool}" is sensitive and requires operator approval, but no approval channel is configured. Run interactively, pass --approve-url, or set security.allowUnattendedSensitive=true to allow unattended use.`, code: 'TOOL_DENIED_NO_APPROVER' };
53
69
  audit.append({ taskId, agent: agent.name, tool, args, result, ok: false, configDir });
54
70
  return result;
55
71
  }
@@ -1,14 +1,19 @@
1
1
  // Bash tool — runs a shell command, captures stdout/stderr/exit.
2
2
  //
3
- // Workspace is constrained to the lazyclaw process cwd (spec §5.2). We
4
- // don't sandbox further (per §10 #6 destructive-pattern confirmation
5
- // is OFF by default); the audit log captures every invocation so post-hoc
6
- // forensics work.
3
+ // The command runs with cwd defaulted to the lazyclaw process cwd, but this
4
+ // is NOT an OS sandbox absolute paths and `cd` escape it. Two real
5
+ // protections apply instead: (1) bash is a `sensitive` tool, so the
6
+ // fail-closed approval gate in tool_runner requires operator confirmation
7
+ // (or an explicit security.allowUnattendedSensitive opt-in) before it runs;
8
+ // (2) the child env is scrubbed of secrets (scrubEnv) so a command cannot
9
+ // exfiltrate API keys / channel tokens inherited from the parent or
10
+ // <configDir>/.env. Every invocation is still audit-logged for forensics.
7
11
  //
8
12
  // Timeout defaults to 30s so a runaway command can't stall the whole
9
13
  // agent turn. Override via args.timeoutMs (capped at 5 minutes).
10
14
 
11
15
  import { spawn } from 'node:child_process';
16
+ import { scrubEnv } from '../scrub_env.mjs';
12
17
 
13
18
  export const NAME = 'bash';
14
19
  export const DESCRIPTION = 'Run a shell command in the agent\'s workspace. Returns {stdout, stderr, exitCode}. Timeout 30s by default.';
@@ -34,7 +39,7 @@ export async function exec(args, { cwd = process.cwd() } = {}) {
34
39
  MAX_TIMEOUT_MS
35
40
  );
36
41
  return new Promise((resolve) => {
37
- const child = spawn('sh', ['-c', args.command], { cwd, env: process.env });
42
+ const child = spawn('sh', ['-c', args.command], { cwd, env: scrubEnv(process.env) });
38
43
  let stdout = '', stderr = '';
39
44
  let outBytes = 0, errBytes = 0;
40
45
  let truncated = false;
@@ -3,6 +3,8 @@
3
3
  // return a structured error if it is missing at runtime. A persistent
4
4
  // headless Chromium context is reused across calls in the same process.
5
5
 
6
+ import { isSafeUrl, isPrivateAddr } from './web.mjs';
7
+
6
8
  let _backend = null;
7
9
  export function __setBrowserBackend(b) { _backend = b; }
8
10
 
@@ -15,6 +17,16 @@ async function ensureCtx() {
15
17
  catch { throw new Error('browser: playwright not installed (npm i playwright)'); }
16
18
  const browser = await pw.chromium.launch({ headless: true });
17
19
  const context = await browser.newContext();
20
+ // Block in-page requests (redirects, subresources, JS-driven navigations)
21
+ // to loopback / private / link-local / metadata IPs — goto-time validation
22
+ // alone misses those. Cheap literal-IP check, no per-request DNS.
23
+ await context.route('**/*', (route) => {
24
+ try {
25
+ const h = new URL(route.request().url()).hostname.replace(/^\[|\]$/g, '');
26
+ if (h === 'localhost' || h === '0.0.0.0' || isPrivateAddr(h)) return route.abort('blockedbyclient');
27
+ } catch { /* unparseable → let Chromium handle it */ }
28
+ return route.continue();
29
+ });
18
30
  const page = await context.newPage();
19
31
  _ctx = {
20
32
  navigate: async (url) => { await page.goto(url, { waitUntil: 'domcontentloaded' }); return { url: page.url(), title: await page.title() }; },
@@ -39,6 +51,12 @@ const browser_navigate = {
39
51
  parameters: { type: 'object', properties: { url: { type: 'string' } }, required: ['url'] },
40
52
  async exec(args) {
41
53
  if (!safeHttp(args.url)) return { ok: false, error: 'browser_navigate: http(s) only' };
54
+ // Same DNS-resolving SSRF guard as web_fetch before driving Chromium at
55
+ // the URL — otherwise an agent could navigate to the dashboard, cloud
56
+ // metadata (169.254.169.254), or internal services and read them back
57
+ // via title / DOM / screenshot.
58
+ const safe = await isSafeUrl(args.url);
59
+ if (!safe.ok) return { ok: false, error: `browser_navigate: ${safe.error}` };
42
60
  try { const ctx = await ensureCtx(); return { ok: true, ...(await ctx.navigate(args.url)) }; }
43
61
  catch (e) { return { ok: false, error: `browser_navigate: ${e.message}` }; }
44
62
  },
@@ -4,12 +4,12 @@
4
4
 
5
5
  import fs from 'node:fs';
6
6
  import path from 'node:path';
7
+ import * as skills from '../../skills.mjs';
7
8
 
8
9
  function resolveConfigDir(ctx) {
9
10
  return ctx?.configDir || process.env.LAZYCLAW_CONFIG_DIR || path.join(process.env.HOME || '.', '.lazyclaw');
10
11
  }
11
12
 
12
- function skillsDir(ctx) { return path.join(resolveConfigDir(ctx), 'skills'); }
13
13
  function memoryDir(ctx) { return path.join(resolveConfigDir(ctx), 'memory'); }
14
14
  function userMdPath(ctx) { return path.join(memoryDir(ctx), 'USER.md'); }
15
15
 
@@ -19,15 +19,26 @@ const skill_view = {
19
19
  parameters: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] },
20
20
  async exec(args, ctx) {
21
21
  if (!args?.name) return { ok: false, error: 'skill_view: name required' };
22
- const file = path.join(skillsDir(ctx), args.name, 'SKILL.md');
23
- if (!fs.existsSync(file)) return { ok: false, error: `skill_view: ${args.name} not installed` };
24
- return { ok: true, name: args.name, content: fs.readFileSync(file, 'utf8') };
22
+ const configDir = resolveConfigDir(ctx);
23
+ if (!skills.skillExists(args.name, configDir)) return { ok: false, error: `skill_view: ${args.name} not installed` };
24
+ try {
25
+ const content = skills.loadSkill(args.name, configDir);
26
+ // Record the recall so the curator can age out never-used skills.
27
+ // Best-effort: a usage-write hiccup must never fail the tool call.
28
+ try {
29
+ const curator = await import('../../skills_curator.mjs');
30
+ curator.recordUsage(args.name, configDir, Date.now());
31
+ } catch { /* non-fatal */ }
32
+ return { ok: true, name: args.name, content };
33
+ } catch (err) {
34
+ return { ok: false, error: `skill_view: ${err?.message || err}` };
35
+ }
25
36
  },
26
37
  };
27
38
 
28
39
  const skill_create = {
29
40
  name: 'skill_create', category: 'learning', sensitive: true,
30
- description: 'Create a new skill at <configDir>/skills/<name>/SKILL.md. Fails if already exists.',
41
+ description: 'Create a new skill at <configDir>/skills/<name>.md. Fails if already exists.',
31
42
  parameters: {
32
43
  type: 'object',
33
44
  properties: {
@@ -39,10 +50,8 @@ const skill_create = {
39
50
  async exec(args, ctx) {
40
51
  if (!args?.name || !args?.body) return { ok: false, error: 'skill_create: name + body required' };
41
52
  if (!/^[a-z0-9][a-z0-9-]*$/.test(args.name)) return { ok: false, error: 'skill_create: kebab-case name only' };
42
- const dir = path.join(skillsDir(ctx), args.name);
43
- const file = path.join(dir, 'SKILL.md');
44
- if (fs.existsSync(file)) return { ok: false, error: `skill_create: ${args.name} already exists; use skill_edit` };
45
- fs.mkdirSync(dir, { recursive: true });
53
+ const configDir = resolveConfigDir(ctx);
54
+ if (skills.skillExists(args.name, configDir)) return { ok: false, error: `skill_create: ${args.name} already exists; use skill_edit` };
46
55
  const fm = [
47
56
  '---',
48
57
  `name: ${args.name}`,
@@ -54,7 +63,7 @@ const skill_create = {
54
63
  '---',
55
64
  '',
56
65
  ].join('\n');
57
- fs.writeFileSync(file, fm + args.body + (args.body.endsWith('\n') ? '' : '\n'));
66
+ const file = skills.installSkill(args.name, fm + args.body + (args.body.endsWith('\n') ? '' : '\n'), configDir);
58
67
  return { ok: true, name: args.name, file };
59
68
  },
60
69
  };
@@ -68,14 +77,15 @@ const skill_edit = {
68
77
  required: ['name', 'body'],
69
78
  },
70
79
  async exec(args, ctx) {
71
- const file = path.join(skillsDir(ctx), args.name, 'SKILL.md');
72
- if (!fs.existsSync(file)) return { ok: false, error: `skill_edit: ${args.name} not installed` };
73
- const src = fs.readFileSync(file, 'utf8');
80
+ if (!args?.name || !args?.body) return { ok: false, error: 'skill_edit: name + body required' };
81
+ const configDir = resolveConfigDir(ctx);
82
+ if (!skills.skillExists(args.name, configDir)) return { ok: false, error: `skill_edit: ${args.name} not installed` };
83
+ const src = skills.loadSkill(args.name, configDir);
74
84
  const m = /^---\n([\s\S]*?)\n---\n([\s\S]*)$/.exec(src);
75
85
  if (!m) return { ok: false, error: 'skill_edit: missing frontmatter' };
76
86
  let fm = m[1];
77
87
  fm = fm.replace(/version:\s*(\d+)/, (_, v) => `version: ${Number(v) + 1}`);
78
- fs.writeFileSync(file, `---\n${fm}\n---\n${args.body}${args.body.endsWith('\n') ? '' : '\n'}`);
88
+ skills.installSkill(args.name, `---\n${fm}\n---\n${args.body}${args.body.endsWith('\n') ? '' : '\n'}`, configDir);
79
89
  return { ok: true, name: args.name };
80
90
  },
81
91
  };
@@ -97,7 +97,11 @@ export async function exec(args, { configDir } = {}) {
97
97
  }
98
98
  } else {
99
99
  try {
100
- openIndex(configDir);
100
+ // Skip the full-b-tree integrity_check on the read hot path. recall is
101
+ // the most frequent reader and every CLI subcommand / agent worker is a
102
+ // fresh process, so the check (which scales with index size) would be
103
+ // paid on essentially every recall. It belongs in `doctor`, not here.
104
+ openIndex(configDir, { runIntegrityCheck: false });
101
105
  } catch (err) {
102
106
  return { ok: false, error: `recall: openIndex failed — ${err?.message || err}` };
103
107
  }