@arthovlabs/liber 0.0.0-stage → 0.5.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 (60) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +348 -2
  3. package/bin/liber +706 -0
  4. package/bin/liber-tui +244 -0
  5. package/bin/liber-tui.py +825 -0
  6. package/bin/liberd-entry +17 -0
  7. package/liber.yaml +84 -0
  8. package/package.json +43 -4
  9. package/resources/icons/appIcon.png +0 -0
  10. package/resources/icons/appIcon.svg +20 -0
  11. package/resources/icons/appIcon@512.png +0 -0
  12. package/resources/icons/atom-transparent-256.png +0 -0
  13. package/resources/icons/atom-transparent.png +0 -0
  14. package/resources/icons/atom-transparent.svg +19 -0
  15. package/resources/icons/make-atom.js +168 -0
  16. package/src/cli/ui.js +520 -0
  17. package/src/daemon/core/auth.js +217 -0
  18. package/src/daemon/core/automations.js +130 -0
  19. package/src/daemon/core/budget.js +145 -0
  20. package/src/daemon/core/circuit-breaker.js +31 -0
  21. package/src/daemon/core/config-apply.js +223 -0
  22. package/src/daemon/core/connectors.js +131 -0
  23. package/src/daemon/core/engine.js +1029 -0
  24. package/src/daemon/core/evidence.js +306 -0
  25. package/src/daemon/core/executor.js +10 -0
  26. package/src/daemon/core/grounding.js +420 -0
  27. package/src/daemon/core/injection.js +24 -0
  28. package/src/daemon/core/journal.js +81 -0
  29. package/src/daemon/core/liber-soul.js +157 -0
  30. package/src/daemon/core/lock.js +32 -0
  31. package/src/daemon/core/memory.js +106 -0
  32. package/src/daemon/core/model-switch.js +58 -0
  33. package/src/daemon/core/policy.js +102 -0
  34. package/src/daemon/core/providers.js +503 -0
  35. package/src/daemon/core/registry.js +356 -0
  36. package/src/daemon/core/secrets.js +35 -0
  37. package/src/daemon/core/skills.js +264 -0
  38. package/src/daemon/core/tools/browser-tool.js +280 -0
  39. package/src/daemon/core/tools/file-tools.js +277 -0
  40. package/src/daemon/core/tools/memory-tool.js +127 -0
  41. package/src/daemon/core/tools/skill-tool.js +44 -0
  42. package/src/daemon/core/tools/task-tool.js +41 -0
  43. package/src/daemon/core/tools/terminal-tool.js +449 -0
  44. package/src/daemon/core/tools/todo-tool.js +100 -0
  45. package/src/daemon/core/tools/web-tool.js +181 -0
  46. package/src/daemon/core/trace.js +40 -0
  47. package/src/daemon/core/validate.js +105 -0
  48. package/src/daemon/core/vault.js +158 -0
  49. package/src/daemon/core/verifier.js +98 -0
  50. package/src/daemon/core/voice.js +83 -0
  51. package/src/daemon/core/workspace.js +199 -0
  52. package/src/daemon/liberd.js +1343 -0
  53. package/src/daemon/llm/adapters/anthropic-adapter.js +190 -0
  54. package/src/daemon/llm/adapters/gemini-adapter.js +148 -0
  55. package/src/daemon/llm/adapters/ollama-adapter.js +74 -0
  56. package/src/daemon/llm/adapters/openai-adapter.js +148 -0
  57. package/src/daemon/llm/base.js +31 -0
  58. package/src/daemon/llm/manager.js +134 -0
  59. package/src/utils/config-loader.js +109 -0
  60. package/src/utils/logger.js +56 -0
