@arthovlabs/liber 0.5.6 → 0.6.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/README.md CHANGED
@@ -373,6 +373,52 @@ Every decision and every run is appended to `.liber/terminal-audit.log`, and
373
373
  Docker Desktop must be running for the container backend; when it is not, LiBER
374
374
  falls back to Constrained Language Mode and says so instead of pretending.
375
375
 
376
+ Each line records `decision` (`allow`, `run` or **`deny`**), the command, the exit
377
+ code and duration, the sandbox in force, and **who asked** — `agentId`, `client`
378
+ and `turnId`. A refusal is the event an operator most needs to see, so it is
379
+ logged with its reason instead of being swallowed. Files rotate at 5 MB
380
+ (current → `.1`) so the trail stays readable.
381
+
382
+ ### Stopping a turn that is already running
383
+
384
+ `POST /stop {"agentId":"dev-bot"}` is the kill switch. It marks the agent so the
385
+ engine stops **before its next model call**, and it kills any command already
386
+ running for it — on Windows the whole process tree, not just the shell wrapper.
387
+ The turn ends with `stopped: true` and an honest "Stopped by you after N
388
+ iterations. Nothing is claimed as complete." The desktop shell's ■ button calls
389
+ this endpoint as well as aborting its own stream, so a turn started by a script is
390
+ just as stoppable as one started by hand.
391
+
392
+ ## 🔗 Sharing a session (read-only links)
393
+
394
+ Right-click a session in the sidebar → **Share link**. LiBER mints a 128-bit
395
+ capability URL:
396
+
397
+ ```
398
+ POST /sessions/share {"key":"dev-bot#<session>","ttl_hours":168}
399
+ GET /shared/<id> no bearer token; the id is the credential
400
+ GET /sessions/share list links, expiry and view count
401
+ DELETE /sessions/share?id=<id> revoke immediately
402
+ ```
403
+
404
+ What a link can and cannot do:
405
+
406
+ - **read only** — there is no route from a share id to `/chat`, `/config` or any
407
+ tool, so a leaked link cannot cost you tokens or change anything;
408
+ - **sanitized** — the payload is built server-side: tool observations
409
+ (`OBSERVATION [E…]`), denials and internal retry prompts are dropped,
410
+ credentials are redacted, the account path becomes `~`, and long messages are
411
+ truncated. Rendering-side hiding is not relied on;
412
+ - **expiring** — seven days by default, checked at read time, and revoked links
413
+ answer 404 rather than a cached copy.
414
+
415
+ ### Kill switch vs. share link
416
+
417
+ A share link is deliberately *not* a token: it cannot be replayed into the API.
418
+ The only thing it unlocks is `GET /shared/<id>`, once per request, while
419
+ `expires_at` is in the future and `revoked` is false.
420
+
421
+
376
422
  ---
377
423
 
378
424
  ## 🔑 Clients and tokens
package/liber.yaml CHANGED
@@ -70,7 +70,7 @@ policy:
70
70
  backend: "docker"
71
71
  image: "alpine:latest"
72
72
  allow_network: false
73
- mode: "allowlist" # read_only: block state-changing commands · allowlist: only the list · prompt: HITL only
73
+ mode: "prompt" # ask the human for state-changing commands; read-only runs freely
74
74
  allowlist: ["npm test", "node --test", "git status", "git diff", "git add", "git commit", "git push"]
75
75
  denylist: []
76
76
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arthovlabs/liber",
3
- "version": "0.5.6",
3
+ "version": "0.6.0",
4
4
  "description": "LiBER — autonomous agent orchestrator with a machine-enforced grounding gate and a skill library",
