@securecoms/agent-runtime 0.12.1 → 0.12.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +19 -5
  2. package/dist/bin.js +309 -179
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -207,9 +207,13 @@ the runtime works as follows in the owner's private chat:
207
207
  recalls memories.
208
208
  - **The memory tool gate.** On every owner private-chat turn, and on any turn
209
209
  that carries memories, the gate checks each tool call before it runs:
210
+ <!-- [COPY, approved by Eric 2026-10-12] skills spec 2026-10-12 section 7 C-3. -->
211
+ - What a tool may do is decided by its handler, not its name. A skill file
212
+ that reuses a built-in's name with a different handler is not loaded.
210
213
  - `current_time`, `echo` and `memory_note` run as usual.
211
- - `web_search`, `fetch_url`, `http_request` and `sql_query` run only when
212
- their arguments pass the gate. The gate refuses an argument that holds a
214
+ - Network reads (`web_search`, `fetch_url`, `sql_query`, `http_request` GET)
215
+ run only when the values they will send pass the gate. Numbers are checked
216
+ too. The gate refuses an argument that holds a
213
217
  number the owner did not type in this message (digits, number words, and
214
218
  Roman numerals longer than one letter); a URL, an e-mail address or a
215
219
  base64-like token longer than 24 characters that is not in the owner's
@@ -222,9 +226,14 @@ the runtime works as follows in the owner's private chat:
222
226
  number (`YEAR_WINDOW` = 1). It still counts toward the 3-word runs and the
223
227
  200-character budget.
224
228
  - `http_request` sends only GET on these turns. Any other method is refused.
225
- - Every other tool, skills included, is refused on these turns.
226
- - The gate stops copying of numbers, links and exact wording. It does not
227
- stop the model from rewording a memory, and it binds only this runtime.
229
+ - Anything that sends, changes or deletes something is refused on these
230
+ turns.
231
+ - The bridge decides which tools a turn may use. Today it allows only
232
+ `current_time` and `web_search` in the owner's private chat, and no custom
233
+ skill on any turn.
234
+ - The gate stops a model from pasting numbers, links and exact wording from
235
+ memory into a tool. It does not stop a model that rewords a memory or
236
+ deliberately encodes it in other words, and it binds only this runtime.
228
237
 
229
238
  **With bridge 0.28 or older.** A turn without the bridge's `memory_unified`
230
239
  reply works as in runtime 0.11.0: `memory_note` saves, replaces or forgets a
@@ -233,6 +242,11 @@ not run.
233
242
 
234
243
  ## Adding a skill
235
244
 
245
+ <!-- [COPY, approved by Eric 2026-10-12] skills spec 2026-10-12 section 7 C-3. -->
246
+ Note: a skill you add here is loaded but not offered to the model yet. The
247
+ bridge grants tools per turn, and it does not grant custom skills on any turn.
248
+ The six built-ins below are the tools agents use today.
249
+
236
250
  A skill is either a JSON manifest or a `SKILL.md` file, dropped under
237
251
  `SECURECOMS_SKILLS_DIR` in one of three layouts:
238
252
 
package/dist/bin.js CHANGED
@@ -2585,8 +2585,12 @@ async function readCapped(res, maxBytes) {
2585
2585
  }
2586
2586
 
2587
2587
  // src/builtins/fetch-url.ts