@@ -0,0 +1,44 @@
1
+ // src/daemon/core/tools/skill-tool.js — progressive skill access for the model.
2
+ const skills = require('../skills');
3
+
4
+ const ACTIONS = ['list', 'view', 'search', 'create', 'update'];
5
+
6
+ function manageSkill({ action = 'list', name = null, query = null, description = null, body = null, triggers = [], always = false, overwrite = false } = {}) {
7
+ const act = String(action || 'list').toLowerCase();
8
+
9
+ if (!ACTIONS.includes(act)) {
10
+ return { error: `Unknown skill action '${action}'. Use: ${ACTIONS.join(', ')}.` };
11
+ }
12
+
13
+ if (act === 'list') {
14
+ return {
15
+ total: skills.scan().length,
16
+ skills: skills.scan().map(s => ({
17
+ name: s.name,
18
+ description: s.description,
19
+ always: s.always,
20
+ source: s.source,
21
+ files: s.files
22
+ })),
23
+ index: skills.indexLines()
24
+ };
25
+ }
26
+
27
+ if (act === 'view') {
28
+ if (!name) return { error: "Missing 'name' argument for action 'view'." };
29
+ return skills.view(name);
30
+ }
31
+
32
+ if (act === 'search') {
33
+ return skills.search(query);
34
+ }
35
+
36
+ // create / update — project-scoped writes (approval enforced by the caller)
37
+ if (!name) return { error: `Missing 'name' argument for action '${act}'.` };
38
+ if (!description) return { error: `Missing 'description' argument for action '${act}'.` };
39
+ if (!body) return { error: `Missing 'body' argument for action '${act}'.` };
40
+
41
+ return skills.writeSkill({ name, description, body, triggers, always }, act === 'update' || overwrite === true);
42
+ }
43
+
44
+ module.exports = { manageSkill, ACTIONS };
@@ -0,0 +1,41 @@
1
+ // src/daemon/core/tools/task-tool.js — subagent schemas (execution is handled by the engine).
2
+ const TASK_PARAMS = {
3
+ type: 'object',
4
+ properties: {
5
+ goal: { type: 'string', description: 'A self-contained objective for the subagent.' },
6
+ context: { type: 'string', description: 'Optional background: paths, constraints, what is already known.' }
7
+ },
8
+ required: ['goal']
9
+ };
10
+
11
+ const TASKS_PARAMS = {
12
+ type: 'object',
13
+ properties: {
14
+ tasks: {
15
+ type: 'array',
16
+ description: 'Up to 4 independent objectives to run in parallel (each gets its own context and receipts).'
17
+ }
18
+ },
19
+ required: ['tasks']
20
+ };
21
+
22
+ function stub() {
23
+ return { error: 'task tools are executed by the engine, not the registry.' };
24
+ }
25
+
26
+ const TASK_TOOLS = [
27
+ {
28
+ name: 'task',
29
+ description: 'Spawn one focused subagent with its own context and receipts. Use for self-contained research or verification that would flood the main context.',
30
+ parameters: TASK_PARAMS,
31
+ handler: stub
32
+ },
33
+ {
34
+ name: 'tasks',
35
+ description: 'Run up to 4 independent subagents in parallel and collect their reports. Use for comparisons or multi-file investigations.',
36
+ parameters: TASKS_PARAMS,
37
+ handler: stub
38
+ }
39
+ ];
40
+
41
+ module.exports = { TASK_TOOLS, stub };
@@ -0,0 +1,449 @@
1
+ // src/daemon/core/tools/terminal-tool.js — Liber Terminal Execution Engine
2
+ const { exec } = require('child_process');
3
+ const path = require('path');
4
+ const fs = require('fs');
5
+ const policy = require('../policy');
6
+
7
+ // Destructive patterns that strictly require Human-In-The-Loop approval
8
+ const DESTRUCTIVE_PATTERNS = [
9
+ /\brm\s+(-rf?|-fr?)/i,
10
+ /\bdel\s+(\/s|\/f|\/q)/i,
11
+ /\brmdir\s+\/s/i,
12
+ /\bformat\s+\S/i,
13
+ /\bdrop\s+(database|table)\b/i,
14
+ /\btruncate\s+table\b/i,
15
+ /\bchmod\s+-R\s+777\b/i,
16
+ /\bshutdown\b/i,
17
+ /\breboot\b/i,
18
+ /\bkill\s+-9\b/i,
19
+ /\bgit\s+push\s+--force\b/i,
20
+ /\bgit\s+reset\s+--hard\b/i,
21
+ /\bgit\s+push\b/i,
22
+ /\bdiskpart\b/i,
23
+ /\breg\s+(add|delete|import)\b/i,
24
+ /\bbcdedit\b/i,
25
+ /\bnetsh\b/i,
26
+ /\bcurl[^\n|]*\|\s*(sh|bash|powershell|pwsh)\b/i,
27
+ /\bwget[^\n|]*\|\s*(sh|bash)\b/i
28
+ ];
29
+
30
+ // Commands that change system/project state — blocked in read_only mode unless allowlisted
31
+ const MUTATION_PATTERNS = [
32
+ /\b(npm|pnpm|yarn|bun)\s+(install|add|remove|update|upgrade|init|publish|login|logout|link|unlink|ci)\b/i,
33
+ /\b(pip|pip3|python\s+-m\s+pip)\s+(install|uninstall|upgrade)\b/i,
34
+ /\b(cargo|go)\s+(install|add|update)\b/i,
35
+ /\bgit\s+(commit|push|checkout|reset|rebase|merge|branch|tag|clean|revert|cherry-pick|rm|add)\b/i,
36
+ /\b(rm|del|erase|rmdir|mv|move|ren|rename|cp|copy|scp|rsync)\b/i,
37
+ /\b(set-content|out-file|add-content|new-item|remove-item|move-item|copy-item|rename-item|set-item)\b/i,
38
+ /\b(chmod|chown|sudo)\b/i,
39
+ /\b(format\s+\S|diskpart|reg|bcdedit|netsh|schtasks|sc\s)/i,
40
+ />>\s*\S/,
41
+ /(^|[\s])>\s*\S/,
42
+ /\b(tee|clip)\b/i
43
+ ];
44
+
45
+ function isCommandSensitive(command) {
46
+ if (!command) return false;
47
+ return DESTRUCTIVE_PATTERNS.some(pattern => pattern.test(command));
48
+ }
49
+
50
+ // Characters that turn one command into several. A prefix match that runs past
51
+ // one of these is not a prefix match any more — it is a different command.
52
+ const CHAIN = /[;&|`<>]|\$\(/;
53
+
54
+ // Redirection and command substitution can write or execute whatever they
55
+ // produce, so no prefix entry can cover them: they are refused outright.
56
+ const REDIRECT_OR_SUBST = /(^|[^|])(>>?|<)|\$\(|`/;
57
+
58
+ /**
59
+ * Remove the redirects that write nothing: merging stderr into stdout (`2>&1`)
60
+ * and discarding to the null device. `npm test 2>&1 | tail -20` is a normal
61
+ * daily command; refusing it taught the model to avoid piping output, which is
62
+ * worse than the redirect itself. Real file redirects stay in and stay blocked.
63
+ */
64
+ function stripBenignRedirects(command) {
65
+ return String(command || '')
66
+ .replace(/\d*>&\d+/g, ' ')
67
+ .replace(/\d*>>?\s*(\/dev\/null|nul)\b/gi, ' ');
68
+ }
69
+
70
+ // Commands that only read. Listing a directory is not a privilege escalation,
71
+ // and a model that cannot even look at the tree burns its whole loop budget on
72
+ // denials (that is exactly what happened: three refused listings in one turn).
73
+ const READ_ONLY_PREFIXES = [
74
+ 'ls', 'dir', 'pwd', 'cd', 'cat', 'type', 'head', 'tail', 'wc', 'grep', 'rg',
75
+ 'stat', 'file', 'du', 'df', 'date', 'whoami', 'hostname', 'uname', 'echo', 'printenv',
76
+ 'git status', 'git log', 'git diff', 'git show', 'git rev-parse', 'git ls-files',
77
+ 'git remote -v', 'git remote show', 'git blame',
78
+ 'node --version', 'node -v', 'npm --version', 'npx --version', 'tsc --version',
79
+ 'python --version', 'py --version',
80
+ 'get-childitem', 'get-location', 'get-content', 'get-item', 'get-command',
81
+ 'test-path', 'resolve-path', 'select-object', 'select-string', 'where-object',
82
+ 'sort-object', 'measure-object', 'format-table', 'format-list', 'write-output',
83
+ 'out-string', 'get-process', 'get-date', 'get-host'
84
+ ];
85
+
86
+ /**
87
+ * Split a command into the stages a shell would actually run. Quotes are
88
+ * respected so `echo "a | b"` stays one stage.
89
+ */
90
+ function splitSegments(command) {
91
+ const out = [];
92
+ let cur = '';
93
+ let quote = null;
94
+ const s = String(command || '');
95
+ for (let i = 0; i < s.length; i++) {
96
+ const c = s[i];
97
+ if (quote) {
98
+ cur += c;
99
+ if (c === quote) quote = null;
100
+ continue;
101
+ }
102
+ if (c === '"' || c === "'") { quote = c; cur += c; continue; }
103
+ if (c === ';' || c === '\n') { out.push(cur); cur = ''; continue; }
104
+ if (c === '&' && s[i + 1] === '&') { out.push(cur); cur = ''; i++; continue; }
105
+ if (c === '|' && s[i + 1] === '|') { out.push(cur); cur = ''; i++; continue; }
106
+ if (c === '|' || c === '&') { out.push(cur); cur = ''; continue; }
107
+ cur += c;
108
+ }
109
+ out.push(cur);
110
+ return out.map(x => x.trim()).filter(Boolean);
111
+ }
112
+
113
+ const isReadOnly = seg => matchesAllowEntry(READ_ONLY_PREFIXES, String(seg).trim().toLowerCase());
114
+
115
+ /**
116
+ * Does `command` start with an allowlist entry and stay *inside* that entry?
117
+ * 'git status -s' matches 'git status'; 'git status; rm -rf /' does not.
118
+ */
119
+ function matchesAllowEntry(list, lower) {
120
+ for (const rawEntry of list) {
121
+ const entry = String(rawEntry || '').trim().toLowerCase();
122
+ if (!entry) continue;
123
+ if (!lower.startsWith(entry)) continue;
124
+ const rest = lower.slice(entry.length);
125
+ if (rest !== '' && !/^\s/.test(rest)) continue; // 'git statusX' is not 'git status'
126
+ if (CHAIN.test(rest)) continue; // 'git status; evil' is not 'git status'
127
+ return true;
128
+ }
129
+ return false;
130
+ }
131
+
132
+ /**
133
+ * Terminal policy: denylist always wins; allowlist mode requires a prefix;
134
+ * read_only blocks state-changing commands; prompt fails closed and reports
135
+ * that a human could approve (the engine escalates and re-dispatches).
136
+ */
137
+ /** What the caller may run instead of hunting for a permitted spelling. */
138
+ function allowedHint(allowlist) {
139
+ const list = [...allowlist.map(String), ...READ_ONLY_PREFIXES.slice(0, 12)];
140
+ return list.filter(Boolean).slice(0, 24);
141
+ }
142
+
143
+ function evaluatePolicy(command, opts = {}) {
144
+ const raw = String(command || '').trim();
145
+ // Judge the command without the redirects that cannot write; splitting on the
146
+ // raw string would also cut `2>&1` at the ampersand and invent a bad stage.
147
+ const cmd = stripBenignRedirects(raw);
148
+ const term = policy.terminalConfig();
149
+ const lower = raw.toLowerCase();
150
+ const segments = splitSegments(cmd);
151
+
152
+ for (const pattern of term.denylist) {
153
+ const p = String(pattern || '').trim().toLowerCase();
154
+ if (!p) continue;
155
+ const afterChain = CHAIN.test(cmd) && lower.includes(p);
156
+ if (lower.startsWith(p) || afterChain || segments.some(seg => seg.toLowerCase().startsWith(p))) {
157
+ return {
158
+ denied: true,
159
+ reason: `Command blocked by policy.terminal.denylist ('${pattern}'). A denylist entry cannot be approved away.`
160
+ };
161
+ }
162
+ }
163
+
164
+ // Human approval overrides the *mode* gate (prompt/allowlist/read_only) only,
165
+ // never the denylist above.
166
+ if (opts.approved === true) return { denied: false, approved: true };
167
+
168
+ if (term.mode === 'open') return { denied: false };
169
+
170
+ const redirect = REDIRECT_OR_SUBST.test(cmd);
171
+ const hint = allowedHint(term.allowlist);
172
+
173
+ // A stage is covered if an allowlist entry covers it, or if it only reads.
174
+ const coveredSegments = redirect
175
+ ? [] // redirection invalidates every stage: it writes or substitutes
176
+ : segments.filter(seg => matchesAllowEntry(term.allowlist, seg.toLowerCase()) || isReadOnly(seg));
177
+ const uncovered = redirect ? [cmd] : segments.filter(seg => !matchesAllowEntry(term.allowlist, seg.toLowerCase()) && !isReadOnly(seg));
178
+
179
+ if (term.mode === 'allowlist') {
180
+ if (redirect) {
181
+ return {
182
+ denied: true,
183
+ reason: "Blocked: redirection (>, >>, <) and command substitution ($( ), backticks) are never covered by a prefix allowlist. Run the command without them, or ask the operator to widen policy.terminal.mode.",
184
+ allowed_prefixes: hint
185
+ };
186
+ }
187
+ if (uncovered.length) {
188
+ return {
189
+ denied: true,
190
+ reason: `Command stage not allowed (mode 'allowlist'): '${uncovered[0].slice(0, 80)}'. Read-only inventory commands are always allowed (ls, dir, Get-ChildItem, cat, git log, …); pipelines are judged stage by stage. Allowed prefixes: ${hint.slice(0, 12).join(', ')}.`,
191
+ allowed_prefixes: hint
192
+ };
193
+ }
194
+ return { denied: false };
195
+ }
196
+
197
+ if (term.mode === 'prompt') {
198
+ // Fail closed, and say a human could approve: the engine escalates on
199
+ // needs_approval and re-dispatches with meta.approved.
200
+ if (!redirect && coveredSegments.length === segments.length) return { denied: false };
201
+ return {
202
+ denied: true,
203
+ needs_approval: true,
204
+ reason: "Terminal mode 'prompt' needs human approval for commands that change state or redirect output. Read-only listing/reading is allowed without asking.",
205
+ allowed_prefixes: hint
206
+ };
207
+ }
208
+
209
+ if (term.mode === 'read_only' && MUTATION_PATTERNS.some(pattern => pattern.test(cmd))) {
210
+ const allowlisted = !redirect && segments.length > 0 &&
211
+ segments.every(seg => matchesAllowEntry(term.allowlist, seg.toLowerCase()));
212
+ if (!allowlisted) {
213
+ return {
214
+ denied: true,
215
+ reason: 'Command changes project/system state, blocked by read-only terminal policy. Allowlist it in liber.yaml or ask the operator to switch policy.terminal.mode.',
216
+ allowed_prefixes: hint
217
+ };
218
+ }
219
+ }
220
+
221
+ return { denied: false };
222
+ }
223
+
224
+ /**
225
+ * Sandbox profile.
226
+ *
227
+ * This is not a container. On Windows it runs the command in PowerShell
228
+ * Constrained Language Mode, which refuses the .NET/COM surface that most
229
+ * script-based escapes need ([System.IO.File]::WriteAllText, Add-Type,
230
+ * reflection), and it hands the child a minimal environment so provider keys and
231
+ * tokens are not inherited. With the prefix policy and a fixed cwd, that removes
232
+ * the easiest ways a command walks out of the workspace.
233
+ */
234
+ function sandboxPolicy() {
235
+ const term = policy.terminalConfig();
236
+ const requested = term.sandbox !== false;
237
+ const supported = process.platform === 'win32';
238
+ return { on: requested && supported, requested, supported };
239
+ }
240
+
241
+ /** Minimal environment: nothing secret is inherited by the child. */
242
+ function sandboxEnv() {
243
+ const keep = ['PATH', 'PATHEXT', 'SYSTEMROOT', 'WINDIR', 'COMSPEC', 'TEMP', 'TMP',
244
+ 'USERPROFILE', 'HOMEDRIVE', 'HOMEPATH', 'APPDATA', 'LOCALAPPDATA',
245
+ 'PROCESSOR_ARCHITECTURE', 'NUMBER_OF_PROCESSORS'];
246
+ const env = {};
247
+ for (const k of keep) if (process.env[k] !== undefined) env[k] = process.env[k];
248
+ return env;
249
+ }
250
+
251
+ function auditTerminal(entry) {
252
+ try {
253
+ const dir = path.resolve(process.cwd(), '.liber');
254
+ fs.mkdirSync(dir, { recursive: true });
255
+ fs.appendFileSync(path.join(dir, 'terminal-audit.log'),
256
+ new Date().toISOString() + ' ' + JSON.stringify(entry) + '\n', 'utf8');
257
+ } catch { /* auditing must never block a command */ }
258
+ }
259
+
260
+ /**
261
+ * A real container, for when the language-mode sandbox is not enough.
262
+ *
263
+ * The command runs inside Docker with: no network, a read-only root filesystem,
264
+ * a small tmpfs for /tmp, dropped capabilities, no-new-privileges, and a memory
265
+ * and pid cap. The workspace is the only host path mounted, so the rest of the
266
+ * machine — including the browser profile and SSH keys — is not reachable.
267
+ */
268
+ function containerPolicy() {
269
+ const term = policy.terminalConfig();
270
+ const requested = term.backend === 'docker';
271
+ return {
272
+ on: requested && containerAvailable(),
273
+ requested,
274
+ image: term.image || 'alpine:latest',
275
+ network: term.allow_network === true,
276
+ memory: term.memory || '512m',
277
+ pids: Number(term.pids) || 128
278
+ };
279
+ }
280
+
281
+ let _dockerCache = null;
282
+ /** Is a Docker daemon reachable? Cached for a minute; the check costs ~50ms. */
283
+ function containerAvailable(force = false) {
284
+ if (!force && _dockerCache && Date.now() - _dockerCache.at < 60_000) return _dockerCache.ok;
285
+ let ok = false;
286
+ try {
287
+ const out = require('child_process').execFileSync('docker', ['version', '--format', '{{.Server.Version}}'],
288
+ { timeout: 8000, windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'] });
289
+ ok = String(out).trim().length > 0;
290
+ } catch { ok = false; }
291
+ _dockerCache = { ok, at: Date.now() };
292
+ return ok;
293
+ }
294
+
295
+ function runContainer(command, { cwd, timeout, profile }) {
296
+ const { execFile } = require('child_process');
297
+ const mount = `${path.resolve(process.cwd())}:/work:rw`;
298
+ const args = [
299
+ 'run', '--rm', '--interactive',
300
+ ...(profile.network ? ['--network', 'bridge'] : ['--network', 'none']),
301
+ '--read-only',
302
+ '--tmpfs', '/tmp:rw,noexec,nosuid,size=64m',
303
+ '--memory', profile.memory,
304
+ '--pids-limit', String(profile.pids),
305
+ '--cap-drop', 'ALL',
306
+ '--security-opt', 'no-new-privileges',
307
+ '-v', mount,
308
+ '-w', '/work',
309
+ profile.image,
310
+ 'sh', '-lc', command
311
+ ];
312
+ return new Promise((resolve) => {
313
+ execFile('docker', args, { timeout, maxBuffer: 10 * 1024 * 1024, windowsHide: true },
314
+ (error, stdout, stderr) => resolve({ error, stdout: String(stdout || ''), stderr: String(stderr || '') }));
315
+ });
316
+ }
317
+
318
+ /**
319
+ * Run under Constrained Language Mode via a throwaway script file. A file avoids
320
+ * every quoting trap that inline -Command has with quotes and $ signs.
321
+ */
322
+ function runSandboxed(command, { cwd, timeout }) {
323
+ const { execFile } = require('child_process');
324
+ const tmpDir = path.resolve(process.cwd(), '.liber', 'tmp');
325
+ fs.mkdirSync(tmpDir, { recursive: true });
326
+ const script = path.join(tmpDir, 'run-' + Date.now() + '-' + Math.random().toString(36).slice(2, 6) + '.ps1');
327
+ fs.writeFileSync(script,
328
+ "$ExecutionContext.SessionState.LanguageMode = 'ConstrainedLanguage'\n" +
329
+ "$ErrorActionPreference = 'Continue'\n" +
330
+ command + '\n', 'utf8');
331
+
332
+ return new Promise((resolve) => {
333
+ execFile('powershell.exe',
334
+ ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', script],
335
+ { cwd, timeout, maxBuffer: 10 * 1024 * 1024, windowsHide: true, env: sandboxEnv() },
336
+ (error, stdout, stderr) => {
337
+ try { fs.unlinkSync(script); } catch { }
338
+ resolve({ error, stdout: String(stdout || ''), stderr: String(stderr || '') });
339
+ });
340
+ });
341
+ }
342
+
343
+ async function runTerminal({ command, timeout = 45000, cwd = process.cwd() }, meta = {}) {
344
+ if (!command || typeof command !== 'string') {
345
+ return { error: "Missing or invalid command argument." };
346
+ }
347
+
348
+ const decision = evaluatePolicy(command, { approved: meta && meta.approved === true });
349
+ if (decision.denied) {
350
+ // Keep the escalation signal: a consumer (registry/engine) needs to know the
351
+ // difference between "policy says no" and "policy says ask a human".
352
+ return {
353
+ error: decision.reason,
354
+ policy_denied: true,
355
+ needs_approval: decision.needs_approval === true,
356
+ allowed_prefixes: decision.allowed_prefixes || null,
357
+ command
358
+ };
359
+ }
360
+
361
+ const startTime = Date.now();
362
+ const isWindows = process.platform === 'win32';
363
+ const shellOption = isWindows ? 'powershell.exe' : '/bin/bash';
364
+ const sandbox = sandboxPolicy();
365
+ const container = containerPolicy();
366
+ const backend = container.on ? 'docker' : (sandbox.on ? 'constrained-language' : 'none');
367
+ auditTerminal({ decision: 'allow', command, cwd, sandbox: backend });
368
+
369
+ const finish = (error, stdout, stderr) => {
370
+ const durationMs = Date.now() - startTime;
371
+ const outStr = String(stdout || '').trim();
372
+ const errStr = String(stderr || '').trim();
373
+ const MAX_OUTPUT = 15000;
374
+ let truncated = false;
375
+ let finalOut = outStr;
376
+ if (finalOut.length > MAX_OUTPUT) {
377
+ finalOut = finalOut.slice(0, MAX_OUTPUT) + `\n... [TRUNCATED: Output exceeded ${MAX_OUTPUT} characters]`;
378
+ truncated = true;
379
+ }
380
+ const exitCode = error ? (typeof error.code === 'number' ? error.code : 1) : 0;
381
+ auditTerminal({ decision: 'run', command, exit_code: exitCode, duration_ms: durationMs, sandbox: backend });
382
+ if (error) {
383
+ return {
384
+ command,
385
+ exit_code: exitCode,
386
+ output: finalOut,
387
+ error: errStr || error.message,
388
+ duration_ms: durationMs,
389
+ truncated,
390
+ sandboxed: backend !== 'none',
391
+ backend
392
+ };
393
+ }
394
+ // stderr is not noise: a blocked call explains itself there and nowhere else
395
+ const withErr = errStr
396
+ ? (finalOut ? finalOut + "\n[stderr] " + errStr : "[stderr] " + errStr)
397
+ : finalOut;
398
+ return {
399
+ command,
400
+ exit_code: 0,
401
+ output: withErr || '(command succeeded with no output)',
402
+ duration_ms: durationMs,
403
+ truncated,
404
+ sandboxed: backend !== 'none',
405
+ backend
406
+ };
407
+ };
408
+
409
+ if (container.on) {
410
+ const { error, stdout, stderr } = await runContainer(command, {
411
+ cwd,
412
+ timeout: parseInt(timeout, 10) || 45000,
413
+ profile: container
414
+ });
415
+ return finish(error, stdout, stderr);
416
+ }
417
+
418
+ if (sandbox.on) {
419
+ const { error, stdout, stderr } = await runSandboxed(command, {
420
+ cwd,
421
+ timeout: parseInt(timeout, 10) || 45000
422
+ });
423
+ return finish(error, stdout, stderr);
424
+ }
425
+
426
+ return new Promise((resolve) => {
427
+ exec(
428
+ command,
429
+ {
430
+ cwd,
431
+ timeout: parseInt(timeout, 10) || 45000,
432
+ maxBuffer: 10 * 1024 * 1024, // 10MB
433
+ shell: shellOption,
434
+ windowsHide: true
435
+ },
436
+ (error, stdout, stderr) => resolve(finish(error, stdout, stderr))
437
+ );
438
+ });
439
+ }
440
+
441
+ module.exports = {
442
+ runTerminal,
443
+ isCommandSensitive,
444
+ evaluatePolicy,
445
+ sandboxPolicy,
446
+ sandboxEnv,
447
+ containerPolicy,
448
+ containerAvailable
449
+ };
@@ -0,0 +1,100 @@
1
+ // src/daemon/core/tools/todo-tool.js — Liber Task Planning & Tracking Tool
2
+ // Harness-style: the whole list can be rewritten in one call, and each item
3
+ // carries a status (pending | in_progress | completed).
4
+ const STATUSES = new Set(['pending', 'in_progress', 'completed']);
5
+
6
+ class TodoManager {
7
+ constructor() {
8
+ this.tasks = [];
9
+ this.counter = 1;
10
+ }
11
+
12
+ normaliseStatus(status) {
13
+ const s = String(status || '').toLowerCase().replace(/[-\s]/g, '_');
14
+ if (s === 'done' || s === 'complete') return 'completed';
15
+ if (s === 'active' || s === 'current' || s === 'inprogress') return 'in_progress';
16
+ return STATUSES.has(s) ? s : 'pending';
17
+ }
18
+
19
+ // Replace the list wholesale, keeping ids stable for unchanged items.
20
+ set(items) {
21
+ if (!Array.isArray(items)) return { error: "action 'set' needs an items array." };
22
+ const next = items.slice(0, 30).map((raw, i) => {
23
+ const item = typeof raw === 'string' ? { task: raw } : (raw || {});
24
+ const task = String(item.task || item.text || '').trim();
25
+ const prior = this.tasks[i];
26
+ const same = prior && prior.task === task ? prior : null;
27
+ return {
28
+ id: same ? same.id : this.counter++,
29
+ task,
30
+ status: this.normaliseStatus(item.status || (same ? same.status : 'pending')),
31
+ created_at: same ? same.created_at : new Date().toISOString()
32
+ };
33
+ }).filter(t => t.task);
34
+ this.tasks = next;
35
+ return { success: true, message: `Plan updated (${next.length} item${next.length === 1 ? '' : 's'}).`, tasks: this.tasks };
36
+ }
37
+
38
+ execute({ action = "list", task = null, id = null, status = null, items = null }) {
39
+ const act = (action || "list").toLowerCase();
40
+
41
+ if (act === "set" || act === "write" || act === "plan") return this.set(items);
42
+
43
+ if (act === "add") {
44
+ if (!task) return { error: "Missing task description for action 'add'." };
45
+ const item = {
46
+ id: this.counter++,
47
+ task: String(task),
48
+ status: this.normaliseStatus(status),
49
+ created_at: new Date().toISOString()
50
+ };
51
+ this.tasks.push(item);
52
+ return { success: true, message: `Task #${item.id} added.`, tasks: this.tasks };
53
+ }
54
+
55
+ if (act === "update" || act === "status") {
56
+ if (!id) return { error: "Missing task id to update." };
57
+ const item = this.tasks.find(t => t.id === parseInt(id, 10));
58
+ if (!item) return { error: `Task #${id} not found.` };
59
+ item.status = this.normaliseStatus(status);
60
+ if (item.status === 'completed') item.completed_at = new Date().toISOString();
61
+ return { success: true, message: `Task #${id} → ${item.status}.`, tasks: this.tasks };
62
+ }
63
+
64
+ if (act === "complete" || act === "done") {
65
+ if (!id) return { error: "Missing task id to complete." };
66
+ const item = this.tasks.find(t => t.id === parseInt(id, 10));
67
+ if (!item) return { error: `Task #${id} not found.` };
68
+ item.status = "completed";
69
+ item.completed_at = new Date().toISOString();
70
+ return { success: true, message: `Task #${id} marked as completed.`, tasks: this.tasks };
71
+ }
72
+
73
+ if (act === "delete" || act === "remove") {
74
+ if (!id) return { error: "Missing task id to delete." };
75
+ const idx = this.tasks.findIndex(t => t.id === parseInt(id, 10));
76
+ if (idx === -1) return { error: `Task #${id} not found.` };
77
+ const removed = this.tasks.splice(idx, 1)[0];
78
+ return { success: true, message: `Task #${id} deleted.`, removed, tasks: this.tasks };
79
+ }
80
+
81
+ if (act === "clear") {
82
+ this.tasks = [];
83
+ return { success: true, message: "All tasks cleared.", tasks: this.tasks };
84
+ }
85
+
86
+ // Default: list
87
+ return {
88
+ total: this.tasks.length,
89
+ pending: this.tasks.filter(t => t.status === "pending").length,
90
+ in_progress: this.tasks.filter(t => t.status === "in_progress").length,
91
+ completed: this.tasks.filter(t => t.status === "completed").length,
92
+ tasks: this.tasks
93
+ };
94
+ }
95
+ }
96
+
97
+ const manager = new TodoManager();
98
+ module.exports = {
99
+ manageTodo: (args) => manager.execute(args)
100
+ };