5
5
  "bin": {
6
6
  "liber": "bin/liber",
@@ -21,7 +21,7 @@ const DEFAULT_LIMIT = 240; // requests per minute per client
21
21
  function scopeFor(method, endpoint) {
22
22
  if (endpoint === '/shutdown' || endpoint.startsWith('/clients')) return 'admin';
23
23
  if (endpoint.startsWith('/config') || endpoint === '/vault/migrate') return method === 'GET' ? 'read' : 'admin';
24
- if (endpoint === '/chat' || endpoint === '/chat/stream' || endpoint === '/model/switch' || endpoint.endsWith('/run')) return 'chat';
24
+ if (endpoint === '/chat' || endpoint === '/chat/stream' || endpoint === '/model/switch' || endpoint === '/stop' || endpoint === '/sessions/share' || endpoint.endsWith('/run')) return 'chat';
25
25
  if (endpoint.startsWith('/hitl') || endpoint.startsWith('/memory') || endpoint.startsWith('/budget') || endpoint.startsWith('/sessions')) return 'chat';
26
26
  if (method === 'GET' || method === 'HEAD') return 'read';
27
27
  return 'tools';
@@ -123,6 +123,13 @@ function can(client, scope) {
123
123
  return list.includes('*') || list.includes(scope);
124
124
  }
125
125
 
126
+ function rotateAudit(file, maxBytes = 5 * 1024 * 1024) {
127
+ try {
128
+ if (require('fs').statSync(file).size < maxBytes) return;
129
+ require('fs').renameSync(file, file + '.1');
130
+ } catch { /* first write, or a rotation race */ }
131
+ }
132
+
126
133
  function audit(entry) {
127
134
  try {
128
135
  fs.mkdirSync(DIR(), { recursive: true });
@@ -10,6 +10,7 @@ const budget = require('./budget');
10
10
  const verifier = require('./verifier');
11
11
  const journal = require('./journal');
12
12
  const injection = require('./injection');
13
+ const stops = require('./stops');
13
14
 
14
15
  const DEFAULT_GROUNDING = {
15
16
  enabled: true,
@@ -398,14 +399,19 @@ class LiberEngine {
398
399
  depth = 0,
399
400
  allowedTools = null,
400
401
  resumeState = null,
401
- shouldStop = null
402
+ shouldStop = null,
403
+ client = null
402
404
  }) {
403
405
  const resumed = !!resumeState;
404
406
  // The client can hang up mid-turn (stop button / closed window). Checked
405
407
  // between iterations so we stop spending tokens as soon as it does.
406
408
  const stopRequested = () => {
407
- try { return typeof shouldStop === 'function' && shouldStop() === true; } catch { return false; }
409
+ try { if (typeof shouldStop === 'function' && shouldStop() === true) return true; } catch { /* socket may be gone */ }
410
+ // A /stop request is honoured even when no socket is watching: that is the
411
+ // whole point of a kill switch.
412
+ return stops.isRequested(agentId);
408
413
  };
414
+ stops.clear(agentId);
409
415
  let stopped = false;
410
416
  // Session history lives under its own key so a new session starts empty;
411
417
  // tracing, evidence and the breaker stay keyed to the real agent.
@@ -861,7 +867,7 @@ class LiberEngine {
861
867
  seq: ++subSeq
862
868
  });
863
869
  } else {
864
- result = await registry.execute(call.name, call.args);
870
+ result = await registry.execute(call.name, call.args, { agentId, client: client || null, turnId: ledger.turnId || null });
865
871
  }
866
872
 
867
873
  // A tool can refuse for policy reasons and report that a human may
@@ -875,7 +881,7 @@ class LiberEngine {
875
881
  const approved = await waitForHitl(agentId, call.name, call.args);
876
882
  if (approved) {
877
883
  onEvent('hitl_approved', { tool: call.name, retried: true });
878
- result = await registry.execute(call.name, call.args, { approved: true });
884
+ result = await registry.execute(call.name, call.args, { approved: true, agentId, client: client || null, turnId: ledger.turnId || null });
879
885
  } else {
880
886
  result = { error: `DENIED: approval was not granted. Tool '${call.name}' did not execute.`, policy_denied: true };
881
887
  }
@@ -0,0 +1,47 @@
1
+ // src/daemon/core/http-guard.js — two decisions every request makes before routing.
2
+ //
3
+ // Kept pure and small so both can be tested without a socket: which browser origin
4
+ // is allowed to read a response, and how many requests an unauthenticated address
5
+ // may make. Neither is a substitute for the bearer token; they shrink what a page
6
+ // the user happens to visit, or a local probe, can do before it gets that far.
7
+
8
+ const LOOPBACK = /^https?:\/\/(127\.0\.0\.1|localhost|\[::1\])(:\d+)?$/;
9
+
10
+ /** Returns the origin to reflect, or null when it must not be echoed back. */
11
+ function allowOrigin(origin) {
12
+ const value = String(origin || '');
13
+ return LOOPBACK.test(value) ? value : null;
14
+ }
15
+
16
+ /**
17
+ * A per-address ceiling for routes that answer without a token.
18
+ * 240/min is far above what a shell does and far below what a probe wants.
19
+ */
20
+ function createThrottle({ limit, windowMs = 60000 } = {}) {
21
+ const max = Number.isFinite(limit) ? limit : (Number(process.env.LIBER_UNAUTH_RPM) || 240);
22
+ const buckets = new Map();
23
+
24
+ return {
25
+ limit: max,
26
+ windowMs,
27
+ check(ip, now = Date.now()) {
28
+ const key = String(ip || 'local');
29
+ let bucket = buckets.get(key);
30
+ if (!bucket || now >= bucket.reset) {
31
+ bucket = { count: 0, reset: now + windowMs };
32
+ buckets.set(key, bucket);
33
+ }
34
+ bucket.count += 1;
35
+ const allowed = bucket.count <= max;
36
+ return {
37
+ allowed,
38
+ remaining: Math.max(0, max - bucket.count),
39
+ retryAfterMs: allowed ? 0 : Math.max(0, bucket.reset - now)
40
+ };
41
+ },
42
+ reset() { buckets.clear(); },
43
+ get size() { return buckets.size; }
44
+ };
45
+ }
46
+
47
+ module.exports = { allowOrigin, createThrottle, LOOPBACK };
@@ -294,6 +294,42 @@ function status() {
294
294
  return { providers: rows, custom: customs };
295
295
  }
296
296
 
297
+ /**
298
+ * Fill provider/endpoint when liber.yaml leaves them null.
299
+ *
300
+ * A fresh install ships `provider: null` and `endpoint: null` on purpose: the
301
+ * only thing the user is expected to hand over is a key. Nothing used to turn
302
+ * that key back into a base URL, so the adapter built `null/chat/completions`
303
+ * and every turn died — either as a URL parse error or, when a placeholder key
304
+ * from .env.example was sent instead, as HTTP 401. The endpoint has to come from
305
+ * whichever provider is actually configured.
306
+ */
307
+ function engineDefaults(engine = {}) {
308
+ const cfg = { ...engine };
309
+ const missingProvider = !cfg.provider || cfg.provider === 'null';
310
+ const missingEndpoint = !cfg.endpoint || cfg.endpoint === 'null';
311
+ if (!missingProvider && !missingEndpoint) return cfg;
312
+
313
+ const { providers, custom } = status();
314
+ // Rank what is configured: an explicit custom endpoint first, then real API
315
+ // keys, and only then a local server. A machine that happens to run Ollama
316
+ // exports OLLAMA_HOST, which would otherwise hijack the endpoint away from a
317
+ // perfectly good cloud key.
318
+ const rank = p => (p.kind === 'custom' ? 0 : p.kind === 'api' ? 1 : 2);
319
+ const pick = [...custom, ...providers]
320
+ .filter(p => p.configured && p.base && p.adapter)
321
+ .sort((a, b) => rank(a) - rank(b))[0];
322
+ if (!pick) return cfg;
323
+
324
+ if (missingProvider) cfg.provider = pick.adapter;
325
+ if (missingEndpoint) cfg.endpoint = String(pick.base).replace(/\/+$/, '');
326
+ if (!cfg.apiKey) {
327
+ const key = resolve(pick.id, pick.base);
328
+ if (key) cfg.apiKey = key;
329
+ }
330
+ return cfg;
331
+ }
332
+
297
333
  function imports() {
298
334
  return IMPORTS.map(i => {
299
335
  let present = false, when = null;
@@ -499,5 +535,6 @@ module.exports = {
499
535
  status, imports, importFrom,
500
536
  setKey, setToken, clear, setCustom,
501
537
  resolve, resolveStored, test, deviceStart, devicePoll,
538
+ engineDefaults,
502
539
  storePath: () => STORE()
503
540
  };
@@ -0,0 +1,151 @@
1
+ // src/daemon/core/shares.js — read-only session links (capability URLs).
2
+ //
3
+ // A share is a 128-bit id that maps to one session key. Whoever holds the id can
4
+ // read that session; nobody can write through it. The daemon binds to 127.0.0.1,
5
+ // so the link is only reachable from this machine (or a tunnel the operator
6
+ // chooses). Expiry and revoke are first-class, not an afterthought.
7
+ const crypto = require('crypto');
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+ const secrets = require('./secrets');
11
+
12
+ const MAX_MESSAGES = 400;
13
+ const MAX_CHARS_PER_MESSAGE = 8000;
14
+ const DEFAULT_TTL_HOURS = 24 * 7;
15
+
16
+ function dir() {
17
+ return path.resolve(process.cwd(), '.liber');
18
+ }
19
+ function file() {
20
+ return process.env.LIBER_SHARES_FILE || path.join(dir(), 'shares.json');
21
+ }
22
+
23
+ function read() {
24
+ try {
25
+ const raw = JSON.parse(fs.readFileSync(file(), 'utf8'));
26
+ return { version: 1, shares: raw && typeof raw.shares === 'object' ? raw.shares : {} };
27
+ } catch {
28
+ return { version: 1, shares: {} };
29
+ }
30
+ }
31
+
32
+ function write(state) {
33
+ const target = file();
34
+ fs.mkdirSync(path.dirname(target), { recursive: true });
35
+ const tmp = target + '.tmp';
36
+ fs.writeFileSync(tmp, JSON.stringify(state, null, 2), 'utf8');
37
+ try { fs.chmodSync(tmp, 0o600); } catch { /* Windows has no POSIX mode; its ACL applies */ }
38
+ fs.renameSync(tmp, target);
39
+ }
40
+
41
+ function newId() {
42
+ return crypto.randomBytes(16).toString('hex'); // 32 hex chars = 128 bits
43
+ }
44
+
45
+ function create({ key, title = null, ttlHours = DEFAULT_TTL_HOURS } = {}) {
46
+ const sessionKey = String(key || '').trim();
47
+ if (!sessionKey) throw new Error('A session key is required to share.');
48
+
49
+ const hours = Number(ttlHours);
50
+ const ttl = Number.isFinite(hours) && hours > 0 ? Math.min(hours, 24 * 365) : DEFAULT_TTL_HOURS;
51
+
52
+ const state = read();
53
+ const id = newId();
54
+ const now = Date.now();
55
+ const record = {
56
+ id,
57
+ key: sessionKey,
58
+ title: title ? String(title).slice(0, 120) : null,
59
+ created_at: new Date(now).toISOString(),
60
+ expires_at: new Date(now + ttl * 3600 * 1000).toISOString(),
61
+ revoked: false,
62
+ views: 0,
63
+ last_viewed_at: null
64
+ };
65
+ state.shares[id] = record;
66
+ write(state);
67
+ return record;
68
+ }
69
+
70
+ function list() {
71
+ const state = read();
72
+ return Object.values(state.shares)
73
+ .sort((a, b) => String(b.created_at).localeCompare(String(a.created_at)))
74
+ .map(r => ({ ...r, expired: isExpired(r) }));
75
+ }
76
+
77
+ function isExpired(record) {
78
+ return !record || record.revoked === true || Date.parse(record.expires_at) <= Date.now();
79
+ }
80
+
81
+ /** Resolve a link for reading. Returns null for unknown, revoked or expired ids. */
82
+ function resolve(id) {
83
+ const state = read();
84
+ const record = state.shares[String(id || '')];
85
+ if (!record || isExpired(record)) return null;
86
+
87
+ record.views = (record.views || 0) + 1;
88
+ record.last_viewed_at = new Date().toISOString();
89
+ try { write(state); } catch { /* a read must not fail because the counter could not be saved */ }
90
+ return record;
91
+ }
92
+
93
+ function revoke(id) {
94
+ const state = read();
95
+ const key = String(id || '');
96
+ if (!state.shares[key]) return false;
97
+ state.shares[key].revoked = true;
98
+ state.shares[key].revoked_at = new Date().toISOString();
99
+ write(state);
100
+ return true;
101
+ }
102
+
103
+ // Turns the model's own scaffolding into noise the reader never asked for, and
104
+ // keeps anything that could identify this machine out of a link.
105
+ const INTERNAL = /^(OBSERVATION \[E|DENIED:|GROUNDING AUDIT|STOP —|You have at most|You returned an empty response|Your reply contained no \[FINAL|Your previous answer)/;
106
+ const HOME_PATHS = [
107
+ [new RegExp(String(process.env.USERPROFILE || '').replace(/[\\^$.*+?()[\]{}|]/g, '\\$&'), 'gi'), '~'],
108
+ // C:\Users\<name>\... and the POSIX equivalent, so a shared link never carries
109
+ // the account name of the machine it came from.
110
+ [/[A-Z]:\\Users\\[^\\\s"']+/gi, '~'],
111
+ [/\/home\/[^/\s"']+/g, '~'],
112
+ [/\/Users\/[^/\s"']+/g, '~']
113
+ ];
114
+
115
+ function sanitize(messages = []) {
116
+ const out = [];
117
+ for (const m of messages) {
118
+ const role = m && m.role ? String(m.role) : 'user';
119
+ let content = String((m && m.content) || '');
120
+ if (INTERNAL.test(content)) continue; // tool output, retries, audits
121
+ content = secrets.redact(content); // keys, tokens, cards
122
+ for (const [rx, to] of HOME_PATHS) if (rx.source) content = content.replace(rx, to);
123
+ if (content.length > MAX_CHARS_PER_MESSAGE) {
124
+ content = content.slice(0, MAX_CHARS_PER_MESSAGE) + '\n… [truncated in the shared copy]';
125
+ }
126
+ out.push({ role, content, ts: (m && m.ts) || null });
127
+ if (out.length >= MAX_MESSAGES) break;
128
+ }
129
+ return out;
130
+ }
131
+
132
+ /** The only thing a public link ever returns. */
133
+ function publicPayload(record, messages) {
134
+ return {
135
+ success: true,
136
+ share: {
137
+ id: record.id,
138
+ title: record.title,
139
+ created_at: record.created_at,
140
+ expires_at: record.expires_at,
141
+ read_only: true
142
+ },
143
+ messages: sanitize(messages)
144
+ };
145
+ }
146
+
147
+ module.exports = {
148
+ create, list, resolve, revoke, sanitize, publicPayload,
149
+ isExpired,
150
+ storePath: () => file()
151
+ };
@@ -0,0 +1,76 @@
1
+ // src/daemon/core/stops.js — per-turn stop requests and the processes they own.
2
+ //
3
+ // The old kill switch was "close the browser": the SSE handler noticed the client
4
+ // disconnecting and the engine stopped at the next iteration. That leaves two
5
+ // holes: a caller with no socket (a script) cannot stop anything, and a terminal
6
+ // command keeps running to completion. This module closes both: a request marks
7
+ // the agent, and every child process started on its behalf is killed at once.
8
+ const stops = new Map(); // agentId -> { requested: boolean, at: number|null, children: Set }
9
+
10
+ function entry(agentId) {
11
+ const id = String(agentId || 'default');
12
+ if (!stops.has(id)) stops.set(id, { requested: false, at: null, children: new Set() });
13
+ return stops.get(id);
14
+ }
15
+
16
+ function request(agentId) {
17
+ const e = entry(agentId);
18
+ e.requested = true;
19
+ e.at = Date.now();
20
+ const killed = killChildren(agentId);
21
+ return { agentId: String(agentId || 'default'), killed };
22
+ }
23
+
24
+ function isRequested(agentId) {
25
+ const e = stops.get(String(agentId || 'default'));
26
+ return !!(e && e.requested);
27
+ }
28
+
29
+ function clear(agentId) {
30
+ const e = stops.get(String(agentId || 'default'));
31
+ if (e) { e.requested = false; e.at = null; }
32
+ }
33
+
34
+ /** Register a child process so /stop can end it mid-run. Returns a disposer. */
35
+ function track(agentId, child) {
36
+ if (!child || typeof child.kill !== 'function') return () => {};
37
+ const e = entry(agentId);
38
+ e.children.add(child);
39
+ const done = () => e.children.delete(child);
40
+ child.once('exit', done);
41
+ child.once('error', done);
42
+ return done;
43
+ }
44
+
45
+ function killChildren(agentId) {
46
+ const e = entry(agentId);
47
+ let killed = 0;
48
+ for (const child of [...e.children]) {
49
+ try {
50
+ // Kill the direct child first — it is the one we have a handle on — then, on
51
+ // Windows, take the whole tree: powershell.exe dying does not stop the
52
+ // command it launched.
53
+ try { child.kill('SIGKILL'); } catch { /* already gone */ }
54
+ if (process.platform === 'win32' && child.pid) {
55
+ require('child_process').execFile('taskkill', ['/PID', String(child.pid), '/T', '/F'],
56
+ { windowsHide: true }, () => {});
57
+ }
58
+ killed += 1;
59
+ } catch { /* already gone */ }
60
+ e.children.delete(child);
61
+ }
62
+ return killed;
63
+ }
64
+
65
+ /** Called by the engine before it spends another model call or runs a tool. */
66
+ function shouldStop(agentId) {
67
+ return isRequested(agentId);
68
+ }
69
+
70
+ function snapshot() {
71
+ return [...stops.entries()].map(([agentId, e]) => ({
72
+ agentId, requested: e.requested, at: e.at, children: e.children.size
73
+ }));
74
+ }
75
+
76
+ module.exports = { request, isRequested, clear, track, killChildren, shouldStop, snapshot };
@@ -248,12 +248,30 @@ function sandboxEnv() {
248
248
  return env;
249
249
  }
250
250
 
251
+ const stops = require('../stops');
252
+
253
+ function auditFile(name) {
254
+ return path.join(path.resolve(process.cwd(), '.liber'), name);
255
+ }
256
+
257
+ /**
258
+ * Keep an audit trail readable over months: when the file passes 5 MB it is
259
+ * rotated once (current -> .1, older .1 discarded). An audit log that grows
260
+ * without bound is the same as no audit log — nobody reads 200 MB.
261
+ */
262
+ function rotate(file, maxBytes = 5 * 1024 * 1024) {
263
+ try {
264
+ if (fs.statSync(file).size < maxBytes) return;
265
+ fs.renameSync(file, file + '.1');
266
+ } catch { /* no file yet, or a rotation race — the append below still works */ }
267
+ }
268
+
251
269
  function auditTerminal(entry) {
252
270
  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');
271
+ fs.mkdirSync(path.resolve(process.cwd(), '.liber'), { recursive: true });
272
+ const file = auditFile('terminal-audit.log');
273
+ rotate(file);
274
+ fs.appendFileSync(file, new Date().toISOString() + ' ' + JSON.stringify(entry) + '\n', 'utf8');
257
275
  } catch { /* auditing must never block a command */ }
258
276
  }
259
277
 
@@ -323,7 +341,7 @@ function containerUsable(force = false, image = 'alpine:latest') {
323
341
  return _runCache;
324
342
  }
325
343
 
326
- function runContainer(command, { cwd, timeout, profile }) {
344
+ function runContainer(command, { cwd, timeout, profile, meta = {} }) {
327
345
  const { execFile } = require('child_process');
328
346
  const mount = `${path.resolve(process.cwd())}:/work:rw`;
329
347
  // On POSIX hosts run as the invoking user: files the container writes land
@@ -348,8 +366,9 @@ function runContainer(command, { cwd, timeout, profile }) {
348
366
  'sh', '-lc', command
349
367
  ];
350
368
  return new Promise((resolve) => {
351
- execFile('docker', args, { timeout, maxBuffer: 10 * 1024 * 1024, windowsHide: true },
369
+ const child = execFile('docker', args, { timeout, maxBuffer: 10 * 1024 * 1024, windowsHide: true },
352
370
  (error, stdout, stderr) => resolve({ error, stdout: String(stdout || ''), stderr: String(stderr || '') }));
371
+ stops.track(meta.agentId, child);
353
372
  });
354
373
  }
355
374
 
@@ -357,7 +376,7 @@ function runContainer(command, { cwd, timeout, profile }) {
357
376
  * Run under Constrained Language Mode via a throwaway script file. A file avoids
358
377
  * every quoting trap that inline -Command has with quotes and $ signs.
359
378
  */
360
- function runSandboxed(command, { cwd, timeout }) {
379
+ function runSandboxed(command, { cwd, timeout, meta = {} }) {
361
380
  const { execFile } = require('child_process');
362
381
  const tmpDir = path.resolve(process.cwd(), '.liber', 'tmp');
363
382
  fs.mkdirSync(tmpDir, { recursive: true });
@@ -368,13 +387,14 @@ function runSandboxed(command, { cwd, timeout }) {
368
387
  command + '\n', 'utf8');
369
388
 
370
389
  return new Promise((resolve) => {
371
- execFile('powershell.exe',
390
+ const child = execFile('powershell.exe',
372
391
  ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', script],
373
392
  { cwd, timeout, maxBuffer: 10 * 1024 * 1024, windowsHide: true, env: sandboxEnv() },
374
393
  (error, stdout, stderr) => {
375
394
  try { fs.unlinkSync(script); } catch { }
376
395
  resolve({ error, stdout: String(stdout || ''), stderr: String(stderr || '') });
377
396
  });
397
+ stops.track(meta.agentId, child);
378
398
  });
379
399
  }
380
400
 
@@ -383,8 +403,17 @@ async function runTerminal({ command, timeout = 45000, cwd = process.cwd() }, me
383
403
  return { error: "Missing or invalid command argument." };
384
404
  }
385
405
 
406
+ const ctx = { agentId: meta.agentId || null, client: meta.client || null, turnId: meta.turnId || null };
386
407
  const decision = evaluatePolicy(command, { approved: meta && meta.approved === true });
387
408
  if (decision.denied) {
409
+ // A refusal is the event an operator most needs to see; it used to leave no trace.
410
+ auditTerminal({
411
+ decision: 'deny',
412
+ command,
413
+ reason: decision.reason,
414
+ needs_approval: decision.needs_approval === true,
415
+ ...ctx
416
+ });
388
417
  // Keep the escalation signal: a consumer (registry/engine) needs to know the
389
418
  // difference between "policy says no" and "policy says ask a human".
390
419
  return {
@@ -402,7 +431,7 @@ async function runTerminal({ command, timeout = 45000, cwd = process.cwd() }, me
402
431
  const sandbox = sandboxPolicy();
403
432
  const container = containerPolicy();
404
433
  const backend = container.on ? 'docker' : (sandbox.on ? 'constrained-language' : 'none');
405
- auditTerminal({ decision: 'allow', command, cwd, sandbox: backend });
434
+ auditTerminal({ decision: 'allow', command, cwd, sandbox: backend, ...ctx });
406
435
 
407
436
  const finish = (error, stdout, stderr) => {
408
437
  const durationMs = Date.now() - startTime;
@@ -416,7 +445,7 @@ async function runTerminal({ command, timeout = 45000, cwd = process.cwd() }, me
416
445
  truncated = true;
417
446
  }
418
447
  const exitCode = error ? (typeof error.code === 'number' ? error.code : 1) : 0;
419
- auditTerminal({ decision: 'run', command, exit_code: exitCode, duration_ms: durationMs, sandbox: backend });
448
+ auditTerminal({ decision: 'run', command, exit_code: exitCode, duration_ms: durationMs, sandbox: backend, ...ctx });
420
449
  if (error) {
421
450
  return {
422
451
  command,
@@ -448,7 +477,8 @@ async function runTerminal({ command, timeout = 45000, cwd = process.cwd() }, me
448
477
  const { error, stdout, stderr } = await runContainer(command, {
449
478
  cwd,
450
479
  timeout: parseInt(timeout, 10) || 45000,
451
- profile: container
480
+ profile: container,
481
+ meta
452
482
  });
453
483
  return finish(error, stdout, stderr);
454
484
  }
@@ -456,13 +486,14 @@ async function runTerminal({ command, timeout = 45000, cwd = process.cwd() }, me
456
486
  if (sandbox.on) {
457
487
  const { error, stdout, stderr } = await runSandboxed(command, {
458
488
  cwd,
459
- timeout: parseInt(timeout, 10) || 45000
489
+ timeout: parseInt(timeout, 10) || 45000,
490
+ meta
460
491
  });
461
492
  return finish(error, stdout, stderr);
462
493
  }
463
494
 
464
495
  return new Promise((resolve) => {
465
- exec(
496
+ const child = exec(
466
497
  command,
467
498
  {
468
499
  cwd,
@@ -473,6 +504,7 @@ async function runTerminal({ command, timeout = 45000, cwd = process.cwd() }, me
473
504
  },
474
505
  (error, stdout, stderr) => resolve(finish(error, stdout, stderr))
475
506
  );
507
+ stops.track(meta.agentId, child);
476
508
  });
477
509
  }
478
510
 
@@ -10,6 +10,13 @@ const registry = require('./core/registry');
10
10
  const circuitBreaker = require('./core/circuit-breaker');
11
11
  const trace = require('./core/trace');
12
12
  const auth = require('./core/auth');
13
+ const shares = require('./core/shares');
14
+ const stops = require('./core/stops');
15
+
16
+ const guard = require('./core/http-guard');
17
+
18
+ // ceiling for the routes that answer without a token
19
+ const unauth = guard.createThrottle();
13
20
  const policy = require('./core/policy');
14
21
  const journal = require('./core/journal');
15
22
  const modelSwitch = require('./core/model-switch');
@@ -323,10 +330,16 @@ class LiberDaemon {
323
330
  const endpoint = url.pathname;
324
331
  res.setHeader('Content-Type', 'application/json');
325
332
 
326
- // CORS for the desktop shell (Neutralino/WebView2 origin) and any local client.
327
- // The daemon binds to loopback and every stateful endpoint still requires the
328
- // bearer token, so a wildcard origin does not weaken access control.
329
- res.setHeader('Access-Control-Allow-Origin', '*');
333
+ // CORS: reflect only loopback origins. The shell is served from
334
+ // http://127.0.0.1:<port> (or localhost), so that is the whole allowlist.
335
+ // A wildcard used to be harmless only because of the bearer token; removing
336
+ // it means a page the user happens to visit cannot even attempt the call.
337
+ const origin = String(req.headers.origin || '');
338
+ const allowed = guard.allowOrigin(req.headers.origin);
339
+ if (allowed) {
340
+ res.setHeader('Access-Control-Allow-Origin', allowed);
341
+ res.setHeader('Vary', 'Origin');
342
+ }
330
343
  res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
331
344
  res.setHeader('Access-Control-Allow-Headers', 'Authorization, Content-Type');
332
345
  if (req.method === 'OPTIONS') {
@@ -335,6 +348,19 @@ class LiberDaemon {
335
348
  return;
336
349
  }
337
350
 
351
+ // The unauthenticated routes need a ceiling of their own: without a token there
352
+ // is nothing else to throttle against. 240/min per address is far above what a
353
+ // shell does and far below what a probe wants.
354
+ if (endpoint === '/healthz' || endpoint === '/whoami' || endpoint.startsWith('/shared/')) {
355
+ const gate = unauth.check(req.socket.remoteAddress || 'local');
356
+ if (!gate.allowed) {
357
+ res.statusCode = 429;
358
+ res.setHeader('retry-after', Math.ceil(gate.retryAfterMs / 1000));
359
+ res.end(JSON.stringify({ success: false, error: 'Too many requests without a token.' }));
360
+ return;
361
+ }
362
+ }
363
+
338
364
  // Unauthenticated liveness probe only — it exposes no state.
339
365
  if (endpoint === '/healthz') {
340
366
  res.end(JSON.stringify({ ok: true }));
@@ -359,6 +385,25 @@ class LiberDaemon {
359
385
  return;
360
386
  }
361
387
 
388
+ // A share link is a capability URL: the 128-bit id is the credential, so this
389
+ // route answers without a bearer token — and only ever returns one session,
390
+ // sanitized, with no tool output and no path from this machine.
391
+ if (req.method === 'GET' && endpoint.startsWith('/shared/')) {
392
+ const id = endpoint.slice('/shared/'.length);
393
+ // A revoked link must stop working in the browser too, not only on the wire:
394
+ // without this a cached copy keeps answering after DELETE /sessions/share.
395
+ res.setHeader('Cache-Control', 'no-store');
396
+ const record = shares.resolve(id);
397
+ if (!record) {
398
+ res.statusCode = 404;
399
+ res.end(JSON.stringify({ success: false, error: 'This link is unknown, expired or revoked.' }));
400
+ return;
401
+ }
402
+ const messages = memory.get(record.key) || [];
403
+ res.end(JSON.stringify(shares.publicPayload(record, messages)));
404
+ return;
405
+ }
406
+
362
407
  // Every request is resolved to a client, then checked for scope and rate.
363
408
  const requestStart = Date.now();
364
409
  const client = auth.verify(auth.extract(req));
@@ -497,9 +542,57 @@ class LiberDaemon {
497
542
  let body = '';
498
543
  for await (const chunk of req) body += chunk;
499
544
  const { verdict } = JSON.parse(body || '{}');
500
- const handled = this.resolveHitl(verdict === 'approve' || verdict === true);
545
+ const approved = verdict === 'approve' || verdict === true;
546
+ auth.audit({ client: client.id, endpoint, decision: approved ? 'approve' : 'deny', reason: 'hitl verdict' });
547
+ const handled = this.resolveHitl(approved);
501
548
  res.end(JSON.stringify({ success: true, handled }));
502
549
  }
550
+ else if (endpoint === '/stop') {
551
+ // The kill switch. It marks the agent so the engine stops before its next
552
+ // model call, and kills any command already running for it.
553
+ let body = '';
554
+ for await (const chunk of req) body += chunk;
555
+ const { agentId } = JSON.parse(body || '{}');
556
+ if (!agentId) {
557
+ res.statusCode = 400;
558
+ res.end(JSON.stringify({ success: false, error: 'Missing agentId.' }));
559
+ return;
560
+ }
561
+ const out = stops.request(agentId);
562
+ logger.warn(agentId, 'STOP_REQUESTED', { client: client.id, killed: out.killed });
563
+ auth.audit({ client: client.id, endpoint, decision: 'allow', reason: 'stop ' + agentId, killed: out.killed });
564
+ res.end(JSON.stringify({ success: true, agentId: out.agentId, killed: out.killed }));
565
+ }
566
+ else if (endpoint === '/sessions/share') {
567
+ if (req.method === 'GET') {
568
+ res.end(JSON.stringify({ success: true, shares: shares.list() }));
569
+ return;
570
+ }
571
+ if (req.method === 'DELETE') {
572
+ const id = url.searchParams.get('id');
573
+ const ok = shares.revoke(id);
574
+ auth.audit({ client: client.id, endpoint, decision: ok ? 'allow' : 'deny', reason: 'revoke ' + (id || '(none)') });
575
+ res.statusCode = ok ? 200 : 404;
576
+ res.end(JSON.stringify({ success: ok, revoked: ok ? id : null }));
577
+ return;
578
+ }
579
+ let body = '';
580
+ for await (const chunk of req) body += chunk;
581
+ const { key, title, ttl_hours } = JSON.parse(body || '{}');
582
+ if (!key) {
583
+ res.statusCode = 400;
584
+ res.end(JSON.stringify({ success: false, error: 'Missing session key.' }));
585
+ return;
586
+ }
587
+ if (!(memory.get(key) || []).length) {
588
+ res.statusCode = 404;
589
+ res.end(JSON.stringify({ success: false, error: 'No such session: ' + key }));
590
+ return;
591
+ }
592
+ const record = shares.create({ key, title, ttlHours: ttl_hours });
593
+ auth.audit({ client: client.id, endpoint, decision: 'allow', reason: 'share ' + record.id });
594
+ res.end(JSON.stringify({ success: true, share: record, path: '/shared/' + record.id }));
595
+ }
503
596
  else if (endpoint === '/sessions/history') {
504
597
  // The window keeps rendered HTML in memory only, so after a restart a
505
598
  // session looked empty while its messages were still on disk. This
@@ -1283,7 +1376,8 @@ class LiberDaemon {
1283
1376
  onEvent: sendEvent,
1284
1377
  waitForHitl: (aid, toolName, args) => this.waitForHitl(aid, `${toolName}: ${JSON.stringify(args)}`),
1285
1378
  maxLoops: this.maxLoops,
1286
- shouldStop
1379
+ shouldStop,
1380
+ client: client.id
1287
1381
  }));
1288
1382
  if (this.traceEnabled) {
1289
1383
  trace.appendTurn({ agentId, userMessage: message, result, model: agent.config.engine?.model });
@@ -1307,6 +1401,7 @@ class LiberDaemon {
1307
1401
  memoryKey,
1308
1402
  agentConfig: agent.config,
1309
1403
  userMessage: message,
1404
+ client: client.id,
1310
1405
  memory,
1311
1406
  llmManager,
1312
1407
  // This endpoint answers with one JSON body, so there is no channel to
@@ -22,6 +22,11 @@ class LLMManager {
22
22
  * @param {Object} config - Bagian 'engine' dari liber.yaml
23
23
  */
24
24
  initialize(config) {
25
+ // liber.yaml may leave provider/endpoint null and expect the installed key
26
+ // to supply them; resolve before anything reads config.endpoint, or the
27
+ // adapter ends up requesting null/chat/completions.
28
+ const { engineDefaults } = require('../core/providers');
29
+ config = engineDefaults(config);
25
30
  this.engineConfig = { ...config };
26
31
  const ProviderClass = this.adapters[config.provider];
27
32
  if (!ProviderClass) {