@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.
- package/LICENSE +21 -0
- package/README.md +348 -2
- package/bin/liber +706 -0
- package/bin/liber-tui +244 -0
- package/bin/liber-tui.py +825 -0
- package/bin/liberd-entry +17 -0
- package/liber.yaml +84 -0
- package/package.json +43 -4
- package/resources/icons/appIcon.png +0 -0
- package/resources/icons/appIcon.svg +20 -0
- package/resources/icons/appIcon@512.png +0 -0
- package/resources/icons/atom-transparent-256.png +0 -0
- package/resources/icons/atom-transparent.png +0 -0
- package/resources/icons/atom-transparent.svg +19 -0
- package/resources/icons/make-atom.js +168 -0
- package/src/cli/ui.js +520 -0
- package/src/daemon/core/auth.js +217 -0
- package/src/daemon/core/automations.js +130 -0
- package/src/daemon/core/budget.js +145 -0
- package/src/daemon/core/circuit-breaker.js +31 -0
- package/src/daemon/core/config-apply.js +223 -0
- package/src/daemon/core/connectors.js +131 -0
- package/src/daemon/core/engine.js +1029 -0
- package/src/daemon/core/evidence.js +306 -0
- package/src/daemon/core/executor.js +10 -0
- package/src/daemon/core/grounding.js +420 -0
- package/src/daemon/core/injection.js +24 -0
- package/src/daemon/core/journal.js +81 -0
- package/src/daemon/core/liber-soul.js +157 -0
- package/src/daemon/core/lock.js +32 -0
- package/src/daemon/core/memory.js +106 -0
- package/src/daemon/core/model-switch.js +58 -0
- package/src/daemon/core/policy.js +102 -0
- package/src/daemon/core/providers.js +503 -0
- package/src/daemon/core/registry.js +356 -0
- package/src/daemon/core/secrets.js +35 -0
- package/src/daemon/core/skills.js +264 -0
- package/src/daemon/core/tools/browser-tool.js +280 -0
- package/src/daemon/core/tools/file-tools.js +277 -0
- package/src/daemon/core/tools/memory-tool.js +127 -0
- package/src/daemon/core/tools/skill-tool.js +44 -0
- package/src/daemon/core/tools/task-tool.js +41 -0
- package/src/daemon/core/tools/terminal-tool.js +449 -0
- package/src/daemon/core/tools/todo-tool.js +100 -0
- package/src/daemon/core/tools/web-tool.js +181 -0
- package/src/daemon/core/trace.js +40 -0
- package/src/daemon/core/validate.js +105 -0
- package/src/daemon/core/vault.js +158 -0
- package/src/daemon/core/verifier.js +98 -0
- package/src/daemon/core/voice.js +83 -0
- package/src/daemon/core/workspace.js +199 -0
- package/src/daemon/liberd.js +1343 -0
- package/src/daemon/llm/adapters/anthropic-adapter.js +190 -0
- package/src/daemon/llm/adapters/gemini-adapter.js +148 -0
- package/src/daemon/llm/adapters/ollama-adapter.js +74 -0
- package/src/daemon/llm/adapters/openai-adapter.js +148 -0
- package/src/daemon/llm/base.js +31 -0
- package/src/daemon/llm/manager.js +134 -0
- package/src/utils/config-loader.js +109 -0
- 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
|
+
};
|