@securecoms/agent-runtime 0.5.0 → 0.7.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
@@ -98,7 +98,7 @@ All configuration is via `SECURECOMS_*` environment variables (see
98
98
  | `SECURECOMS_DATA_DIR` | `~/.securecoms/agent-runtime` | Root directory for per-channel state |
99
99
  | `SECURECOMS_SKILLS_CONFIG` | `<dataDir>/skills.json` (or `--skills-config <path>`) | Path to `skills.json` — named endpoints, databases and flags |
100
100
  | `SECURECOMS_LOG_LEVEL` | `info` | One of `debug`, `info`, `warn`, `error` |
101
- | `SECURECOMS_TOOL_ALLOW` | `echo`, `web_search`, `fetch_url`, `http_request`, `sql_query`, `current_time`, `remember` | Comma-separated tool allow-list |
101
+ | `SECURECOMS_TOOL_ALLOW` | `echo`, `web_search`, `fetch_url`, `http_request`, `sql_query`, `current_time` | Comma-separated tool allow-list |
102
102
  | `SECURECOMS_TOOL_DENY` | unset | Comma-separated tool deny-list (wins over allow) |
103
103
  | `SECURECOMS_TOOL_DEFAULT` | `deny` | Fallback policy for a tool matched by neither list |
104
104
 
@@ -205,7 +205,7 @@ the line — for example `requires: [endpoint:crm]` in
205
205
 
206
206
  ## Curated skills
207
207
 
208
- Seven built-in handlers ship with the runtime. All network built-ins refuse
208
+ Six built-in handlers ship with the runtime. All network built-ins refuse
209
209
  `localhost` and any private, loopback or link-local resolved address, and
210
210
  every tool result is capped at 20 kB (truncated, not an error). A redirect to a
211
211
  different origin is followed without the caller's headers and without the
@@ -220,7 +220,6 @@ to a host the operator did not name.
220
220
  | `web_search` | `query`, `count` (1–10, default 5) | `requires: [search_provider]` — needs a readable, non-empty `SECURECOMS_BRAVE_KEY_FILE` or `SECURECOMS_TAVILY_KEY_FILE`; Brave is used first when both are set. |
221
221
  | `http_request` | `name` (a configured `endpoint:<name>`), `path` (a query string is kept, a `#fragment` is dropped), `method` (GET/POST/PUT/DELETE), `body` | `requires: [any_endpoint]`. The model names an endpoint and a path, never a host; a path that would leave the endpoint's `baseUrl` origin is refused. |
222
222
  | `sql_query` | `name` (a configured `database:<name>`), `sql` (read-only `SELECT` only; a `;` or a write keyword anywhere in the text — inside a string literal or a comment included — is refused) | `requires: [any_database]`. SQLite or Postgres (`pg` required separately for Postgres). Results capped at 200 rows. |
223
- | `remember` | `fact` (≤300 chars) | Appends a dated line to the agent-wide `MEMORY.md`. |
224
223
 
225
224
  ## skills.json
226
225
 
@@ -283,12 +282,16 @@ file that already exists, so a customized `IDENTITY.md` is safe to keep
283
282
  across a re-init. `securecoms-agent persona show` prints the assembled system
284
283
  prompt exactly as a turn would see it, useful for checking an edit landed.
285
284
 
286
- Separately, `MEMORY.md` in the data dir is **agent-wide** (not per-persona,
287
- not per-channel): the `remember` skill appends a dated, one-line fact to it,
288
- and its contents are injected as a system message on every turn across every
289
- channel. It is capped at 8 kB; once it overflows, the **oldest** lines are
290
- dropped first, on the theory that the newest remembered facts are the ones
291
- still likely to be true.
285
+ Legacy `MEMORY.md` files are not active configuration. Earlier runtime versions
286
+ injected one agent-wide file into every channel, which crossed channel
287
+ audiences. On startup the runtime moves each legacy file to a mode-0600
288
+ `MEMORY.quarantined*.md` file in the same directory without overwriting an
289
+ existing quarantine. The quarantined content is never injected or writable by
290
+ a tool. The runtime inventories that quarantine plus inactive per-channel JSON
291
+ stores for the owner-review surface. Review offers a passphrase-encrypted
292
+ export and exact-source deletion; the 90-day clock begins only after the
293
+ machine successfully reports the source. Explicit fact-to-channel assignment
294
+ is a separate migration step and never guesses or fans out an audience.
292
295
 
293
296
  ## Managed configuration (from the web app)
294
297
 
@@ -307,7 +310,7 @@ managed directory at all behaves exactly as it did before. `skills.json` is
307
310
  looked up the same way, and re-read on every turn — a new endpoint or a skill
308
311
  switched off in the app takes effect on the next message, with no restart.
309
312
 
310
- Two deliberate exceptions:
313
+ One deliberate exception:
311
314
 
312
315
  - **The environment still wins for the tool policy.** `SECURECOMS_TOOL_ALLOW`
313
316
  overrides the `allow` list in the `tools` block of `skills.json`, so an
@@ -316,35 +319,55 @@ Two deliberate exceptions:
316
319
  rather than replacing it: deny is a safety mechanism, so one entry in the
317
320
  service unit must not silently un-deny every tool the owner switched off in
318
321
  the app. Everything the environment denies stays denied either way.
319
- - **Writes go to the data dir**, not the managed directory — except `MEMORY.md`,
320
- which is written wherever the agent already keeps it. An existing
321
- `<dataDir>/MEMORY.md` is copied into the managed directory once, at startup,
322
- so the owner can read and prune it in the app.
323
322
 
324
- The runtime also writes `local-config.json` into the managed directory when one
325
- exists: what this agent's OWN persona files say, and a single boolean for
326
- whether it has a readable search key. It is written at startup and rewritten
327
- whenever the search-key answer changes, so a key file dropped in (or taken
328
- away) mid-run is reported without a restart. It is a report the daemon passes
329
- upward — nothing in the runtime reads it back, and it holds no key value.
323
+ Runtime-owned writes go to the data directory, not the managed directory.
324
+ Legacy `MEMORY.md` is handled only by the quarantine behavior described above.
330
325
 
331
- ### A known limitation: the generated tool paragraph
326
+ The runtime also writes mode-0600 `local-config.json` into the managed directory
327
+ when one exists: what this agent's OWN persona files say, booleans for readable
328
+ search/managed-LLM keys, and a bounded inventory of inert legacy-memory files.
329
+ It is written at startup and rewritten whenever those answers or the inventory
330
+ change. The daemon revalidates current regular-file bytes, removes local paths,
331
+ and only then reports it upward. Nothing in the runtime reads the report back,
332
+ and it holds no key value.
333
+
334
+ ### Per-turn channel authorization
335
+
336
+ Installation configuration is only a ceiling. On every inbound message the
337
+ bridge supplies an immutable, server-derived authorization claim containing
338
+ the sender audience, membership state, and channel tool grants. The runtime
339
+ intersects that claim with installed skills, configuration requirements, and
340
+ the local allow/deny gate before showing tools to the model, then repeats the
341
+ same channel-grant check immediately before execution. Missing or malformed
342
+ claims, inactive/unknown membership, and absent grants all fail closed.
343
+
344
+ This staged release issues no channel grants yet, so no generic tool is offered
345
+ even when it is installed and enabled. Read grants can enable only the curated
346
+ read-only tools; `http_request` is additionally limited to GET at execution.
347
+ Side-effect grant fields are reserved in the protocol, but side-effect calls
348
+ remain denied until exact-operation confirmation, expiry, and idempotency are
349
+ implemented end to end.
350
+
351
+ ### The generated tool paragraph is an installation summary
332
352
 
333
353
  The `AGENTS.md` the app delivers ends in a generated paragraph naming the tools
334
- this agent has. The backend derives that list from the saved configuration plus
335
- the search-key boolean above; it does not see the running process. Three things
336
- can therefore make it diverge from what the runtime actually offers:
354
+ configured for the installation, and explicitly tells the model that the
355
+ per-turn offered list is authoritative. The backend derives the installation
356
+ list from saved configuration plus the search-key boolean above; it does not
357
+ see the running process or a turn's channel grants. These things can therefore
358
+ make the installation summary differ from what the runtime offers:
337
359
 
338
360
  - a `SECURECOMS_TOOL_ALLOW` or `SECURECOMS_TOOL_DENY` set in the service unit,
339
361
  which the backend cannot see;
340
362
  - a customer skill folder whose `requires:` is narrower than the shipped one, so
341
363
  the runtime withholds a tool the backend counted;
342
- - a custom skill the backend knows nothing about, so it is offered but unnamed.
364
+ - a custom skill the backend knows nothing about, so it can be eligible but
365
+ unnamed;
366
+ - the current channel has no matching server-issued read grant.
343
367
 
344
- In any of those cases the paragraph is advisory rather than exact. The gate is
345
- still authoritative — a tool named in the paragraph but denied by policy is
346
- still refused at call time — so the risk is a wrong claim in conversation, not
347
- a wrong permission.
368
+ The paragraph is therefore an advisory configuration summary, not an authority
369
+ claim. Channel authorization and the local gate are both enforced at call
370
+ time, so a configured but ungranted or denied tool is still refused.
348
371
 
349
372
  ## Provenance
350
373
 
package/dist/bin.js CHANGED
@@ -1,11 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
- import { readFileSync as readFileSync9 } from "node:fs";
4
+ import { readFileSync as readFileSync8 } from "node:fs";
5
5
  import { fileURLToPath as fileURLToPath3 } from "node:url";
6
- import { dirname as dirname4, join as join9 } from "node:path";
6
+ import { dirname as dirname4, join as join8 } from "node:path";
7
7
  import { spawnSync } from "node:child_process";
8
- import { accessSync, constants, existsSync as existsSync7, mkdirSync as mkdirSync3, realpathSync, rmSync, statSync as statSync2, writeFileSync as writeFileSync4 } from "node:fs";
8
+ import { accessSync, constants, existsSync as existsSync6, mkdirSync, realpathSync, rmSync, statSync as statSync2, writeFileSync as writeFileSync2 } from "node:fs";
9
9
  import { homedir as homedir4, userInfo } from "node:os";
10
10
 
11
11
  // src/config.ts
@@ -57,8 +57,7 @@ var DEFAULT_TOOL_ALLOW = [
57
57
  "fetch_url",
58
58
  "http_request",
59
59
  "sql_query",
60
- "current_time",
61
- "remember"
60
+ "current_time"
62
61
  ];
63
62
  function loadConfig(argv, env) {
64
63
  const identityId = parseIdentity(argv, env);
@@ -440,10 +439,16 @@ var LLMDispatcher = class {
440
439
  endpoint;
441
440
  model;
442
441
  apiKey;
442
+ chatPath;
443
+ chatAuth;
444
+ authHeaderName;
443
445
  constructor(endpoint, model, opts = {}) {
444
446
  this.endpoint = normalizeEndpoint(endpoint);
445
447
  this.model = model;
446
448
  this.apiKey = opts.apiKey;
449
+ this.chatPath = opts.chatPath ?? "/v1/chat/completions";
450
+ this.chatAuth = opts.chatAuth ?? "bearer";
451
+ this.authHeaderName = opts.authHeaderName ?? "x-api-key";
447
452
  }
448
453
  async complete(messages, tools) {
449
454
  const body = {
@@ -454,10 +459,12 @@ var LLMDispatcher = class {
454
459
  if (tools && tools.length > 0) {
455
460
  body.tools = tools;
456
461
  }
457
- const url = `${this.endpoint}/v1/chat/completions`;
462
+ const url = `${this.endpoint}${this.chatPath}`;
458
463
  const headers = { "Content-Type": "application/json" };
459
- if (this.apiKey) {
464
+ if (this.apiKey && this.chatAuth === "bearer") {
460
465
  headers.Authorization = `Bearer ${this.apiKey}`;
466
+ } else if (this.apiKey && this.chatAuth === "x-api-key") {
467
+ headers[this.authHeaderName] = this.apiKey;
461
468
  }
462
469
  const resp = await fetch(url, {
463
470
  method: "POST",
@@ -465,8 +472,8 @@ var LLMDispatcher = class {
465
472
  body: JSON.stringify(body)
466
473
  });
467
474
  if (!resp.ok) {
468
- const text = await resp.text();
469
- throw new Error(`LLM request failed (${resp.status}): ${text}`);
475
+ await resp.body?.cancel();
476
+ throw new Error(`LLM request failed (${resp.status})`);
470
477
  }
471
478
  const data = await resp.json();
472
479
  const choice = data.choices?.[0];
@@ -486,10 +493,13 @@ import { join as join2 } from "node:path";
486
493
 
487
494
  // src/llm-providers.json
488
495
  var llm_providers_default = {
489
- openrouter: { chat_base: "https://openrouter.ai/api/v1", models_url: "https://openrouter.ai/api/v1/models", models_id_path: "data[].id", validate_url: "https://openrouter.ai/api/v1/key", needs_key: true },
490
- anthropic: { chat_base: "https://openrouter.ai/api/v1", models_url: "https://openrouter.ai/api/v1/models", models_id_path: "data[].id", validate_url: "https://openrouter.ai/api/v1/key", needs_key: true },
491
- openai: { chat_base: "https://api.openai.com/v1", models_url: "https://api.openai.com/v1/models", models_id_path: "data[].id", validate_url: "https://api.openai.com/v1/models", needs_key: true },
492
- local: { chat_base: "http://127.0.0.1:11434", models_url: "http://127.0.0.1:11434/api/tags", models_id_path: "models[].name", validate_url: null, needs_key: false }
496
+ openrouter: { chat_base: "https://openrouter.ai/api/v1", chat_path: "/v1/chat/completions", chat_auth: "bearer", models_url: "https://openrouter.ai/api/v1/models", models_id_path: "data[].id", validate_url: "https://openrouter.ai/api/v1/key", probe_auth: "bearer", extra_headers: null, catalog_prefix: "self", needs_key: true },
497
+ openai: { chat_base: "https://api.openai.com/v1", chat_path: "/v1/chat/completions", chat_auth: "bearer", models_url: "https://api.openai.com/v1/models", models_id_path: "data[].id", validate_url: "https://api.openai.com/v1/models", probe_auth: "bearer", extra_headers: null, catalog_prefix: "openai/", needs_key: true },
498
+ anthropic: { chat_base: "https://api.anthropic.com/v1", chat_path: "/v1/chat/completions", chat_auth: "bearer", models_url: "https://api.anthropic.com/v1/models", models_id_path: "data[].id", validate_url: "https://api.anthropic.com/v1/models", probe_auth: "x-api-key", extra_headers: { "anthropic-version": "2023-06-01" }, catalog_prefix: "anthropic/", needs_key: true },
499
+ gemini: { chat_base: "https://generativelanguage.googleapis.com/v1beta/openai", chat_path: "/chat/completions", chat_auth: "bearer", models_url: "https://generativelanguage.googleapis.com/v1beta/openai/models", models_id_path: "data[].id", validate_url: "https://generativelanguage.googleapis.com/v1beta/openai/models", probe_auth: "bearer", extra_headers: null, catalog_prefix: "google/", needs_key: true },
500
+ meta: { chat_base: "https://api.meta.ai/v1", chat_path: "/v1/chat/completions", chat_auth: "bearer", models_url: "https://api.meta.ai/v1/models", models_id_path: "data[].id", validate_url: "https://api.meta.ai/v1/models", probe_auth: "bearer", extra_headers: null, catalog_prefix: null, needs_key: true },
501
+ xai: { chat_base: "https://api.x.ai/v1", chat_path: "/v1/chat/completions", chat_auth: "bearer", models_url: "https://api.x.ai/v1/models", models_id_path: "data[].id", validate_url: "https://api.x.ai/v1/models", probe_auth: "bearer", extra_headers: null, catalog_prefix: "x-ai/", needs_key: true },
502
+ local: { chat_base: "http://127.0.0.1:11434", chat_path: "/v1/chat/completions", chat_auth: "none", models_url: "http://127.0.0.1:11434/api/tags", models_id_path: "models[].name", validate_url: null, probe_auth: "none", extra_headers: null, catalog_prefix: null, needs_key: false }
493
503
  };
494
504
 
495
505
  // src/llm-key.ts
@@ -513,7 +523,12 @@ function resolveLlmApiKey(cfg, readFile) {
513
523
 
514
524
  // src/llm-source.ts
515
525
  var PROVIDER_TABLE = Object.freeze(Object.fromEntries(
516
- Object.entries(llm_providers_default).map(([p, e]) => [p, Object.freeze({ endpoint: e.chat_base, needsKey: e.needs_key })])
526
+ Object.entries(llm_providers_default).map(([p, e]) => [p, Object.freeze({
527
+ endpoint: e.chat_base,
528
+ needsKey: e.needs_key,
529
+ chatPath: e.chat_path,
530
+ chatAuth: e.chat_auth
531
+ })])
517
532
  ));
518
533
  var LlmSourceUnavailableError = class extends Error {
519
534
  };
@@ -523,6 +538,9 @@ function providerOrigin(provider) {
523
538
  const u = new URL(entry.endpoint);
524
539
  return `${u.protocol}//${u.host}`.toLowerCase();
525
540
  }
541
+ function providerSlug(provider) {
542
+ return providerOrigin(provider).replace("://", "_").replace(/:/g, "-");
543
+ }
526
544
  var explicitEndpointSet = (env) => Boolean(env.SECURECOMS_LLM_ENDPOINT || env.SECURECOMS_LLM_API_KEY_FILE || env.SECURECOMS_LLM_API_KEY);
527
545
  function isExplicitSource(cfg, env) {
528
546
  return explicitEndpointSet(env) || Boolean(cfg.llmApiKeyFile) || Boolean(cfg.llmApiKey);
@@ -539,7 +557,9 @@ function resolveLlmSource(cfg, explicit, deps) {
539
557
  model: cfg.llmModel,
540
558
  apiKey: resolveLlmApiKey(cfg, deps.readFile),
541
559
  provider: "env",
542
- origin: "env"
560
+ origin: "env",
561
+ chatPath: "/v1/chat/completions",
562
+ chatAuth: "bearer"
543
563
  };
544
564
  }
545
565
  const managedDir = join2(deps.home, ".securecoms", "runtime-config", cfg.identityId);
@@ -567,6 +587,11 @@ function resolveLlmSource(cfg, explicit, deps) {
567
587
  if (typeof provider !== "string" || typeof model !== "string") {
568
588
  throw new LlmSourceUnavailableError(`${sourcePath} is missing "provider" or "model"`);
569
589
  }
590
+ if (!Object.hasOwn(llm_providers_default, provider)) {
591
+ throw new LlmSourceUnavailableError(
592
+ `unknown LLM provider ${JSON.stringify(provider)} in ${sourcePath}`
593
+ );
594
+ }
570
595
  const entry = PROVIDER_TABLE[provider];
571
596
  if (!entry) {
572
597
  throw new LlmSourceUnavailableError(
@@ -575,53 +600,63 @@ function resolveLlmSource(cfg, explicit, deps) {
575
600
  }
576
601
  let apiKey;
577
602
  if (entry.needsKey) {
578
- const keyPath = join2(managedDir, "llm.key");
579
- const hostPath = join2(managedDir, "llm.key.host");
580
- if (!deps.exists(keyPath)) {
603
+ const expectedOrigin = providerOrigin(provider);
604
+ const slug = providerSlug(provider);
605
+ const keyPath = join2(managedDir, `llm.key.${slug}`);
606
+ const hostPath = join2(managedDir, `llm.key.${slug}.host`);
607
+ const legacyKey = join2(managedDir, "llm.key");
608
+ const legacyHost = join2(managedDir, "llm.key.host");
609
+ const useLegacy = !deps.exists(keyPath) && deps.exists(legacyKey) && deps.exists(legacyHost);
610
+ const boundKey = useLegacy ? legacyKey : keyPath;
611
+ const boundHost = useLegacy ? legacyHost : hostPath;
612
+ if (!deps.exists(boundKey)) {
581
613
  throw new LlmSourceUnavailableError(
582
614
  `${keyPath} does not exist \u2014 no key has been written for this managed source, refusing to read it`
583
615
  );
584
616
  }
585
617
  let mode;
586
618
  try {
587
- ({ mode } = deps.statFile(keyPath));
619
+ ({ mode } = deps.statFile(boundKey));
588
620
  } catch (e) {
589
621
  throw new LlmSourceUnavailableError(
590
- `cannot stat ${keyPath}: ${e instanceof Error ? e.message : String(e)}`
622
+ `cannot stat ${boundKey}: ${e instanceof Error ? e.message : String(e)}`
591
623
  );
592
624
  }
593
625
  if ((mode & 63) !== 0) {
594
626
  throw new LlmSourceUnavailableError(
595
- `${keyPath} is readable by other users on this machine \u2014 tighten its permissions to owner-only`
627
+ `${boundKey} is readable by other users on this machine \u2014 tighten its permissions to owner-only`
596
628
  );
597
629
  }
598
- const expectedOrigin = providerOrigin(provider);
599
- if (!deps.exists(hostPath)) {
630
+ if (!deps.exists(boundHost)) {
600
631
  throw new LlmSourceUnavailableError(
601
- `${keyPath} has no ${hostPath} sidecar recording which origin it belongs to \u2014 refusing to send it to ${expectedOrigin}`
632
+ `${boundKey} has no ${boundHost} sidecar recording which origin it belongs to \u2014 refusing to send it to ${expectedOrigin}`
602
633
  );
603
634
  }
604
- const actualOrigin = deps.readFile(hostPath).trim();
635
+ const actualOrigin = deps.readFile(boundHost).trim();
605
636
  if (actualOrigin !== expectedOrigin) {
606
637
  throw new LlmSourceUnavailableError(
607
- `${hostPath} is bound to ${actualOrigin}, but provider ${JSON.stringify(provider)} requires ${expectedOrigin} \u2014 refusing to send the key across origins`
638
+ `${boundHost} is bound to ${actualOrigin}, but provider ${JSON.stringify(provider)} requires ${expectedOrigin} \u2014 refusing to send the key across origins`
608
639
  );
609
640
  }
610
- apiKey = resolveLlmApiKey({ llmApiKeyFile: keyPath }, deps.readFile);
641
+ apiKey = resolveLlmApiKey({ llmApiKeyFile: boundKey }, deps.readFile);
611
642
  }
612
643
  return {
613
644
  endpoint: entry.endpoint,
614
645
  model,
615
646
  ...apiKey !== void 0 ? { apiKey } : {},
616
647
  provider,
617
- origin: "managed"
648
+ origin: "managed",
649
+ chatPath: entry.chatPath,
650
+ chatAuth: entry.chatAuth
618
651
  };
619
652
  }
620
653
  return {
621
654
  endpoint: cfg.llmEndpoint,
622
655
  model: cfg.llmModel,
623
656
  provider: "default",
624
- origin: "default"
657
+ origin: "default",
658
+ chatPath: "/v1/chat/completions",
659
+ chatAuth: "bearer"
625
660
  };
626
661
  }
627
662
 
@@ -637,7 +672,11 @@ function buildLlmShim(cfg, explicit, deps) {
637
672
  return {
638
673
  async complete(messages, tools) {
639
674
  const src = resolveLlmSource(cfg, explicit, deps);
640
- const dispatcher = new LLMDispatcher(src.endpoint, src.model, { apiKey: src.apiKey });
675
+ const dispatcher = new LLMDispatcher(src.endpoint, src.model, {
676
+ apiKey: src.apiKey,
677
+ chatPath: src.chatPath,
678
+ chatAuth: src.chatAuth
679
+ });
641
680
  return dispatcher.complete(messages, tools);
642
681
  }
643
682
  };
@@ -652,116 +691,59 @@ function bootLine(cfg, explicit, deps) {
652
691
  }
653
692
 
654
693
  // src/memory.ts
655
- import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
656
- import { join as join3 } from "node:path";
657
694
  var MAX_HISTORY = 50;
695
+ var MAX_FACTS = 200;
658
696
  var AgentMemory = class {
659
697
  history = [];
660
- memories = [];
661
- dataDir;
662
- constructor(dataDir) {
663
- this.dataDir = dataDir;
664
- this.loadHistory();
665
- this.loadMemories();
666
- }
667
- /** Add a message to conversation history. */
698
+ facts = [];
699
+ constructor(snapshot) {
700
+ if (snapshot) this.replace(snapshot);
701
+ }
702
+ /** Replace the working view at the start of every authenticated turn. */
703
+ replace(snapshot) {
704
+ this.history = snapshot.recent.slice(-MAX_HISTORY).map((turn) => ({
705
+ role: turn.role === "peer" ? "user" : "assistant",
706
+ content: turn.text
707
+ }));
708
+ this.facts = snapshot.facts.slice(-MAX_FACTS);
709
+ }
710
+ /** Add a message to this turn's in-process history only. */
668
711
  addMessage(msg) {
669
712
  this.history.push(msg);
670
713
  if (this.history.length > MAX_HISTORY * 2) {
671
714
  this.history = this.history.slice(-MAX_HISTORY);
672
715
  }
673
- this.saveHistory();
674
716
  }
675
717
  /** Get recent conversation history for LLM context. */
676
718
  getHistory() {
677
719
  return this.history.slice(-MAX_HISTORY);
678
720
  }
679
- /** Clear conversation history. */
721
+ /** Clear this process-local view; durable deletion belongs to the bridge. */
680
722
  clear() {
681
723
  this.history = [];
682
- this.saveHistory();
724
+ this.facts = [];
683
725
  }
684
- /**
685
- * Recall relevant memories to prepend to the LLM context.
686
- * Case-insensitive keyword-overlap match over captured entries: query is
687
- * split into words of length >= 3, entries are scored by number of
688
- * matching words, and the top 5 with score > 0 are returned.
689
- */
726
+ /** Case-insensitive keyword-overlap recall over protected channel facts. */
690
727
  async recall(query) {
691
- const queryWords = query.toLowerCase().split(/\W+/).filter((w) => w.length >= 3);
728
+ const queryWords = query.toLowerCase().split(/\W+/).filter((word) => word.length >= 3);
692
729
  if (queryWords.length === 0) return null;
693
- const scored = this.memories.map((m) => {
694
- const contentLower = m.content.toLowerCase();
730
+ const scored = this.facts.map((content) => {
731
+ const contentLower = content.toLowerCase();
695
732
  const score = queryWords.reduce(
696
- (acc, w) => contentLower.includes(w) ? acc + 1 : acc,
733
+ (acc, word) => contentLower.includes(word) ? acc + 1 : acc,
697
734
  0
698
735
  );
699
- return { memory: m, score };
700
- }).filter((s) => s.score > 0).sort((a, b) => b.score - a.score).slice(0, 5);
736
+ return { content, score };
737
+ }).filter((entry) => entry.score > 0).sort((a, b) => b.score - a.score).slice(0, 5);
701
738
  if (scored.length === 0) return null;
702
- const memories = scored.map((s) => `- ${s.memory.content}`).join("\n");
703
739
  return `Relevant context from memory:
704
- ${memories}`;
705
- }
706
- /**
707
- * Capture a notable fact to local memory (best-effort).
708
- */
709
- async capture(content, kind) {
710
- try {
711
- this.memories.push({
712
- content,
713
- category: kind,
714
- capturedAt: (/* @__PURE__ */ new Date()).toISOString()
715
- });
716
- this.saveMemories();
717
- } catch {
718
- }
719
- }
720
- get historyPath() {
721
- return join3(this.dataDir, "conversation-history.json");
722
- }
723
- get memoriesPath() {
724
- return join3(this.dataDir, "memories.json");
725
- }
726
- loadHistory() {
727
- try {
728
- if (existsSync(this.historyPath)) {
729
- const raw = readFileSync(this.historyPath, "utf-8");
730
- this.history = JSON.parse(raw);
731
- }
732
- } catch {
733
- this.history = [];
734
- }
735
- }
736
- saveHistory() {
737
- try {
738
- mkdirSync(this.dataDir, { recursive: true });
739
- writeFileSync(this.historyPath, JSON.stringify(this.history));
740
- } catch {
741
- }
742
- }
743
- loadMemories() {
744
- try {
745
- if (existsSync(this.memoriesPath)) {
746
- const raw = readFileSync(this.memoriesPath, "utf-8");
747
- this.memories = JSON.parse(raw);
748
- }
749
- } catch {
750
- this.memories = [];
751
- }
752
- }
753
- saveMemories() {
754
- try {
755
- mkdirSync(this.dataDir, { recursive: true });
756
- writeFileSync(this.memoriesPath, JSON.stringify(this.memories));
757
- } catch {
758
- }
740
+ ${scored.map((entry) => `- ${entry.content}`).join("\n")}`;
759
741
  }
760
742
  };
761
743
 
762
744
  // src/skill-invoker.ts
763
- import { readFileSync as readFileSync6, readdirSync, existsSync as existsSync5, statSync } from "node:fs";
764
- import { join as join6 } from "node:path";
745
+ import { readFileSync as readFileSync4, readdirSync, existsSync as existsSync3, statSync } from "node:fs";
746
+ import { join as join4 } from "node:path";
765
747
 
766
748
  // src/skill-md.ts
767
749
  var SkillMdError = class extends Error {
@@ -916,23 +898,23 @@ function resolveRequires(req, env) {
916
898
  }
917
899
 
918
900
  // src/skills-config.ts
919
- import { existsSync as existsSync3, readFileSync as readFileSync2 } from "node:fs";
901
+ import { existsSync as existsSync2, readFileSync } from "node:fs";
920
902
  import { isAbsolute as isAbsolute2, resolve as resolve2 } from "node:path";
921
903
 
922
904
  // src/config-dirs.ts
923
- import { existsSync as existsSync2 } from "node:fs";
905
+ import { existsSync } from "node:fs";
924
906
  import { homedir as homedir2 } from "node:os";
925
- import { join as join4 } from "node:path";
907
+ import { join as join3 } from "node:path";
926
908
  function managedConfigDir(identityId, home = homedir2()) {
927
- return join4(home, ".securecoms", "runtime-config", identityId);
909
+ return join3(home, ".securecoms", "runtime-config", identityId);
928
910
  }
929
911
  function configSearchPath(identityId, dataDir, home = homedir2()) {
930
912
  return [managedConfigDir(identityId, home), dataDir];
931
913
  }
932
914
  function findConfigFile(name, dirs) {
933
915
  for (const dir of dirs) {
934
- const path = join4(dir, name);
935
- if (existsSync2(path)) return path;
916
+ const path = join3(dir, name);
917
+ if (existsSync(path)) return path;
936
918
  }
937
919
  return null;
938
920
  }
@@ -942,10 +924,10 @@ var EMPTY_SKILLS_CONFIG = { endpoints: {}, databases: {}, flags: [] };
942
924
  var empty = () => ({ endpoints: {}, databases: {}, flags: [] });
943
925
  var abs2 = (p, dataDir) => isAbsolute2(p) ? p : resolve2(dataDir, p);
944
926
  function loadSkillsConfigFile(path, dataDir) {
945
- if (!existsSync3(path)) return empty();
927
+ if (!existsSync2(path)) return empty();
946
928
  let raw;
947
929
  try {
948
- raw = JSON.parse(readFileSync2(path, "utf8"));
930
+ raw = JSON.parse(readFileSync(path, "utf8"));
949
931
  } catch (e) {
950
932
  console.warn(`[skills-config] ignoring ${path}: ${e instanceof Error ? e.message : String(e)}`);
951
933
  return empty();
@@ -1200,7 +1182,7 @@ var fetchUrl = async (args, ctx) => {
1200
1182
  };
1201
1183
 
1202
1184
  // src/builtins/http-request.ts
1203
- import { readFileSync as readFileSync3 } from "node:fs";
1185
+ import { readFileSync as readFileSync2 } from "node:fs";
1204
1186
  var METHODS = /* @__PURE__ */ new Set(["GET", "POST", "PUT", "DELETE"]);
1205
1187
  var httpRequest = async (args, ctx) => {
1206
1188
  const name = typeof args.name === "string" ? args.name : "";
@@ -1243,7 +1225,7 @@ var httpRequest = async (args, ctx) => {
1243
1225
  const headers = { accept: "application/json" };
1244
1226
  if (endpoint.headersFile) {
1245
1227
  try {
1246
- for (const [k, v] of Object.entries(JSON.parse(readFileSync3(endpoint.headersFile, "utf8")))) {
1228
+ for (const [k, v] of Object.entries(JSON.parse(readFileSync2(endpoint.headersFile, "utf8")))) {
1247
1229
  if (typeof v === "string") headers[k] = v;
1248
1230
  }
1249
1231
  } catch {
@@ -1268,59 +1250,8 @@ var httpRequest = async (args, ctx) => {
1268
1250
  }
1269
1251
  };
1270
1252
 
1271
- // src/long-term-memory.ts
1272
- import { copyFileSync, existsSync as existsSync4, mkdirSync as mkdirSync2, readFileSync as readFileSync4, writeFileSync as writeFileSync2 } from "node:fs";
1273
- import { join as join5 } from "node:path";
1274
- var MEMORY_FILE = "MEMORY.md";
1275
- var MEMORY_MAX_BYTES = 8192;
1276
- var FACT_MAX_CHARS = 300;
1277
- function readLongTermMemory(dirs) {
1278
- const p = findConfigFile(MEMORY_FILE, dirs);
1279
- if (!p) return null;
1280
- const body = readFileSync4(p, "utf8").trim();
1281
- return body || null;
1282
- }
1283
- function memoryTarget(dirs) {
1284
- for (const d of dirs) if (existsSync4(join5(d, MEMORY_FILE))) return d;
1285
- for (const d of dirs) if (existsSync4(d)) return d;
1286
- return dirs[dirs.length - 1];
1287
- }
1288
- function migrateMemoryIntoManaged(managedDir, dataDir) {
1289
- const managed = join5(managedDir, MEMORY_FILE);
1290
- const local = join5(dataDir, MEMORY_FILE);
1291
- if (!existsSync4(managedDir) || existsSync4(managed) || !existsSync4(local)) return false;
1292
- copyFileSync(local, managed);
1293
- return true;
1294
- }
1295
- function rememberFact(dirs, fact, now) {
1296
- const clean = fact.replace(/\s+/g, " ").trim().slice(0, FACT_MAX_CHARS);
1297
- if (!clean) throw new Error("remember needs a non-empty fact");
1298
- const line = `- ${now.toISOString().slice(0, 10)} ${clean}`;
1299
- const dir = memoryTarget(dirs);
1300
- mkdirSync2(dir, { recursive: true });
1301
- const existing = readLongTermMemory(dirs);
1302
- let lines = existing ? existing.split("\n") : [];
1303
- lines.push(line);
1304
- while (Buffer.byteLength(`${lines.join("\n")}
1305
- `, "utf8") > MEMORY_MAX_BYTES && lines.length > 1) {
1306
- lines = lines.slice(1);
1307
- }
1308
- writeFileSync2(join5(dir, MEMORY_FILE), `${lines.join("\n")}
1309
- `, { mode: 384 });
1310
- return line;
1311
- }
1312
-
1313
- // src/builtins/remember.ts
1314
- var remember = async (args, ctx) => {
1315
- try {
1316
- return JSON.stringify({ remembered: rememberFact(ctx.configDirs, String(args.fact ?? ""), ctx.now()) });
1317
- } catch (e) {
1318
- return toolError(e instanceof Error ? e.message : String(e));
1319
- }
1320
- };
1321
-
1322
1253
  // src/builtins/sql-query.ts
1323
- import { readFileSync as readFileSync5 } from "node:fs";
1254
+ import { readFileSync as readFileSync3 } from "node:fs";
1324
1255
 
1325
1256
  // src/sql-guard.ts
1326
1257
  var SqlNotReadOnlyError = class extends Error {
@@ -1421,7 +1352,7 @@ var sqlQuery = async (args, ctx) => {
1421
1352
  const Client = pg.Client ?? pg.default?.Client;
1422
1353
  if (!Client) return toolError(`the installed "pg" module does not export a Client`);
1423
1354
  const client = new Client({
1424
- connectionString: readFileSync5(db.postgresUrlFile, "utf8").trim(),
1355
+ connectionString: readFileSync3(db.postgresUrlFile, "utf8").trim(),
1425
1356
  // never logged (D4)
1426
1357
  statement_timeout: SQL_TIMEOUT_MS
1427
1358
  });
@@ -1533,8 +1464,7 @@ var BUILTINS = {
1533
1464
  fetch_url: fetchUrl,
1534
1465
  web_search: webSearch,
1535
1466
  http_request: httpRequest,
1536
- sql_query: sqlQuery,
1537
- remember
1467
+ sql_query: sqlQuery
1538
1468
  };
1539
1469
 
1540
1470
  // src/skill-invoker.ts
@@ -1560,19 +1490,19 @@ var SkillInvoker = class {
1560
1490
  * `<skillsDir>/<subdir>/manifest.json`.
1561
1491
  */
1562
1492
  loadSkills() {
1563
- if (!existsSync5(this.skillsDir)) {
1493
+ if (!existsSync3(this.skillsDir)) {
1564
1494
  console.warn(`[SkillInvoker] Skills directory not found: ${this.skillsDir}`);
1565
1495
  return;
1566
1496
  }
1567
1497
  const flat = [], manifests = [], skillMds = [];
1568
1498
  for (const entry of readdirSync(this.skillsDir, { withFileTypes: true })) {
1569
1499
  if (entry.isFile() && entry.name.endsWith(".json")) {
1570
- flat.push(join6(this.skillsDir, entry.name));
1500
+ flat.push(join4(this.skillsDir, entry.name));
1571
1501
  } else if (entry.isDirectory()) {
1572
- const m = join6(this.skillsDir, entry.name, "manifest.json");
1573
- if (existsSync5(m) && statSync(m).isFile()) manifests.push(m);
1574
- const s = join6(this.skillsDir, entry.name, "SKILL.md");
1575
- if (existsSync5(s) && statSync(s).isFile()) skillMds.push(s);
1502
+ const m = join4(this.skillsDir, entry.name, "manifest.json");
1503
+ if (existsSync3(m) && statSync(m).isFile()) manifests.push(m);
1504
+ const s = join4(this.skillsDir, entry.name, "SKILL.md");
1505
+ if (existsSync3(s) && statSync(s).isFile()) skillMds.push(s);
1576
1506
  }
1577
1507
  }
1578
1508
  for (const p of [...flat.sort(), ...manifests.sort()]) this.loadManifestFile(p);
@@ -1588,14 +1518,14 @@ var SkillInvoker = class {
1588
1518
  }
1589
1519
  loadSkillMdFile(path) {
1590
1520
  try {
1591
- this.register(parseSkillMd(readFileSync6(path, "utf-8"), path), path);
1521
+ this.register(parseSkillMd(readFileSync4(path, "utf-8"), path), path);
1592
1522
  } catch (err) {
1593
1523
  console.warn(`[SkillInvoker] Skipping ${path}: ${err instanceof Error ? err.message : String(err)}`);
1594
1524
  }
1595
1525
  }
1596
1526
  loadManifestFile(path) {
1597
1527
  try {
1598
- const raw = readFileSync6(path, "utf-8");
1528
+ const raw = readFileSync4(path, "utf-8");
1599
1529
  const manifest = JSON.parse(raw);
1600
1530
  if (typeof manifest.handler !== "string" || !(manifest.handler.startsWith("builtin:") || manifest.handler.startsWith("script:"))) {
1601
1531
  console.warn(
@@ -1758,10 +1688,78 @@ function createDynamicToolGate(policyFor) {
1758
1688
  }
1759
1689
 
1760
1690
  // src/data-plane.ts
1761
- import { readFileSync as readFileSync7 } from "node:fs";
1691
+ import { readFileSync as readFileSync5 } from "node:fs";
1762
1692
  import { WebSocket } from "node:http";
1763
1693
  import { homedir as homedir3 } from "node:os";
1764
- import { join as join7 } from "node:path";
1694
+ import { join as join5 } from "node:path";
1695
+ var NO_TURN_MEMORY = Object.freeze({
1696
+ recent: Object.freeze([]),
1697
+ facts: Object.freeze([])
1698
+ });
1699
+ function parseTurnMemory(value) {
1700
+ if (!value || typeof value !== "object" || Array.isArray(value)) return NO_TURN_MEMORY;
1701
+ const raw = value;
1702
+ if (!Array.isArray(raw.recent) || !Array.isArray(raw.facts)) return NO_TURN_MEMORY;
1703
+ const recent = [];
1704
+ for (const value2 of raw.recent.slice(-50)) {
1705
+ if (!value2 || typeof value2 !== "object" || Array.isArray(value2)) return NO_TURN_MEMORY;
1706
+ const turn = value2;
1707
+ if (turn.role !== "peer" && turn.role !== "agent" || typeof turn.text !== "string") {
1708
+ return NO_TURN_MEMORY;
1709
+ }
1710
+ recent.push(Object.freeze({ role: turn.role, text: turn.text }));
1711
+ }
1712
+ const facts = [];
1713
+ for (const fact of raw.facts.slice(-200)) {
1714
+ if (typeof fact !== "string") return NO_TURN_MEMORY;
1715
+ facts.push(fact);
1716
+ }
1717
+ return Object.freeze({ recent: Object.freeze(recent), facts: Object.freeze(facts) });
1718
+ }
1719
+ var AUDIENCES = /* @__PURE__ */ new Set([
1720
+ "owner_dm",
1721
+ "team_room",
1722
+ "partner_room",
1723
+ "other_human_dm",
1724
+ "agent_to_agent",
1725
+ "unknown"
1726
+ ]);
1727
+ var NO_TURN_AUTHORIZATION = Object.freeze({
1728
+ version: 1,
1729
+ audience: "unknown",
1730
+ sender_membership: "unknown",
1731
+ tool_grants: Object.freeze([])
1732
+ });
1733
+ function parseTurnAuthorization(value) {
1734
+ if (!value || typeof value !== "object" || Array.isArray(value)) return NO_TURN_AUTHORIZATION;
1735
+ const raw = value;
1736
+ if (raw.version !== 1 || typeof raw.audience !== "string" || !AUDIENCES.has(raw.audience) || raw.sender_membership !== "active" && raw.sender_membership !== "unknown" || !Array.isArray(raw.tool_grants)) return NO_TURN_AUTHORIZATION;
1737
+ const grants = [];
1738
+ for (const value2 of raw.tool_grants) {
1739
+ if (!value2 || typeof value2 !== "object" || Array.isArray(value2)) return NO_TURN_AUTHORIZATION;
1740
+ const grant = value2;
1741
+ if (typeof grant.tool !== "string" || !grant.tool || grant.tool.length > 100) return NO_TURN_AUTHORIZATION;
1742
+ if (grant.mode === "read") {
1743
+ grants.push(Object.freeze({ tool: grant.tool, mode: "read" }));
1744
+ continue;
1745
+ }
1746
+ if (grant.mode !== "side_effect" || typeof grant.operation_sha256 !== "string" || !/^[0-9a-f]{64}$/.test(grant.operation_sha256) || typeof grant.confirmation_id !== "string" || !grant.confirmation_id || typeof grant.idempotency_key !== "string" || !grant.idempotency_key || typeof grant.expires_at !== "string" || !grant.expires_at) return NO_TURN_AUTHORIZATION;
1747
+ grants.push(Object.freeze({
1748
+ tool: grant.tool,
1749
+ mode: "side_effect",
1750
+ operation_sha256: grant.operation_sha256,
1751
+ confirmation_id: grant.confirmation_id,
1752
+ idempotency_key: grant.idempotency_key,
1753
+ expires_at: grant.expires_at
1754
+ }));
1755
+ }
1756
+ return Object.freeze({
1757
+ version: 1,
1758
+ audience: raw.audience,
1759
+ sender_membership: raw.sender_membership,
1760
+ tool_grants: Object.freeze(grants)
1761
+ });
1762
+ }
1765
1763
  var SEEN_MAX = 5e3;
1766
1764
  function errText(e) {
1767
1765
  return e instanceof Error ? e.message : String(e);
@@ -1804,8 +1802,8 @@ var SecureComsDataPlane = class {
1804
1802
  generation = 0;
1805
1803
  constructor(cfg, onMessage) {
1806
1804
  this.identityId = cfg.identityId;
1807
- const dir = cfg.connectionsDir ?? join7(homedir3(), ".securecoms", "connections");
1808
- this.connectionPath = join7(dir, `${cfg.identityId}.json`);
1805
+ const dir = cfg.connectionsDir ?? join5(homedir3(), ".securecoms", "connections");
1806
+ this.connectionPath = join5(dir, `${cfg.identityId}.json`);
1809
1807
  this.establishedMs = cfg.establishedMs ?? 5e3;
1810
1808
  this.initialDelayMs = cfg.initialDelayMs ?? 250;
1811
1809
  this.maxDelayMs = cfg.maxDelayMs ?? 3e4;
@@ -1908,9 +1906,9 @@ var SecureComsDataPlane = class {
1908
1906
  if (this.generation === generation) this.running = false;
1909
1907
  }
1910
1908
  async attemptOnce() {
1911
- const conn = JSON.parse(readFileSync7(this.connectionPath, "utf8"));
1909
+ const conn = JSON.parse(readFileSync5(this.connectionPath, "utf8"));
1912
1910
  const httpBase = conn.bridge_url.replace(/^ws/, "http").replace(/\/v1\/ws$/, "");
1913
- const token = readFileSync7(conn.token_file, "utf8").trim();
1911
+ const token = readFileSync5(conn.token_file, "utf8").trim();
1914
1912
  const health = await (await fetch(`${httpBase}/healthz`)).json();
1915
1913
  if (conn.identity_id !== health.identity_id || this.identityId !== health.identity_id) {
1916
1914
  throw new Error(
@@ -1986,10 +1984,17 @@ var SecureComsDataPlane = class {
1986
1984
  text: String(frame.text ?? ""),
1987
1985
  senderId: String(frame.sender_id ?? ""),
1988
1986
  channelId,
1989
- messageId
1987
+ messageId,
1988
+ authorization: parseTurnAuthorization(frame.authorization),
1989
+ memory: parseTurnMemory(frame.memory)
1990
1990
  });
1991
1991
  if (reply != null) {
1992
- ws.send(JSON.stringify({ type: "send", channel_id: channelId, text: String(reply) }));
1992
+ ws.send(JSON.stringify({
1993
+ type: "send",
1994
+ channel_id: channelId,
1995
+ text: String(reply),
1996
+ in_reply_to: messageId
1997
+ }));
1993
1998
  }
1994
1999
  }
1995
2000
  } catch (e) {
@@ -2034,6 +2039,33 @@ async function runToolCalls(toolCalls, skills, context) {
2034
2039
  // src/turn-loop.ts
2035
2040
  var DEFAULT_SYSTEM_PROMPT = "You are a SecureComs agent. Respond helpfully and concisely.";
2036
2041
  var DEFAULT_MAX_ITERATIONS = 5;
2042
+ var READ_ONLY_TOOLS = /* @__PURE__ */ new Set(["echo", "web_search", "fetch_url", "sql_query", "current_time"]);
2043
+ function hasReadGrant(auth, toolName) {
2044
+ return auth.sender_membership === "active" && auth.tool_grants.some((grant) => grant.tool === toolName && grant.mode === "read");
2045
+ }
2046
+ function toolIsOffered(auth, toolName) {
2047
+ return hasReadGrant(auth, toolName) && (READ_ONLY_TOOLS.has(toolName) || toolName === "http_request");
2048
+ }
2049
+ function authorizeToolExecution(auth, toolName, argumentsJson, offeredNames) {
2050
+ if (auth.sender_membership !== "active") {
2051
+ return { allowed: false, reason: "Sender membership is not active" };
2052
+ }
2053
+ if (!offeredNames.has(toolName) || !toolIsOffered(auth, toolName)) {
2054
+ return { allowed: false, reason: `Tool "${toolName}" has no active channel read grant` };
2055
+ }
2056
+ if (toolName === "http_request") {
2057
+ try {
2058
+ const args = JSON.parse(argumentsJson);
2059
+ const method = typeof args.method === "string" ? args.method.toUpperCase() : "GET";
2060
+ if (method !== "GET") {
2061
+ return { allowed: false, reason: "State-changing HTTP calls require an exact confirmed operation grant" };
2062
+ }
2063
+ } catch {
2064
+ return { allowed: false, reason: "Tool arguments are not valid JSON" };
2065
+ }
2066
+ }
2067
+ return { allowed: true, reason: `Tool "${toolName}" has an active channel read grant` };
2068
+ }
2037
2069
  function buildHandler(deps) {
2038
2070
  const maxIterations = deps.maxIterations ?? DEFAULT_MAX_ITERATIONS;
2039
2071
  const log = deps.log ?? (() => {
@@ -2042,18 +2074,20 @@ function buildHandler(deps) {
2042
2074
  return async (m) => {
2043
2075
  const text = m.text?.trim() ? m.text : "";
2044
2076
  if (!text) return null;
2045
- log("info", `message from ${m.senderId.slice(0, 8)}: ${text.slice(0, 80)}`);
2077
+ log(
2078
+ "info",
2079
+ `message received (sender=${m.senderId.slice(0, 8)}, channel=${m.channelId.slice(0, 8)}, chars=${text.length})`
2080
+ );
2046
2081
  const memory = memoryFor(m.channelId);
2082
+ memory.replace(m.memory);
2047
2083
  const messages = [{ role: "system", content: deps.systemPromptFor() }];
2048
2084
  const recalled = await memory.recall(text);
2049
2085
  if (recalled) messages.push({ role: "system", content: recalled });
2050
- const longTerm = deps.longTermMemory?.() ?? null;
2051
- if (longTerm) messages.push({ role: "system", content: `Things you have remembered:
2052
- ${longTerm}` });
2053
2086
  messages.push(...memory.getHistory());
2054
2087
  messages.push({ role: "user", content: text });
2055
2088
  memory.addMessage({ role: "user", content: text });
2056
- const tools = deps.tools();
2089
+ const tools = deps.tools().filter((tool) => toolIsOffered(m.authorization, tool.function.name));
2090
+ const offeredNames = new Set(tools.map((tool) => tool.function.name));
2057
2091
  let response = null;
2058
2092
  for (let iteration = 0; iteration < maxIterations; iteration++) {
2059
2093
  const result = await llm.complete(messages, tools.length > 0 ? tools : void 0);
@@ -2066,10 +2100,18 @@ ${longTerm}` });
2066
2100
  content: result.content ?? "",
2067
2101
  tool_calls: result.toolCalls
2068
2102
  });
2069
- const verdicts = result.toolCalls.map((call) => ({
2070
- call,
2071
- verdict: gate.gate(call.function.name)
2072
- }));
2103
+ const verdicts = result.toolCalls.map((call) => {
2104
+ const channelVerdict = authorizeToolExecution(
2105
+ m.authorization,
2106
+ call.function.name,
2107
+ call.function.arguments,
2108
+ offeredNames
2109
+ );
2110
+ return {
2111
+ call,
2112
+ verdict: channelVerdict.allowed ? gate.gate(call.function.name) : channelVerdict
2113
+ };
2114
+ });
2073
2115
  const allowed = verdicts.filter((v) => v.verdict.allowed).map((v) => v.call);
2074
2116
  for (const v of verdicts) {
2075
2117
  if (!v.verdict.allowed) {
@@ -2104,25 +2146,168 @@ ${longTerm}` });
2104
2146
  return null;
2105
2147
  }
2106
2148
  memory.addMessage({ role: "assistant", content: response });
2107
- if (response.length > 50) {
2108
- try {
2109
- await memory.capture(
2110
- `User asked: "${text.slice(0, 100)}". Agent responded: "${response.slice(0, 200)}"`,
2111
- "conversation"
2112
- );
2113
- } catch (e) {
2114
- log("warn", `memory capture failed: ${e instanceof Error ? e.message : String(e)}`);
2115
- }
2116
- }
2117
- log("info", `response: ${response.slice(0, 80)}`);
2149
+ log("info", `response ready (chars=${response.length})`);
2118
2150
  return response;
2119
2151
  };
2120
2152
  }
2121
2153
 
2122
2154
  // src/persona.ts
2123
- import { existsSync as existsSync6, readFileSync as readFileSync8, renameSync, unlinkSync, writeFileSync as writeFileSync3 } from "node:fs";
2124
- import { dirname as dirname3, join as join8 } from "node:path";
2155
+ import { existsSync as existsSync5, readFileSync as readFileSync7, renameSync as renameSync2, unlinkSync, writeFileSync } from "node:fs";
2156
+ import { dirname as dirname3, join as join7 } from "node:path";
2125
2157
  import { fileURLToPath as fileURLToPath2 } from "node:url";
2158
+
2159
+ // src/long-term-memory.ts
2160
+ import { createHash } from "node:crypto";
2161
+ import { chmodSync, existsSync as existsSync4, lstatSync, readFileSync as readFileSync6, readdirSync as readdirSync2, renameSync } from "node:fs";
2162
+ import { join as join6 } from "node:path";
2163
+ var MEMORY_FILE = "MEMORY.md";
2164
+ var QUARANTINE_MEMORY_FILE = "MEMORY.quarantined.md";
2165
+ var LEGACY_MEMORY_SOURCE_MAX_BYTES = 4 * 1024 * 1024;
2166
+ var LEGACY_MEMORY_REPORT_MAX_BYTES = 4 * 1024 * 1024;
2167
+ var LEGACY_MEMORY_REPORT_MAX_SOURCES = 100;
2168
+ function quarantineOrder(a, b) {
2169
+ const index = (name) => {
2170
+ const match = /^MEMORY\.quarantined(?:\.(\d+))?\.md$/.exec(name);
2171
+ return match?.[1] ? Number(match[1]) + 1 : 0;
2172
+ };
2173
+ return index(a) - index(b) || a.localeCompare(b);
2174
+ }
2175
+ function sourceId(kind, channelId, path, bytes) {
2176
+ return createHash("sha256").update(kind).update("\0").update(channelId ?? "").update("\0").update(path).update("\0").update(bytes).digest("hex");
2177
+ }
2178
+ function readLegacySource(path, kind, contentBudget, channelId) {
2179
+ try {
2180
+ let st = lstatSync(path);
2181
+ if (st.isSymbolicLink() || !st.isFile()) return null;
2182
+ if ((st.mode & 511) !== 384) {
2183
+ chmodSync(path, 384);
2184
+ st = lstatSync(path);
2185
+ }
2186
+ if (st.size > LEGACY_MEMORY_SOURCE_MAX_BYTES) {
2187
+ const marker = Buffer.from(`oversize:${st.size}:${st.mtimeMs}:${st.ctimeMs}`, "utf8");
2188
+ return {
2189
+ id: sourceId(kind, channelId, path, marker),
2190
+ kind,
2191
+ ...channelId ? { channelId } : {},
2192
+ path,
2193
+ content: null,
2194
+ bytes: st.size,
2195
+ exportable: false
2196
+ };
2197
+ }
2198
+ if (st.size > contentBudget) return null;
2199
+ const bytes = readFileSync6(path);
2200
+ if (bytes.byteLength > LEGACY_MEMORY_SOURCE_MAX_BYTES || bytes.byteLength > contentBudget) return null;
2201
+ return {
2202
+ id: sourceId(kind, channelId, path, bytes),
2203
+ kind,
2204
+ ...channelId ? { channelId } : {},
2205
+ path,
2206
+ content: bytes.toString("utf8"),
2207
+ bytes: bytes.byteLength,
2208
+ exportable: true
2209
+ };
2210
+ } catch {
2211
+ return null;
2212
+ }
2213
+ }
2214
+ function collectLegacyMemorySources(managedDir, dataDir) {
2215
+ const sources = [];
2216
+ const seen = /* @__PURE__ */ new Set();
2217
+ let contentBudget = LEGACY_MEMORY_REPORT_MAX_BYTES;
2218
+ const add = (path, kind, channelId) => {
2219
+ if (sources.length >= LEGACY_MEMORY_REPORT_MAX_SOURCES) return;
2220
+ const source = readLegacySource(path, kind, contentBudget, channelId);
2221
+ if (!source) return;
2222
+ sources.push(source);
2223
+ if (source.exportable) contentBudget -= source.bytes;
2224
+ };
2225
+ for (const dir of /* @__PURE__ */ new Set([managedDir, dataDir])) {
2226
+ let names;
2227
+ try {
2228
+ names = readdirSync2(dir);
2229
+ } catch {
2230
+ continue;
2231
+ }
2232
+ for (const name of names.sort(quarantineOrder)) {
2233
+ if (!/^MEMORY\.quarantined(?:\.\d+)?\.md$/.test(name)) continue;
2234
+ const path = join6(dir, name);
2235
+ if (seen.has(path)) continue;
2236
+ seen.add(path);
2237
+ add(path, "global-memory");
2238
+ }
2239
+ }
2240
+ const channelsDir = join6(dataDir, "channels");
2241
+ let channelNames;
2242
+ try {
2243
+ channelNames = readdirSync2(channelsDir);
2244
+ } catch {
2245
+ channelNames = [];
2246
+ }
2247
+ for (const channelId of channelNames.sort()) {
2248
+ const channelDir = join6(channelsDir, channelId);
2249
+ for (const [name, kind] of [
2250
+ ["conversation-history.json", "conversation-history"],
2251
+ ["memories.json", "captured-memory"]
2252
+ ]) {
2253
+ add(join6(channelDir, name), kind, channelId);
2254
+ }
2255
+ }
2256
+ return sources;
2257
+ }
2258
+ function legacyMemoryFingerprint(managedDir, dataDir) {
2259
+ const metadata = [];
2260
+ const add = (path) => {
2261
+ try {
2262
+ const st = lstatSync(path);
2263
+ if (!st.isFile() || st.isSymbolicLink()) return;
2264
+ metadata.push(`${path}\0${st.size}\0${st.mtimeMs}\0${st.ctimeMs}`);
2265
+ } catch {
2266
+ }
2267
+ };
2268
+ for (const dir of /* @__PURE__ */ new Set([managedDir, dataDir])) {
2269
+ try {
2270
+ for (const name of readdirSync2(dir).sort(quarantineOrder)) {
2271
+ if (/^MEMORY\.quarantined(?:\.\d+)?\.md$/.test(name)) add(join6(dir, name));
2272
+ }
2273
+ } catch {
2274
+ }
2275
+ }
2276
+ try {
2277
+ const channelsDir = join6(dataDir, "channels");
2278
+ for (const channelId of readdirSync2(channelsDir).sort()) {
2279
+ add(join6(channelsDir, channelId, "conversation-history.json"));
2280
+ add(join6(channelsDir, channelId, "memories.json"));
2281
+ }
2282
+ } catch {
2283
+ }
2284
+ return createHash("sha256").update(metadata.join("\n")).digest("hex");
2285
+ }
2286
+ function nextQuarantinePath(dir) {
2287
+ const first = join6(dir, QUARANTINE_MEMORY_FILE);
2288
+ if (!existsSync4(first)) return first;
2289
+ for (let n = 1; ; n++) {
2290
+ const candidate = join6(dir, `MEMORY.quarantined.${n}.md`);
2291
+ if (!existsSync4(candidate)) return candidate;
2292
+ }
2293
+ }
2294
+ function quarantineLegacyMemory(managedDir, dataDir) {
2295
+ const moved = [];
2296
+ for (const dir of /* @__PURE__ */ new Set([managedDir, dataDir])) {
2297
+ const source = join6(dir, MEMORY_FILE);
2298
+ if (!existsSync4(source)) continue;
2299
+ if (lstatSync(source).isSymbolicLink()) {
2300
+ throw new Error(`refusing to quarantine symlinked legacy memory: ${source}`);
2301
+ }
2302
+ const target = nextQuarantinePath(dir);
2303
+ renameSync(source, target);
2304
+ chmodSync(target, 384);
2305
+ moved.push(target);
2306
+ }
2307
+ return moved;
2308
+ }
2309
+
2310
+ // src/persona.ts
2126
2311
  var PERSONA_MAX_BYTES = 8192;
2127
2312
  var PERSONA_SOURCES = [
2128
2313
  { file: "IDENTITY.md", heading: "## Identity" },
@@ -2137,7 +2322,7 @@ var SAFETY_PREAMBLE = [
2137
2322
  "- Only use the tools you were given; do not invent capabilities."
2138
2323
  ].join("\n");
2139
2324
  function defaultPersonaDir() {
2140
- return join8(dirname3(fileURLToPath2(import.meta.url)), "..", "persona");
2325
+ return join7(dirname3(fileURLToPath2(import.meta.url)), "..", "persona");
2141
2326
  }
2142
2327
  function assemblePrompt(parts) {
2143
2328
  return parts.map((p) => `${p.heading}
@@ -2150,7 +2335,7 @@ function loadPersona(dirs) {
2150
2335
  if (!path) continue;
2151
2336
  let body;
2152
2337
  try {
2153
- body = readFileSync8(path, "utf8");
2338
+ body = readFileSync7(path, "utf8");
2154
2339
  } catch {
2155
2340
  console.warn(`[persona] cannot read ${path}; ignoring it`);
2156
2341
  continue;
@@ -2170,29 +2355,32 @@ function systemPromptFor(dirs, fallback) {
2170
2355
  ${SAFETY_PREAMBLE}`;
2171
2356
  }
2172
2357
  function writeLocalConfigReport(managedDir, dataDir, now, flags) {
2173
- if (!existsSync6(managedDir)) return false;
2358
+ if (!existsSync5(managedDir)) return false;
2174
2359
  const persona = {};
2175
2360
  for (const { file } of PERSONA_SOURCES) {
2176
- const path2 = join8(dataDir, file);
2177
- if (!existsSync6(path2)) continue;
2361
+ const path2 = join7(dataDir, file);
2362
+ if (!existsSync5(path2)) continue;
2178
2363
  try {
2179
- persona[file] = readFileSync8(path2, "utf8").slice(0, PERSONA_MAX_BYTES);
2364
+ persona[file] = readFileSync7(path2, "utf8").slice(0, PERSONA_MAX_BYTES);
2180
2365
  } catch {
2181
2366
  }
2182
2367
  }
2183
2368
  const doc = {
2184
- schema: "securecoms.local-config/1",
2369
+ schema: "securecoms.local-config/2",
2185
2370
  written_at: now.toISOString(),
2186
2371
  has_search_key: flags.hasSearchKey,
2187
2372
  has_llm_key: flags.hasLlmKey,
2188
- persona
2373
+ persona,
2374
+ // Paths stay in this owner-only local handoff file. The daemon validates
2375
+ // each source again and strips `path` before reporting anything upstream.
2376
+ legacy_memory: collectLegacyMemorySources(managedDir, dataDir)
2189
2377
  };
2190
- const path = join8(managedDir, "local-config.json");
2378
+ const path = join7(managedDir, "local-config.json");
2191
2379
  const tmp = `${path}.${process.pid}.tmp`;
2192
2380
  try {
2193
- writeFileSync3(tmp, `${JSON.stringify(doc, null, 2)}
2381
+ writeFileSync(tmp, `${JSON.stringify(doc, null, 2)}
2194
2382
  `, { mode: 384 });
2195
- renameSync(tmp, path);
2383
+ renameSync2(tmp, path);
2196
2384
  } catch (e) {
2197
2385
  try {
2198
2386
  unlinkSync(tmp);
@@ -2206,12 +2394,12 @@ function personaInit(dataDir, defaultsDir, writeFile, exists) {
2206
2394
  const written = [];
2207
2395
  const skipped = [];
2208
2396
  for (const { file } of PERSONA_SOURCES) {
2209
- const target = join8(dataDir, file);
2397
+ const target = join7(dataDir, file);
2210
2398
  if (exists(target)) {
2211
2399
  skipped.push(target);
2212
2400
  continue;
2213
2401
  }
2214
- writeFile(target, readFileSync8(join8(defaultsDir, file), "utf8"));
2402
+ writeFile(target, readFileSync7(join7(defaultsDir, file), "utf8"));
2215
2403
  written.push(target);
2216
2404
  }
2217
2405
  return { written, skipped };
@@ -2219,11 +2407,17 @@ function personaInit(dataDir, defaultsDir, writeFile, exists) {
2219
2407
  function createLocalConfigReporter(managedDir, dataDir, now) {
2220
2408
  let lastWritten = null;
2221
2409
  return (flags) => {
2222
- if (lastWritten !== null && lastWritten.hasSearchKey === flags.hasSearchKey && lastWritten.hasLlmKey === flags.hasLlmKey) {
2410
+ const legacyFingerprint = legacyMemoryFingerprint(managedDir, dataDir);
2411
+ if (lastWritten !== null && lastWritten.hasSearchKey === flags.hasSearchKey && lastWritten.hasLlmKey === flags.hasLlmKey && lastWritten.legacyFingerprint === legacyFingerprint) {
2223
2412
  return false;
2224
2413
  }
2225
2414
  const wrote = writeLocalConfigReport(managedDir, dataDir, now(), flags);
2226
- if (wrote) lastWritten = { ...flags };
2415
+ if (wrote) {
2416
+ lastWritten = {
2417
+ ...flags,
2418
+ legacyFingerprint: legacyMemoryFingerprint(managedDir, dataDir)
2419
+ };
2420
+ }
2227
2421
  return wrote;
2228
2422
  };
2229
2423
  }
@@ -2244,7 +2438,7 @@ function installWarningFilter(proc = process) {
2244
2438
 
2245
2439
  // src/index.ts
2246
2440
  if (process.argv.includes("--version")) {
2247
- const pkg = JSON.parse(readFileSync9(join9(dirname4(dirname4(fileURLToPath3(import.meta.url))), "package.json"), "utf8"));
2441
+ const pkg = JSON.parse(readFileSync8(join8(dirname4(dirname4(fileURLToPath3(import.meta.url))), "package.json"), "utf8"));
2248
2442
  console.log(pkg.version);
2249
2443
  process.exit(0);
2250
2444
  }
@@ -2258,15 +2452,12 @@ function makeLogger(cfg) {
2258
2452
  else console.log(line);
2259
2453
  };
2260
2454
  }
2261
- function safeChannelSegment(channelId) {
2262
- return channelId.replace(/[^A-Za-z0-9_-]/g, "_") || "_";
2263
- }
2264
2455
  function realLlmSourceDeps() {
2265
2456
  return {
2266
2457
  home: homedir4(),
2267
- readFile: (p) => readFileSync9(p, "utf8"),
2458
+ readFile: (p) => readFileSync8(p, "utf8"),
2268
2459
  statFile: (p) => ({ mode: statSync2(p).mode }),
2269
- exists: (p) => existsSync7(p)
2460
+ exists: (p) => existsSync6(p)
2270
2461
  };
2271
2462
  }
2272
2463
  function realServiceDeps() {
@@ -2276,10 +2467,10 @@ function realServiceDeps() {
2276
2467
  const r = spawnSync(cmd, args, { encoding: "utf8" });
2277
2468
  return { status: r.status ?? 1, stdout: r.stdout ?? "", stderr: r.stderr ?? "" };
2278
2469
  },
2279
- writeFile: (p, data) => writeFileSync4(p, data, { mode: 420 }),
2470
+ writeFile: (p, data) => writeFileSync2(p, data, { mode: 420 }),
2280
2471
  removeFile: (p) => rmSync(p, { force: true }),
2281
- mkdirp: (p) => mkdirSync3(p, { recursive: true }),
2282
- fileExists: (p) => existsSync7(p),
2472
+ mkdirp: (p) => mkdirSync(p, { recursive: true }),
2473
+ fileExists: (p) => existsSync6(p),
2283
2474
  canRead: (p) => {
2284
2475
  try {
2285
2476
  accessSync(p, constants.R_OK);
@@ -2294,7 +2485,7 @@ function realServiceDeps() {
2294
2485
  function realResolveNodeDeps() {
2295
2486
  return {
2296
2487
  realpath: (p) => realpathSync(p),
2297
- fileExists: (p) => existsSync7(p),
2488
+ fileExists: (p) => existsSync6(p),
2298
2489
  nodeMajorOf: (p) => {
2299
2490
  const r = spawnSync(p, ["--version"], { encoding: "utf8" });
2300
2491
  if (r.status !== 0) return null;
@@ -2386,12 +2577,12 @@ async function main() {
2386
2577
  return;
2387
2578
  }
2388
2579
  if (personaVerb === "init") {
2389
- mkdirSync3(cfgForPersona.dataDir, { recursive: true });
2580
+ mkdirSync(cfgForPersona.dataDir, { recursive: true });
2390
2581
  const r = personaInit(
2391
2582
  cfgForPersona.dataDir,
2392
2583
  defaultPersonaDir(),
2393
- (p, d) => writeFileSync4(p, d, { mode: 384 }),
2394
- (p) => existsSync7(p)
2584
+ (p, d) => writeFileSync2(p, d, { mode: 384 }),
2585
+ (p) => existsSync6(p)
2395
2586
  );
2396
2587
  for (const p of r.written) console.log(`wrote ${p}`);
2397
2588
  for (const p of r.skipped) console.log(`kept ${p} (already exists)`);
@@ -2419,7 +2610,7 @@ async function main() {
2419
2610
  const memoryFor = (channelId) => {
2420
2611
  let mem = memories.get(channelId);
2421
2612
  if (!mem) {
2422
- mem = new AgentMemory(join9(cfg.dataDir, "channels", safeChannelSegment(channelId)));
2613
+ mem = new AgentMemory();
2423
2614
  memories.set(channelId, mem);
2424
2615
  }
2425
2616
  return mem;
@@ -2438,14 +2629,14 @@ async function main() {
2438
2629
  const path = keyFilePath(which);
2439
2630
  if (!path) return null;
2440
2631
  try {
2441
- return readFileSync9(path, "utf8").trim() || null;
2632
+ return readFileSync8(path, "utf8").trim() || null;
2442
2633
  } catch {
2443
2634
  log("warn", `cannot read the ${which} key file ${path}`);
2444
2635
  return null;
2445
2636
  }
2446
2637
  };
2447
2638
  const configDirs = configSearchPath(cfg.identityId, cfg.dataDir);
2448
- const hasSearchKey = () => hasReadableKeyFile([keyFilePath("brave"), keyFilePath("tavily")], (p) => readFileSync9(p, "utf8"));
2639
+ const hasSearchKey = () => hasReadableKeyFile([keyFilePath("brave"), keyFilePath("tavily")], (p) => readFileSync8(p, "utf8"));
2449
2640
  const hasLlmKey = () => {
2450
2641
  try {
2451
2642
  return hasLlmKeyFor(resolveLlmSource(cfg, isExplicitSource(cfg, process.env), realLlmSourceDeps()));
@@ -2453,7 +2644,7 @@ async function main() {
2453
2644
  return false;
2454
2645
  }
2455
2646
  };
2456
- migrateMemoryIntoManaged(managedDir, cfg.dataDir);
2647
+ quarantineLegacyMemory(managedDir, cfg.dataDir);
2457
2648
  const reportLocalConfig = createLocalConfigReporter(managedDir, cfg.dataDir, () => /* @__PURE__ */ new Date());
2458
2649
  reportLocalConfig({ hasSearchKey: hasSearchKey(), hasLlmKey: hasLlmKey() });
2459
2650
  const builtinCtx = {
@@ -2475,7 +2666,7 @@ async function main() {
2475
2666
  allow: DEFAULT_TOOL_ALLOW
2476
2667
  });
2477
2668
  const gate = createDynamicToolGate(policyFor);
2478
- const offeredTools = () => {
2669
+ const installationEligibleTools = () => {
2479
2670
  const sc = currentSkillsConfig();
2480
2671
  const g = createLocalToolGate(policyFor());
2481
2672
  const searchKey = hasSearchKey();
@@ -2493,16 +2684,15 @@ async function main() {
2493
2684
  skills,
2494
2685
  gate,
2495
2686
  log,
2496
- // All of these are read PER TURN: a persona edit, a new remembered fact, a
2497
- // key file dropped in, or configuration saved in the web app takes effect
2498
- // on the next message, with no restart.
2687
+ // All of these are read PER TURN: a persona edit, a key file dropped in,
2688
+ // or configuration saved in the web app takes effect on the next message,
2689
+ // with no restart.
2499
2690
  systemPromptFor: () => systemPromptFor(configDirs, DEFAULT_SYSTEM_PROMPT),
2500
- longTermMemory: () => readLongTermMemory(configDirs),
2501
- tools: offeredTools
2691
+ tools: installationEligibleTools
2502
2692
  })
2503
2693
  );
2504
2694
  console.log(
2505
- `${bootLine(cfg, isExplicitSource(cfg, process.env), realLlmSourceDeps())}, ${offeredTools().length} skills, tool gate default=${cfg.toolGate.default}`
2695
+ `${bootLine(cfg, isExplicitSource(cfg, process.env), realLlmSourceDeps())}, ${installationEligibleTools().length} configured skills (channel grant required), tool gate default=${cfg.toolGate.default}`
2506
2696
  );
2507
2697
  await plane.start();
2508
2698
  const shutdown = () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@securecoms/agent-runtime",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "securecoms-agent": "dist/bin.js"
@@ -43,7 +43,7 @@
43
43
  "@types/ws": "^8.5.12",
44
44
  "esbuild": "0.24.0",
45
45
  "typescript": "^5.4.0",
46
- "vitest": "^2.0.0",
46
+ "vitest": "^3.2.6",
47
47
  "ws": "^8.21.0"
48
48
  }
49
49
  }
@@ -1,13 +0,0 @@
1
- ---
2
- name: remember
3
- description: Save a durable fact to the agent's long-term memory.
4
- handler: builtin:remember
5
- parameters:
6
- type: object
7
- properties:
8
- fact: { type: string, description: One durable fact of at most 300 characters. }
9
- required: [fact]
10
- ---
11
- Save a durable fact about the people or the business you work for — a renewal
12
- date, a preference, a name. Not a passing detail from this conversation, and
13
- never a password, key or token.