2588
+ function coerceFetchUrlArgs(args) {
2589
+ const url = typeof args.url === "string" ? args.url.trim() : "";
2590
+ return { url, outbound: { fields: [{ key: "url", value: url, source: args.url }], runs: [], patterns: [], sendable: true } };
2591
+ }
2588
2592
  var fetchUrl = async (args, ctx) => {
2589
- const raw = typeof args.url === "string" ? args.url.trim() : "";
2593
+ const raw = coerceFetchUrlArgs(args).url;
2590
2594
  if (!raw) return toolError('fetch_url needs a "url" string');
2591
2595
  let parsed;
2592
2596
  try {
@@ -2620,8 +2624,38 @@ function httpMethodIsGet(args) {
2620
2624
  if (method === void 0 || method === null) return true;
2621
2625
  return typeof method === "string" && method.toUpperCase() === "GET";
2622
2626
  }
2623
- var httpRequest = async (args, ctx) => {
2627
+ function coerceHttpRequestArgs(args) {
2624
2628
  const name = typeof args.name === "string" ? args.name : "";
2629
+ const method = String(args.method ?? "GET").toUpperCase();
2630
+ const rawPath = String(args.path ?? "");
2631
+ const body = typeof args.body === "string" ? args.body : void 0;
2632
+ const noFragment = rawPath.split("#")[0];
2633
+ const queryAt = noFragment.indexOf("?");
2634
+ const pathPart = queryAt === -1 ? noFragment : noFragment.slice(0, queryAt);
2635
+ const queryPart = queryAt === -1 ? "" : noFragment.slice(queryAt + 1);
2636
+ let segments;
2637
+ let decodedPath = "";
2638
+ try {
2639
+ segments = pathPart.split("/").filter((s) => s && s !== "." && s !== "..").map((s) => decodeURIComponent(s));
2640
+ decodedPath = pathPart.split("/").map((s) => decodeURIComponent(s)).join("/");
2641
+ } catch {
2642
+ segments = null;
2643
+ }
2644
+ const query = [...new URLSearchParams(queryPart)];
2645
+ const fields = [
2646
+ { key: "name", value: name, source: args.name },
2647
+ { key: "method", value: method, source: args.method }
2648
+ ];
2649
+ for (const s of segments ?? []) fields.push({ key: "path", value: s, source: args.path });
2650
+ for (const [k, v] of query) fields.push({ key: "path", value: k, source: args.path }, { key: "path", value: v, source: args.path });
2651
+ if (body !== void 0) fields.push({ key: "body", value: body, source: args.body });
2652
+ const runs2 = [[...segments ?? [], ...query.flat()].join(" ")].filter((r) => r !== "");
2653
+ const patterns = [decodedPath, queryPart, query.length > 0 ? String(new URLSearchParams(query)) : ""].filter((s) => s !== "");
2654
+ return { name, method, rawPath, segments, queryPart, query, body, outbound: { fields, runs: runs2, patterns, sendable: segments !== null } };
2655
+ }
2656
+ var httpRequest = async (args, ctx) => {
2657
+ const call = coerceHttpRequestArgs(args);
2658
+ const name = call.name;
2625
2659
  const endpoint = ctx.skillsConfig.endpoints[name];
2626
2660
  if (!endpoint) {
2627
2661
  return toolError(
@@ -2631,7 +2665,7 @@ var httpRequest = async (args, ctx) => {
2631
2665
  if (endpoint.headersFileRefused) {
2632
2666
  return toolError(secretPathRefusedMessage(`the headers file for endpoint "${name}"`));
2633
2667
  }
2634
- const method = String(args.method ?? "GET").toUpperCase();
2668
+ const method = call.method;
2635
2669
  if (!METHODS.has(method)) return toolError(`unsupported method "${method}": use GET, POST, PUT or DELETE`);
2636
2670
  let base;
2637
2671
  try {
@@ -2639,7 +2673,7 @@ var httpRequest = async (args, ctx) => {
2639
2673
  } catch {
2640
2674
  return toolError(`endpoint "${name}" has an unusable baseUrl`);
2641
2675
  }
2642
- const rawPath = String(args.path ?? "");
2676
+ const rawPath = call.rawPath;
2643
2677
  let asWritten;
2644
2678
  try {
2645
2679
  asWritten = new URL(rawPath, base);
@@ -2649,18 +2683,11 @@ var httpRequest = async (args, ctx) => {
2649
2683
  if (asWritten.origin !== base.origin) {
2650
2684
  return toolError(`path must stay on the configured endpoint (${base.origin}): "${rawPath}" points elsewhere`);
2651
2685
  }
2652
- const noFragment = rawPath.split("#")[0];
2653
- const queryAt = noFragment.indexOf("?");
2654
- const pathPart = queryAt === -1 ? noFragment : noFragment.slice(0, queryAt);
2655
- const queryPart = queryAt === -1 ? "" : noFragment.slice(queryAt + 1);
2656
- let segments;
2657
- try {
2658
- segments = pathPart.split("/").filter((s) => s && s !== "." && s !== "..").map((s) => encodeURIComponent(decodeURIComponent(s)));
2659
- } catch {
2686
+ if (call.segments === null) {
2660
2687
  return toolError(`"${rawPath}" is not a usable path for endpoint "${name}"`);
2661
2688
  }
2662
- const url = new URL(segments.join("/"), base);
2663
- if (queryPart) url.search = new URLSearchParams(queryPart).toString();
2689
+ const url = new URL(call.segments.map((s) => encodeURIComponent(s)).join("/"), base);
2690
+ if (call.queryPart) url.search = new URLSearchParams(call.query).toString();
2664
2691
  const headers = { accept: "application/json" };
2665
2692
  if (endpoint.headersFile) {
2666
2693
  try {
@@ -2671,7 +2698,7 @@ var httpRequest = async (args, ctx) => {
2671
2698
  return toolError(`cannot read the headers file for endpoint "${name}"`);
2672
2699
  }
2673
2700
  }
2674
- const body = typeof args.body === "string" ? args.body : void 0;
2701
+ const body = call.body;
2675
2702
  if (body) headers["content-type"] = headers["content-type"] ?? "application/json";
2676
2703
  try {
2677
2704
  const res = await safeFetch(url.toString(), {
@@ -3068,8 +3095,18 @@ function envelope(columns, rows, truncated, reason) {
3068
3095
  }
3069
3096
  return toolError("database result metadata exceeded the response limit");
3070
3097
  }
3071
- var sqlQuery = async (args, ctx) => {
3098
+ function coerceSqlQueryArgs(args) {
3072
3099
  const name = typeof args.name === "string" ? args.name : "";
3100
+ const sql = String(args.sql ?? "");
3101
+ return {
3102
+ name,
3103
+ sql,
3104
+ outbound: { fields: [{ key: "name", value: name, source: args.name }, { key: "sql", value: sql, source: args.sql }], runs: [], patterns: [], sendable: true }
3105
+ };
3106
+ }
3107
+ var sqlQuery = async (args, ctx) => {
3108
+ const call = coerceSqlQueryArgs(args);
3109
+ const name = call.name;
3073
3110
  const db = ctx.skillsConfig.databases[name];
3074
3111
  if (!db) {
3075
3112
  return toolError(
@@ -3081,7 +3118,7 @@ var sqlQuery = async (args, ctx) => {
3081
3118
  }
3082
3119
  let sql;
3083
3120
  try {
3084
- sql = assertReadOnlySelect(String(args.sql ?? ""));
3121
+ sql = assertReadOnlySelect(call.sql);
3085
3122
  } catch (e) {
3086
3123
  return toolError(e instanceof Error ? e.message : String(e));
3087
3124
  }
@@ -3111,10 +3148,16 @@ var sqlQuery = async (args, ctx) => {
3111
3148
 
3112
3149
  // src/builtins/web-search.ts
3113
3150
  var MIN_RESULTS = 5;
3114
- var webSearch = async (args, ctx) => {
3151
+ function coerceWebSearchArgs(args) {
3115
3152
  const query = typeof args.query === "string" ? args.query.trim() : "";
3116
- if (!query) return toolError('web_search needs a non-empty "query"');
3117
3153
  const count = Math.min(10, Math.max(MIN_RESULTS, Number(args.count) || MIN_RESULTS));
3154
+ const fields = [{ key: "query", value: query, source: args.query }];
3155
+ if (args.count !== void 0) fields.push({ key: "count", value: String(args.count), source: args.count });
3156
+ return { query, count, outbound: { fields, runs: [], patterns: [], sendable: true } };
3157
+ }
3158
+ var webSearch = async (args, ctx) => {
3159
+ const { query, count } = coerceWebSearchArgs(args);
3160
+ if (!query) return toolError('web_search needs a non-empty "query"');
3118
3161
  const brave = ctx.readKeyFile("brave");
3119
3162
  const tavily = brave ? null : ctx.readKeyFile("tavily");
3120
3163
  if (!brave && !tavily) {
@@ -3261,6 +3304,174 @@ var BUILTINS = {
3261
3304
  sql_query: sqlQuery
3262
3305
  };
3263
3306
 
3307
+ // src/memory-note.ts
3308
+ var MEMORY_NOTE = "memory_note";
3309
+ var MEMORY_NOTE_TOOL = {
3310
+ type: "function",
3311
+ function: {
3312
+ name: MEMORY_NOTE,
3313
+ description: "Save, change or delete one short note in this chat's saved notes. Save only what the owner asked you to keep. First check the saved notes already shown to you: if the fact is already saved, do not call this tool; just tell the owner it is already remembered. `text` must be copied word for word from the owner's CURRENT message (a contiguous part of it is fine). You cannot save something said earlier, said in a call, found by a tool, or your own summary. If the owner asks you to remember something from earlier, ask them to type it in one short message. Never store credentials, keys or passwords. To change or delete a note, pass its id: [n7] is id 7. To forget a remembered message shown to you as [r12], pass op forget with id \"r12\". You can make only one change (replace or forget) per owner message: One change per message. Ask the owner to confirm the next one.",
3314
+ parameters: {
3315
+ type: "object",
3316
+ properties: {
3317
+ op: { type: "string", enum: ["add", "replace", "forget"] },
3318
+ text: { type: "string" },
3319
+ id: { anyOf: [{ type: "integer", minimum: 1 }, { type: "string", pattern: "^r[1-9][0-9]*$" }] }
3320
+ },
3321
+ required: ["op"],
3322
+ additionalProperties: false
3323
+ }
3324
+ }
3325
+ };
3326
+ var MEMORY_PIN_TOOL = {
3327
+ type: "function",
3328
+ function: {
3329
+ name: MEMORY_NOTE,
3330
+ description: (
3331
+ // [COPY, NEW] model-facing. Signed off by Eric 2026-10-11 with the final-review copy fixes (plan Task 37).
3332
+ `Pin one short fact the owner asked you to keep, or forget one remembered item. Pin only when the owner asks. You may reword, but every word that carries meaning must be a word the owner typed in this message, in a pinned fact, or in the owner's earlier typed words shown to you (words spoken on a call do not count); otherwise the pin is refused. Words the owner speaks on a call in this private chat may be remembered automatically, but they cannot be pinned. Never pin credentials, keys or passwords. To forget an item shown to you as [r12], pass op forget with id "r12". You can make only one change per owner message.`
3333
+ ),
3334
+ parameters: {
3335
+ type: "object",
3336
+ properties: {
3337
+ op: { type: "string", enum: ["add", "forget"] },
3338
+ text: { type: "string" },
3339
+ id: { type: "string", pattern: "^r[1-9][0-9]*$" }
3340
+ },
3341
+ required: ["op"],
3342
+ additionalProperties: false
3343
+ }
3344
+ }
3345
+ };
3346
+ function memoryNoteAllowed(auth, m) {
3347
+ return auth.sender_membership === "active" && auth.memory_write === true && m.memoryNote !== void 0;
3348
+ }
3349
+ var MEMORY_NOTE_MAX_TEXT_UNITS = 2 * 600;
3350
+ var REFUSAL = {
3351
+ too_long: "Not saved. The note text is too long. Ask the owner to type the fact in one short message.",
3352
+ provenance: `Not saved. A note must be copied word for word from the owner's current message, and that message must be short. Ask the owner to type the fact in one short message, for example "Remember: ...".`,
3353
+ secret: "Not saved. The text looks like a password or a key. Never store credentials.",
3354
+ cap: "Not saved. The notes are full or the note is too long. Replace or forget older notes first.",
3355
+ audience: "Not saved. Notes can be saved only from the owner's own typed message in their private chat.",
3356
+ cleared: "Not saved. The owner cleared the notes after this message arrived.",
3357
+ // [COPY] spec 7b §9.1.
3358
+ budget: "One change per message. Ask the owner to confirm the next one.",
3359
+ // [COPY, NEW] Approved by Eric 2026-10-09. The bridge emits it when the chat's memory was purged after the write was queued.
3360
+ purged: "Not saved. This chat's memory was removed after this message arrived.",
3361
+ mode: "Not saved. Notes are turned off for this agent right now."
3362
+ };
3363
+ var UNIFIED_PIN_CAP = 200;
3364
+ var UNIFIED_PIN_MAX_CHARS = 400;
3365
+ var UNIFIED_REFUSAL = {
3366
+ // [COPY, NEW] Mini live check 2026-10-11: the model asked the owner to retype a fact they had just typed. Approved by Eric 2026-10-11.
3367
+ not_owner_words: "Not pinned. A pinned fact must use the owner's own typed words from this message or from their earlier typed words shown to you. Words spoken on a call do not count. Call memory_note once more with the fact copied exactly from the owner's words in this message. Only if that is refused too, ask the owner to type the fact.",
3368
+ cap: `Not pinned. There are already ${UNIFIED_PIN_CAP} pinned facts, or the fact is longer than ${UNIFIED_PIN_MAX_CHARS} characters. Ask the owner to forget a pinned fact first, or to shorten it.`,
3369
+ secret: REFUSAL.secret,
3370
+ audience: "Not pinned. Facts can be pinned only from the owner's own typed message in their private chat.",
3371
+ cleared: REFUSAL.cleared,
3372
+ budget: REFUSAL.budget,
3373
+ purged: REFUSAL.purged,
3374
+ mode: "Not pinned. Memory is turned off for this agent right now.",
3375
+ invalid: "Not changed. The fact is already pinned or has no usable words, the id was not shown with this message, or the request was not a pin or a forget.",
3376
+ // [COPY, NEW] fix round 1: unified turns never fall back to the legacy "replace" wording.
3377
+ too_long: "Not pinned. The fact is too long. Ask the owner to type the fact in one short message.",
3378
+ provenance: "Not pinned. A pinned fact must use the owner's own typed words from this message or from their earlier typed words shown to you. Words spoken on a call do not count. Call memory_note once more with the fact copied exactly from the owner's words in this message. Only if that is refused too, ask the owner to type the fact in one short message.",
3379
+ failed: "Not changed. The memory could not be updated right now. Try again later.",
3380
+ // [COPY, NEW] C26: the bridge also emits these; no spec wording, so plain wording in the style of REFUSAL.
3381
+ message: "Not pinned. The owner's message could not be used for this change. Ask the owner to type the fact again.",
3382
+ store: "Not changed. The memory could not be saved right now. Try again later."
3383
+ };
3384
+ var UNIFIED_ARGS_ERROR = 'memory_note needs op add with text, or op forget with an id like "r12"';
3385
+ function parseArgs(raw) {
3386
+ let parsed;
3387
+ try {
3388
+ parsed = JSON.parse(raw);
3389
+ } catch {
3390
+ return null;
3391
+ }
3392
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
3393
+ const a = parsed;
3394
+ const id = typeof a.id === "number" && Number.isSafeInteger(a.id) && a.id >= 1 ? a.id : void 0;
3395
+ const text = typeof a.text === "string" && a.text.length > 0 ? a.text : void 0;
3396
+ if (a.op === "add" && text !== void 0 && a.id === void 0) return { op: "add", text };
3397
+ if (a.op === "replace" && text !== void 0 && id !== void 0) return { op: "replace", text, id };
3398
+ const recallId = typeof a.id === "string" && /^r[1-9][0-9]{0,15}$/.test(a.id) && Number.isSafeInteger(Number(a.id.slice(1))) ? a.id : void 0;
3399
+ if (a.op === "forget" && a.text === void 0) {
3400
+ if (id !== void 0) return { op: "forget", id };
3401
+ if (recallId !== void 0) return { op: "forget", id: recallId };
3402
+ }
3403
+ return null;
3404
+ }
3405
+ async function runMemoryNoteCalls(calls, m, ctx = {}) {
3406
+ const clock = ctx.clock ?? (() => performance.now());
3407
+ const out = [];
3408
+ const refusal = ctx.unified ? UNIFIED_REFUSAL : REFUSAL;
3409
+ for (const call of calls) {
3410
+ const started = clock();
3411
+ let content;
3412
+ let cancelled = false;
3413
+ if (ctx.signal?.aborted) {
3414
+ cancelled = true;
3415
+ content = toolError("Tool execution cancelled");
3416
+ } else {
3417
+ const req = parseArgs(call.function.arguments);
3418
+ if (!req || !m.memoryNote) content = toolError(ctx.unified ? UNIFIED_ARGS_ERROR : "memory_note needs op add with text, replace with id and text, or forget with id");
3419
+ else if (req.text !== void 0 && req.text.length > MEMORY_NOTE_MAX_TEXT_UNITS) {
3420
+ content = JSON.stringify({ saved: false, reason: "too_long", message: refusal.too_long });
3421
+ } else {
3422
+ let r;
3423
+ try {
3424
+ r = await m.memoryNote(req);
3425
+ } catch {
3426
+ r = { ok: false, reason: "failed" };
3427
+ }
3428
+ if (!r.ok && r.reason === "aborted") cancelled = true;
3429
+ const reason = r.reason ?? "unknown";
3430
+ content = JSON.stringify(r.ok ? { saved: true, op: req.op, id: r.id } : { saved: false, reason, message: Object.hasOwn(refusal, reason) ? refusal[reason] : "Not saved." });
3431
+ }
3432
+ }
3433
+ out.push({ role: "tool", content, tool_call_id: call.id });
3434
+ ctx.onTiming?.({ tool_call_id: call.id, ms: clock() - started, ok: !content.includes('"saved":false') && !content.startsWith('{"error"'), cancelled });
3435
+ }
3436
+ return out;
3437
+ }
3438
+
3439
+ // src/effect-class.ts
3440
+ var BUILTIN_EFFECT_CLASS = Object.freeze({
3441
+ current_time: "local",
3442
+ echo: "local",
3443
+ web_search: "read",
3444
+ fetch_url: "read",
3445
+ sql_query: "read",
3446
+ http_request: "per_call"
3447
+ });
3448
+ var BUILTIN_NAMES = new Set(Object.keys(BUILTINS));
3449
+ function builtinNameMismatch(manifest) {
3450
+ if (manifest.name === MEMORY_NOTE) return true;
3451
+ if (manifest.handler.startsWith("builtin:")) return manifest.handler.slice("builtin:".length) !== manifest.name;
3452
+ return typeof manifest.name === "string" && BUILTIN_NAMES.has(manifest.name);
3453
+ }
3454
+ function effectClassOf(manifest, args) {
3455
+ if (!manifest) return "uninspectable";
3456
+ const handler = manifest.handler;
3457
+ if (handler.startsWith("builtin:")) {
3458
+ const name = handler.slice("builtin:".length);
3459
+ if (!Object.hasOwn(BUILTIN_EFFECT_CLASS, name)) return "uninspectable";
3460
+ const cls = BUILTIN_EFFECT_CLASS[name];
3461
+ if (cls === "per_call") return httpMethodIsGet(args) ? "read" : "side_effect";
3462
+ return cls;
3463
+ }
3464
+ if (handler.startsWith("script:")) {
3465
+ return manifest.effects === "read" ? "uninspectable" : "side_effect";
3466
+ }
3467
+ return "uninspectable";
3468
+ }
3469
+ function manifestOfferableOnGatedTurn(manifest) {
3470
+ if (manifest?.handler === "builtin:http_request") return true;
3471
+ const cls = effectClassOf(manifest, void 0);
3472
+ return cls === "local" || cls === "read";
3473
+ }
3474
+
3264
3475
  // src/side-effect-ledger.ts
3265
3476
  import { createHash } from "node:crypto";
3266
3477
  import { chmodSync, mkdirSync as mkdirSync3 } from "node:fs";
@@ -3446,6 +3657,10 @@ var SkillInvoker = class {
3446
3657
  }
3447
3658
  /** Later file wins, with a warning naming both paths (spec §3). */
3448
3659
  register(manifest, path) {
3660
+ if (builtinNameMismatch(manifest)) {
3661
+ writeServiceLog("warn", "skills.builtin_name_mismatch");
3662
+ return;
3663
+ }
3449
3664
  const prior = this.sources.get(manifest.name);
3450
3665
  if (prior) writeServiceLog("warn", "skills.duplicate_replaced");
3451
3666
  this.skills.set(manifest.name, manifest);
@@ -3503,6 +3718,18 @@ var SkillInvoker = class {
3503
3718
  }
3504
3719
  return out;
3505
3720
  }
3721
+ /** R1, R2: this call's effect class, from the loaded manifest's handler, never from the name. */
3722
+ effectClass(name, args) {
3723
+ return effectClassOf(this.skills.get(name), args);
3724
+ }
3725
+ /** R4: the handler the memory tool gate inspects for; undefined when no manifest of that name is loaded. */
3726
+ handlerOf(name) {
3727
+ return this.skills.get(name)?.handler;
3728
+ }
3729
+ /** R5: false for a tool the gated-turn offer filter drops. */
3730
+ offerableOnGatedTurn(name) {
3731
+ return manifestOfferableOnGatedTurn(this.skills.get(name));
3732
+ }
3506
3733
  /**
3507
3734
  * Execute a skill by name with the given arguments.
3508
3735
  *
@@ -4460,138 +4687,6 @@ async function runToolCalls(toolCalls, skills, context) {
4460
4687
  return out;
4461
4688
  }
4462
4689
 
4463
- // src/memory-note.ts
4464
- var MEMORY_NOTE = "memory_note";
4465
- var MEMORY_NOTE_TOOL = {
4466
- type: "function",
4467
- function: {
4468
- name: MEMORY_NOTE,
4469
- description: "Save, change or delete one short note in this chat's saved notes. Save only what the owner asked you to keep. First check the saved notes already shown to you: if the fact is already saved, do not call this tool; just tell the owner it is already remembered. `text` must be copied word for word from the owner's CURRENT message (a contiguous part of it is fine). You cannot save something said earlier, said in a call, found by a tool, or your own summary. If the owner asks you to remember something from earlier, ask them to type it in one short message. Never store credentials, keys or passwords. To change or delete a note, pass its id: [n7] is id 7. To forget a remembered message shown to you as [r12], pass op forget with id \"r12\". You can make only one change (replace or forget) per owner message: One change per message. Ask the owner to confirm the next one.",
4470
- parameters: {
4471
- type: "object",
4472
- properties: {
4473
- op: { type: "string", enum: ["add", "replace", "forget"] },
4474
- text: { type: "string" },
4475
- id: { anyOf: [{ type: "integer", minimum: 1 }, { type: "string", pattern: "^r[1-9][0-9]*$" }] }
4476
- },
4477
- required: ["op"],
4478
- additionalProperties: false
4479
- }
4480
- }
4481
- };
4482
- var MEMORY_PIN_TOOL = {
4483
- type: "function",
4484
- function: {
4485
- name: MEMORY_NOTE,
4486
- description: (
4487
- // [COPY, NEW] model-facing. Signed off by Eric 2026-10-11 with the final-review copy fixes (plan Task 37).
4488
- `Pin one short fact the owner asked you to keep, or forget one remembered item. Pin only when the owner asks. You may reword, but every word that carries meaning must be a word the owner typed in this message, in a pinned fact, or in the owner's earlier typed words shown to you (words spoken on a call do not count); otherwise the pin is refused. Words the owner speaks on a call in this private chat may be remembered automatically, but they cannot be pinned. Never pin credentials, keys or passwords. To forget an item shown to you as [r12], pass op forget with id "r12". You can make only one change per owner message.`
4489
- ),
4490
- parameters: {
4491
- type: "object",
4492
- properties: {
4493
- op: { type: "string", enum: ["add", "forget"] },
4494
- text: { type: "string" },
4495
- id: { type: "string", pattern: "^r[1-9][0-9]*$" }
4496
- },
4497
- required: ["op"],
4498
- additionalProperties: false
4499
- }
4500
- }
4501
- };
4502
- function memoryNoteAllowed(auth, m) {
4503
- return auth.sender_membership === "active" && auth.memory_write === true && m.memoryNote !== void 0;
4504
- }
4505
- var MEMORY_NOTE_MAX_TEXT_UNITS = 2 * 600;
4506
- var REFUSAL = {
4507
- too_long: "Not saved. The note text is too long. Ask the owner to type the fact in one short message.",
4508
- provenance: `Not saved. A note must be copied word for word from the owner's current message, and that message must be short. Ask the owner to type the fact in one short message, for example "Remember: ...".`,
4509
- secret: "Not saved. The text looks like a password or a key. Never store credentials.",
4510
- cap: "Not saved. The notes are full or the note is too long. Replace or forget older notes first.",
4511
- audience: "Not saved. Notes can be saved only from the owner's own typed message in their private chat.",
4512
- cleared: "Not saved. The owner cleared the notes after this message arrived.",
4513
- // [COPY] spec 7b §9.1.
4514
- budget: "One change per message. Ask the owner to confirm the next one.",
4515
- // [COPY, NEW] Approved by Eric 2026-10-09. The bridge emits it when the chat's memory was purged after the write was queued.
4516
- purged: "Not saved. This chat's memory was removed after this message arrived.",
4517
- mode: "Not saved. Notes are turned off for this agent right now."
4518
- };
4519
- var UNIFIED_PIN_CAP = 200;
4520
- var UNIFIED_PIN_MAX_CHARS = 400;
4521
- var UNIFIED_REFUSAL = {
4522
- // [COPY, NEW] Mini live check 2026-10-11: the model asked the owner to retype a fact they had just typed. Approved by Eric 2026-10-11.
4523
- not_owner_words: "Not pinned. A pinned fact must use the owner's own typed words from this message or from their earlier typed words shown to you. Words spoken on a call do not count. Call memory_note once more with the fact copied exactly from the owner's words in this message. Only if that is refused too, ask the owner to type the fact.",
4524
- cap: `Not pinned. There are already ${UNIFIED_PIN_CAP} pinned facts, or the fact is longer than ${UNIFIED_PIN_MAX_CHARS} characters. Ask the owner to forget a pinned fact first, or to shorten it.`,
4525
- secret: REFUSAL.secret,
4526
- audience: "Not pinned. Facts can be pinned only from the owner's own typed message in their private chat.",
4527
- cleared: REFUSAL.cleared,
4528
- budget: REFUSAL.budget,
4529
- purged: REFUSAL.purged,
4530
- mode: "Not pinned. Memory is turned off for this agent right now.",
4531
- invalid: "Not changed. The fact is already pinned or has no usable words, the id was not shown with this message, or the request was not a pin or a forget.",
4532
- // [COPY, NEW] fix round 1: unified turns never fall back to the legacy "replace" wording.
4533
- too_long: "Not pinned. The fact is too long. Ask the owner to type the fact in one short message.",
4534
- provenance: "Not pinned. A pinned fact must use the owner's own typed words from this message or from their earlier typed words shown to you. Words spoken on a call do not count. Call memory_note once more with the fact copied exactly from the owner's words in this message. Only if that is refused too, ask the owner to type the fact in one short message.",
4535
- failed: "Not changed. The memory could not be updated right now. Try again later.",
4536
- // [COPY, NEW] C26: the bridge also emits these; no spec wording, so plain wording in the style of REFUSAL.
4537
- message: "Not pinned. The owner's message could not be used for this change. Ask the owner to type the fact again.",
4538
- store: "Not changed. The memory could not be saved right now. Try again later."
4539
- };
4540
- var UNIFIED_ARGS_ERROR = 'memory_note needs op add with text, or op forget with an id like "r12"';
4541
- function parseArgs(raw) {
4542
- let parsed;
4543
- try {
4544
- parsed = JSON.parse(raw);
4545
- } catch {
4546
- return null;
4547
- }
4548
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
4549
- const a = parsed;
4550
- const id = typeof a.id === "number" && Number.isSafeInteger(a.id) && a.id >= 1 ? a.id : void 0;
4551
- const text = typeof a.text === "string" && a.text.length > 0 ? a.text : void 0;
4552
- if (a.op === "add" && text !== void 0 && a.id === void 0) return { op: "add", text };
4553
- if (a.op === "replace" && text !== void 0 && id !== void 0) return { op: "replace", text, id };
4554
- const recallId = typeof a.id === "string" && /^r[1-9][0-9]{0,15}$/.test(a.id) && Number.isSafeInteger(Number(a.id.slice(1))) ? a.id : void 0;
4555
- if (a.op === "forget" && a.text === void 0) {
4556
- if (id !== void 0) return { op: "forget", id };
4557
- if (recallId !== void 0) return { op: "forget", id: recallId };
4558
- }
4559
- return null;
4560
- }
4561
- async function runMemoryNoteCalls(calls, m, ctx = {}) {
4562
- const clock = ctx.clock ?? (() => performance.now());
4563
- const out = [];
4564
- const refusal = ctx.unified ? UNIFIED_REFUSAL : REFUSAL;
4565
- for (const call of calls) {
4566
- const started = clock();
4567
- let content;
4568
- let cancelled = false;
4569
- if (ctx.signal?.aborted) {
4570
- cancelled = true;
4571
- content = toolError("Tool execution cancelled");
4572
- } else {
4573
- const req = parseArgs(call.function.arguments);
4574
- if (!req || !m.memoryNote) content = toolError(ctx.unified ? UNIFIED_ARGS_ERROR : "memory_note needs op add with text, replace with id and text, or forget with id");
4575
- else if (req.text !== void 0 && req.text.length > MEMORY_NOTE_MAX_TEXT_UNITS) {
4576
- content = JSON.stringify({ saved: false, reason: "too_long", message: refusal.too_long });
4577
- } else {
4578
- let r;
4579
- try {
4580
- r = await m.memoryNote(req);
4581
- } catch {
4582
- r = { ok: false, reason: "failed" };
4583
- }
4584
- if (!r.ok && r.reason === "aborted") cancelled = true;
4585
- const reason = r.reason ?? "unknown";
4586
- content = JSON.stringify(r.ok ? { saved: true, op: req.op, id: r.id } : { saved: false, reason, message: Object.hasOwn(refusal, reason) ? refusal[reason] : "Not saved." });
4587
- }
4588
- }
4589
- out.push({ role: "tool", content, tool_call_id: call.id });
4590
- ctx.onTiming?.({ tool_call_id: call.id, ms: clock() - started, ok: !content.includes('"saved":false') && !content.startsWith('{"error"'), cancelled });
4591
- }
4592
- return out;
4593
- }
4594
-
4595
4690
  // src/memory-text.ts
4596
4691
  var QUOTES = {
4597
4692
  "\u2018": "'",
@@ -4628,12 +4723,18 @@ var STOP_WORDS = new Set(
4628
4723
  );
4629
4724
 
4630
4725
  // src/memory-tool-gate.ts
4631
- var GATED_TOOLS = /* @__PURE__ */ new Set(["web_search", "fetch_url", "http_request", "sql_query"]);
4632
- var NON_NETWORK_TOOLS = /* @__PURE__ */ new Set(["current_time", "echo", "memory_note"]);
4726
+ var GATED_TOOLS = new Set(Object.keys(BUILTIN_EFFECT_CLASS).filter((n) => BUILTIN_EFFECT_CLASS[n] !== "local"));
4727
+ var NON_NETWORK_TOOLS = /* @__PURE__ */ new Set([...Object.keys(BUILTIN_EFFECT_CLASS).filter((n) => BUILTIN_EFFECT_CLASS[n] === "local"), MEMORY_NOTE]);
4633
4728
  var RUN_LEN = 3;
4634
4729
  var COMPARE_BUDGET_CHARS = 200;
4635
4730
  var MEMORY_GATE_REFUSAL = "Refused: this request repeats a number, link, address or wording from memory or the chat history that the owner did not type in this message. Ask the owner to confirm or type it.";
4636
- var MEMORY_GATE_ACTION_REFUSAL = "Refused: on a turn that uses memory, only read requests (GET) can be sent. Tell the owner the request was not sent.";
4731
+ var MEMORY_GATE_ACTION_REFUSAL = "Refused: in this chat, a tool that sends, changes or deletes something cannot run yet. Tell the owner it was not done.";
4732
+ var MEMORY_GATE_UNAVAILABLE_REFUSAL = "Refused: this tool cannot be used in this chat. Do not ask the owner to retype anything; tell them the tool is unavailable here.";
4733
+ var REASON = {
4734
+ copy: MEMORY_GATE_REFUSAL,
4735
+ action: MEMORY_GATE_ACTION_REFUSAL,
4736
+ unavailable: MEMORY_GATE_UNAVAILABLE_REFUSAL
4737
+ };
4637
4738
  var URL_RE = /\b(?:https?|ftp):\/\/[^\s"'<>]+|\bwww\.[^\s"'<>]+/gi;
4638
4739
  var EMAIL_RE = /[^\s@"'<>]+@[^\s@"'<>]+\.[^\s@"'<>]+/g;
4639
4740
  var BASE64_RE = /[A-Za-z0-9+/=_-]{25,}/g;
@@ -4646,11 +4747,12 @@ function memoryGateApplies(m, unified) {
4646
4747
  const ownerDm = m.authorization.audience === "owner_dm" || m.authorization.relay_speaker === "owner";
4647
4748
  return ownerDm || unified.items.length > 0 || unified.pinnedMore > 0;
4648
4749
  }
4649
- function leaves(v, out) {
4650
- if (typeof v === "string") out.push(v);
4651
- else if (Array.isArray(v)) for (const x of v) leaves(x, out);
4652
- else if (v && typeof v === "object") for (const x of Object.values(v)) leaves(x, out);
4653
- }
4750
+ var OUTBOUND = Object.freeze({
4751
+ "builtin:web_search": (a) => coerceWebSearchArgs(a).outbound,
4752
+ "builtin:fetch_url": (a) => coerceFetchUrlArgs(a).outbound,
4753
+ "builtin:http_request": (a) => coerceHttpRequestArgs(a).outbound,
4754
+ "builtin:sql_query": (a) => coerceSqlQueryArgs(a).outbound
4755
+ });
4654
4756
  var runs = (ts) => {
4655
4757
  const out = [];
4656
4758
  for (let i = 0; i + RUN_LEN <= ts.length; i++) {
@@ -4668,8 +4770,13 @@ var MemoryToolGate = class {
4668
4770
  compareRuns = /* @__PURE__ */ new Set();
4669
4771
  spent = 0;
4670
4772
  year;
4671
- /** `now` is the test seam for the year window; production passes nothing (Date.now). */
4773
+ tools;
4774
+ /**
4775
+ * `tools` is the turn's skill snapshot (R2, R8): the class and handler of each tool. `now` is the test seam for the
4776
+ * year window; production passes nothing (Date.now).
4777
+ */
4672
4778
  constructor(o) {
4779
+ this.tools = o.tools;
4673
4780
  this.year = new Date((o.now ?? Date.now)()).getUTCFullYear();
4674
4781
  const ot = tokens(o.ownerMessage);
4675
4782
  this.ownerTokens = new Set(ot);
@@ -4684,30 +4791,48 @@ var MemoryToolGate = class {
4684
4791
  isNearYear(t) {
4685
4792
  return YEAR_RE.test(t) && Math.abs(Number(t) - this.year) <= YEAR_WINDOW;
4686
4793
  }
4687
- refuse() {
4688
- return { allowed: false, reason: MEMORY_GATE_REFUSAL };
4794
+ refuse(code, effectClass) {
4795
+ return { allowed: false, reason: REASON[code], gate: { code, effectClass } };
4689
4796
  }
4797
+ /** R3: the verdict follows the class of the handler that will run (R1, R2), never the tool's name. */
4690
4798
  check(toolName, argumentsJson) {
4691
- if (NON_NETWORK_TOOLS.has(toolName)) return { allowed: true };
4692
- if (!GATED_TOOLS.has(toolName)) return this.refuse();
4693
4799
  let args;
4800
+ let parsed = true;
4694
4801
  try {
4695
4802
  args = JSON.parse(argumentsJson);
4696
4803
  } catch {
4697
- return this.refuse();
4804
+ parsed = false;
4805
+ }
4806
+ const cls = this.tools.effectClass(toolName, parsed ? args : void 0);
4807
+ if (cls === "local") return { allowed: true };
4808
+ if (cls === "uninspectable") return this.refuse("unavailable", cls);
4809
+ if (!parsed) return this.refuse("copy", cls);
4810
+ if (cls === "side_effect") return this.refuse("action", cls);
4811
+ const handler = this.tools.handlerOf(toolName);
4812
+ const outboundOf = handler !== void 0 && Object.hasOwn(OUTBOUND, handler) ? OUTBOUND[handler] : void 0;
4813
+ if (!outboundOf) return this.refuse("unavailable", cls);
4814
+ if (!args || typeof args !== "object" || Array.isArray(args)) return this.refuse("copy", cls);
4815
+ let out;
4816
+ try {
4817
+ out = outboundOf(args);
4818
+ } catch {
4819
+ return this.refuse("copy", cls);
4698
4820
  }
4699
- if (toolName === "http_request") {
4700
- if (!httpMethodIsGet(args)) return { allowed: false, reason: MEMORY_GATE_ACTION_REFUSAL };
4821
+ if (!out.sendable) return this.refuse("copy", cls);
4822
+ for (const text of out.patterns) {
4823
+ for (const m of text.match(URL_RE) ?? []) if (!this.ownerFolded.includes(foldText(m))) return this.refuse("copy", cls);
4824
+ for (const m of text.match(EMAIL_RE) ?? []) if (!this.ownerFolded.includes(foldText(m))) return this.refuse("copy", cls);
4825
+ for (const m of text.match(BASE64_RE) ?? []) if (!this.ownerFolded.includes(foldText(m))) return this.refuse("copy", cls);
4701
4826
  }
4702
- const strs = [];
4703
- leaves(args, strs);
4704
4827
  let charge = 0;
4705
4828
  let yearUsed = false;
4706
- for (const s of strs) {
4707
- const asciiYears = new Set([...s.matchAll(ASCII_YEAR_RE)].map((m) => m[0]));
4708
- for (const m of s.match(URL_RE) ?? []) if (!this.ownerFolded.includes(foldText(m))) return this.refuse();
4709
- for (const m of s.match(EMAIL_RE) ?? []) if (!this.ownerFolded.includes(foldText(m))) return this.refuse();
4710
- for (const m of s.match(BASE64_RE) ?? []) if (!this.ownerFolded.includes(foldText(m))) return this.refuse();
4829
+ for (const f of out.fields) {
4830
+ if (handler === "builtin:web_search" && f.key === "count" && typeof f.source === "number") continue;
4831
+ const s = f.value;
4832
+ const asciiYears = new Set(typeof f.source === "string" ? [...s.matchAll(ASCII_YEAR_RE)].map((m) => m[0]) : []);
4833
+ for (const m of s.match(URL_RE) ?? []) if (!this.ownerFolded.includes(foldText(m))) return this.refuse("copy", cls);
4834
+ for (const m of s.match(EMAIL_RE) ?? []) if (!this.ownerFolded.includes(foldText(m))) return this.refuse("copy", cls);
4835
+ for (const m of s.match(BASE64_RE) ?? []) if (!this.ownerFolded.includes(foldText(m))) return this.refuse("copy", cls);
4711
4836
  const ts = tokens(s);
4712
4837
  for (const t of ts) {
4713
4838
  if (STOP_WORDS.has(t) || ONE_LETTER_ROMAN.has(t)) continue;
@@ -4716,13 +4841,14 @@ var MemoryToolGate = class {
4716
4841
  yearUsed = true;
4717
4842
  continue;
4718
4843
  }
4719
- return this.refuse();
4844
+ return this.refuse("copy", cls);
4720
4845
  }
4721
4846
  }
4722
- for (const r of runs(ts)) if (this.compareRuns.has(r) && !this.ownerRuns.has(r)) return this.refuse();
4847
+ for (const r of runs(ts)) if (this.compareRuns.has(r) && !this.ownerRuns.has(r)) return this.refuse("copy", cls);
4723
4848
  for (const t of ts) if (!STOP_WORDS.has(t) && this.compareTokens.has(t) && !this.ownerTokens.has(t)) charge += t.length;
4724
4849
  }
4725
- if (this.spent + charge > COMPARE_BUDGET_CHARS) return this.refuse();
4850
+ for (const text of out.runs) for (const r of runs(tokens(text))) if (this.compareRuns.has(r) && !this.ownerRuns.has(r)) return this.refuse("copy", cls);
4851
+ if (this.spent + charge > COMPARE_BUDGET_CHARS) return this.refuse("copy", cls);
4726
4852
  this.spent += charge;
4727
4853
  return { allowed: true };
4728
4854
  }
@@ -5177,9 +5303,11 @@ ${recalledText}` } : null;
5177
5303
  const toolsStart = clock();
5178
5304
  const eligibleTools = deps.tools();
5179
5305
  const tools = eligibleTools.filter((tool) => toolIsOffered(m.authorization, tool.function.name));
5306
+ const gatedTurn = memoryGateApplies(m, unified);
5307
+ if (gatedTurn) tools.splice(0, tools.length, ...tools.filter((tool) => skills.offerableOnGatedTurn(tool.function.name)));
5180
5308
  if (memoryNoteAllowed(m.authorization, m)) tools.push(m.memoryUnified === true ? MEMORY_PIN_TOOL : MEMORY_NOTE_TOOL);
5181
5309
  const offeredNames = new Set(tools.map((tool) => tool.function.name));
5182
- const memoryGate = memoryGateApplies(m, unified) ? new MemoryToolGate({ ownerMessage: text, compareTexts: [...unified.items.map((i) => i.text), ...history.map((h) => typeof h.content === "string" ? h.content : "")] }) : void 0;
5310
+ const memoryGate = gatedTurn ? new MemoryToolGate({ ownerMessage: text, compareTexts: [...unified.items.map((i) => i.text), ...history.map((h) => typeof h.content === "string" ? h.content : "")], tools: skills }) : void 0;
5183
5311
  const systemParts = [persona];
5184
5312
  if (m.voiceGuidance) systemParts.push(`${VOICE_TURN_PREFIX}${m.voiceGuidance}`);
5185
5313
  if (tools.length > 0) systemParts.push(toolAvailabilityLine([...offeredNames]));
@@ -5408,7 +5536,9 @@ ${notesLines.slice(0, k).join("\n")}` };
5408
5536
  for (const v of verdicts) {
5409
5537
  if (!v.verdict.allowed) {
5410
5538
  denied++;
5411
- log("warn", "turn.tool_call_denied");
5539
+ const g = v.verdict.gate;
5540
+ if (g) log("warn", "turn.tool_call_denied", { reason: logLabel(g.code), class: logLabel(g.effectClass) });
5541
+ else log("warn", "turn.tool_call_denied");
5412
5542
  }
5413
5543
  }
5414
5544
  if (turnCapDenied > 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@securecoms/agent-runtime",
3
- "version": "0.12.1",
3
+ "version": "0.12.2",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "securecoms-agent": "dist/bin.js"