@hachej/boring-agent 0.4.7 → 0.4.9

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.
@@ -56,6 +56,14 @@ export interface ApprovalExtensionOptions {
56
56
  readonly name?: string;
57
57
  /** Tool names to gate, with their rule. Any native tool works, including Pi's stock `bash`, `write` and `edit`. */
58
58
  readonly tools: Readonly<Record<string, ApprovalRule>>;
59
+ /**
60
+ * What happens to a tool call whose tool is NOT named in `tools`. `'allow'` (default) runs it ungated. `'deny'` blocks it before it runs
61
+ * (the model is told it was not run), except the tools listed in `allow`. Approval is not asked for unlisted tools: a native hook can
62
+ * block a call but cannot ask the person, so name a tool in `tools` to have it asked.
63
+ */
64
+ readonly unlisted?: 'allow' | 'deny';
65
+ /** With `unlisted: 'deny'`: tool names that still run without approval (for example the read tools). Ignored otherwise. */
66
+ readonly allow?: readonly string[];
59
67
  }
60
68
  /**
61
69
  * The approval gate as an opt-in native extension. It brings no tools: it wraps the named tools (native `wraps`, Pi's own
@@ -64,6 +72,13 @@ export interface ApprovalExtensionOptions {
64
72
  * `conversation.configure({ extensions: { add: [approvals] } })` turns it on and `remove` turns it off from the next call.
65
73
  * Several approval extensions (different `name`s) compose; a tool already gated is never asked twice.
66
74
  *
75
+ * Gating only `bash` is bypassable when self-evolving tools or write+reload are enabled: the agent writes a tool with the ungated
76
+ * `write` tool, reloads it, and the new tool runs through the same `env.exec`. Gate `write`/`edit` and the reload path too, or set
77
+ * `unlisted: 'deny'` (with `allow` for the tools that may run freely) so a tool nobody listed cannot run. The default `'allow'` gates only
78
+ * what `tools` names.
79
+ *
80
+ * A delegated child (`createDelegateTool`) inherits the parent's approval extensions, so a delegate cannot escape the gate.
81
+ *
67
82
  * It gates native tool calls only. A code-mode sandbox or any host code that offers the same capability another way is
68
83
  * not covered: keep such capabilities out of the sandbox (see `isApprovalGated`).
69
84
  */
package/dist/approval.js CHANGED
@@ -1,4 +1,4 @@
1
- import { defineExtension } from '@earendil-works/pi-durable';
1
+ import { ToolTask, defineExtension, hook } from '@earendil-works/pi-durable';
2
2
  import { askPerson } from './ask-user.js';
3
3
  /** The two answers an approval question accepts. A chat answers with exactly these strings through `answerUserQuestion`. */
4
4
  export const APPROVE = 'Approve';
@@ -13,6 +13,8 @@ export const CANCELLED_PREFIX = 'Not run: the approval question was cancelled.';
13
13
  * without reading the question document. The card in `registry/pi-chat` repeats this key (a contract test keeps them equal).
14
14
  */
15
15
  export const APPROVAL_DETAILS = 'boring.approval';
16
+ const GUARDS = (globalThis[Symbol.for('boring.agent.guards')] ??= new WeakSet()); // approval + sandbox extensions: a delegated child inherits them
17
+ const markGuard = (extension) => { GUARDS.add(extension); return extension; };
16
18
  const MEMO = 'boring.approval.started';
17
19
  const GATED = new WeakSet();
18
20
  const text = (value, isError) => ({ isError, output: [{ type: 'text', text: value }] });
@@ -112,6 +114,13 @@ const defaultSummary = (name) => (args) => `${name} ${JSON.stringify(args)}`.sli
112
114
  * `conversation.configure({ extensions: { add: [approvals] } })` turns it on and `remove` turns it off from the next call.
113
115
  * Several approval extensions (different `name`s) compose; a tool already gated is never asked twice.
114
116
  *
117
+ * Gating only `bash` is bypassable when self-evolving tools or write+reload are enabled: the agent writes a tool with the ungated
118
+ * `write` tool, reloads it, and the new tool runs through the same `env.exec`. Gate `write`/`edit` and the reload path too, or set
119
+ * `unlisted: 'deny'` (with `allow` for the tools that may run freely) so a tool nobody listed cannot run. The default `'allow'` gates only
120
+ * what `tools` names.
121
+ *
122
+ * A delegated child (`createDelegateTool`) inherits the parent's approval extensions, so a delegate cannot escape the gate.
123
+ *
115
124
  * It gates native tool calls only. A code-mode sandbox or any host code that offers the same capability another way is
116
125
  * not covered: keep such capabilities out of the sandbox (see `isApprovalGated`).
117
126
  */
@@ -123,5 +132,9 @@ export function createApprovalExtension(options) {
123
132
  ...(rule !== true && rule.when ? { when: rule.when } : {}),
124
133
  }),
125
134
  }));
126
- return defineExtension({ name: options.name ?? 'boring.approval', wraps });
135
+ const allowed = new Set([...Object.keys(options.tools), ...options.allow ?? []]);
136
+ const hooks = options.unlisted === 'deny'
137
+ ? [hook(ToolTask, { beforeTool: call => allowed.has(call.name) ? undefined : { block: `Not run: ${call.name} is not allowed in this conversation.` } })]
138
+ : [];
139
+ return markGuard(defineExtension({ name: options.name ?? 'boring.approval', wraps, hooks }));
127
140
  }
@@ -55,7 +55,10 @@ export interface ChatTransportAccess {
55
55
  * it in `createPromptContext({ context })`; this handler keeps nothing. Without it the `context` field is ignored.
56
56
  */
57
57
  readonly requestContext?: (value: Readonly<Record<string, unknown>>) => void | Promise<void>;
58
- /** Refuse an effect call. Reading stays governed by `authenticate` and `project`. */
58
+ /**
59
+ * Refuse an effect call. Reading stays governed by `authenticate` and `project`. Without it EVERY effect (submit, abort, answer,
60
+ * withdraw, configure) is allowed to whoever `authenticate` accepted: a host with read-only viewers must pass it.
61
+ */
59
62
  readonly allow?: (operation: 'submit' | 'abort' | 'answer' | 'withdraw' | 'configure') => boolean | Promise<boolean>;
60
63
  }
61
64
  export interface ChatTransportOptions {
@@ -123,6 +126,12 @@ export interface ConversationSocketOptions {
123
126
  * hibernation (the original request is gone by then). The same access the HTTP handler gets from `authenticate`. Null closes the socket.
124
127
  */
125
128
  readonly access: (attachment: ConversationSocketAttachment) => Promise<ChatTransportAccess | null>;
129
+ /**
130
+ * Origins (`https://app.example`) besides the request's own host that may open the socket. A browser upgrade whose `Origin` is
131
+ * neither the request's host nor listed here is refused (a cross-site page would otherwise ride the visitor's cookies); a request with
132
+ * no `Origin` (not a browser) is not subject to this check, so `authenticate` still decides who it is.
133
+ */
134
+ readonly allowedOrigins?: readonly string[];
126
135
  }
127
136
  /** The subprotocol a browser client offers and the handler selects. Other offered values (for example a credential) are the host's. */
128
137
  export declare const CHAT_SOCKET_PROTOCOL = "boring.chat";
@@ -241,6 +241,8 @@ export function createChatTransportHandler(options) {
241
241
  || (input['whenBusy'] !== undefined && typeof input['whenBusy'] !== 'string')
242
242
  || (access.requestContext && !contextValue(input['context'])))
243
243
  return failure(400, 'invalid-request');
244
+ if (!await allowed('submit'))
245
+ return failure(403, 'not-authorized'); // before `prepareInput`: a denied submitter reads nothing
244
246
  let prepared = input['content'];
245
247
  if (access.prepareInput) {
246
248
  try {
@@ -248,7 +250,7 @@ export function createChatTransportHandler(options) {
248
250
  }
249
251
  catch { /* submit what the person typed */ }
250
252
  }
251
- if (!await allowed('submit') || access.revoked?.aborted)
253
+ if (access.revoked?.aborted)
252
254
  return failure(403, 'not-authorized');
253
255
  if (access.requestContext && input['context'] !== undefined)
254
256
  await access.requestContext(input['context']);
@@ -311,7 +313,7 @@ export function createChatTransportHandler(options) {
311
313
  if (error instanceof Error && error.code === 'submission-refused') {
312
314
  return Response.json({ schema: CHAT_TRANSPORT.schema, version: CHAT_TRANSPORT.version, reason: 'submission-refused', message: error.message.slice(0, 500) }, { status: 402, headers });
313
315
  }
314
- return failure(503, error instanceof Error && error.message ? `source-unavailable: ${error.message.slice(0, 200)}` : 'source-unavailable');
316
+ return failure(503, 'source-unavailable'); // the detail stays on the host: a message can carry paths, hosts or credentials
315
317
  }
316
318
  return failure(404, 'unknown-operation');
317
319
  };
@@ -342,6 +344,20 @@ export function routeSubmissions(conversation, admit) {
342
344
  }
343
345
  /** The subprotocol a browser client offers and the handler selects. Other offered values (for example a credential) are the host's. */
344
346
  export const CHAT_SOCKET_PROTOCOL = 'boring.chat';
347
+ /** Whether a WebSocket upgrade comes from the page's own host or a listed origin. No `Origin` header: not a browser, so not subject. */
348
+ function sameOrigin(request, allowed = []) {
349
+ const origin = request.headers.get('origin');
350
+ if (origin === null)
351
+ return true;
352
+ if (allowed.includes(origin))
353
+ return true;
354
+ try {
355
+ return new URL(origin).host === (request.headers.get('host') ?? new URL(request.url).host);
356
+ }
357
+ catch {
358
+ return false;
359
+ }
360
+ }
345
361
  const ownerTag = (owner) => `owner:${owner}`;
346
362
  const conversationTag = (id) => `conversation:${id}`;
347
363
  const tagFor = (to) => to?.conversationId !== undefined ? conversationTag(to.conversationId) : to?.owner !== undefined ? ownerTag(to.owner) : undefined;
@@ -426,6 +442,8 @@ export function createConversationSocketHandler(options) {
426
442
  }
427
443
  async function resume() { await Promise.all(state.getWebSockets().map(socket => attach(socket))); }
428
444
  async function accept(socket, request) {
445
+ if (!sameOrigin(request, options.allowedOrigins))
446
+ return false;
429
447
  const found = await options.authenticate(request);
430
448
  if (!found)
431
449
  return false;
@@ -475,7 +493,7 @@ export function createConversationSocketHandler(options) {
475
493
  return failure(503, 'authentication-unavailable');
476
494
  }
477
495
  if (!accepted)
478
- return failure(401, 'authentication-required');
496
+ return failure(sameOrigin(request, options.allowedOrigins) ? 401 : 403, sameOrigin(request, options.allowedOrigins) ? 'authentication-required' : 'origin-not-allowed');
479
497
  const offered = (request.headers.get('sec-websocket-protocol') ?? '').split(',').map(value => value.trim());
480
498
  return new Response(null, { status: 101, webSocket: pair[0], ...(offered.includes(CHAT_SOCKET_PROTOCOL) ? { headers: { 'sec-websocket-protocol': CHAT_SOCKET_PROTOCOL } } : {}) });
481
499
  },
@@ -359,7 +359,7 @@ export function createConversationsHandler(options) {
359
359
  return failure(error.status, error.status === 413 ? 'request-too-large' : 'invalid-request');
360
360
  if (error instanceof TypeError)
361
361
  return failure(400, 'invalid-request');
362
- return failure(503, error instanceof Error && error.message ? `source-unavailable: ${error.message.slice(0, 200)}` : 'source-unavailable');
362
+ return failure(503, 'source-unavailable');
363
363
  }
364
364
  return failure(404, 'unknown-operation');
365
365
  };
@@ -18,7 +18,8 @@ export interface DelegateOptions {
18
18
  * calling tool task (`ownership: { kind: 'task', taskId }`), so aborting the call aborts the child and the parent is idle only
19
19
  * once the child is. Replay-safe: a rerun of the same tool task (after a crash or eviction) finds its child through the native
20
20
  * ownership index and its submission through the request id, so it never starts a second child or a second turn. Returns only the
21
- * child's final text; `details.conversationId` names the child for a UI that shows it.
21
+ * child's final text. The child inherits the parent's approval and sandbox extensions (appended after its own, in the parent's order, so a
22
+ * sandbox stays last), so a delegate cannot escape the parent's gate; `details.conversationId` names the child for a UI that shows it.
22
23
  */
23
24
  export declare function createDelegateTool(options: DelegateOptions): ToolRegistration;
24
25
  //# sourceMappingURL=delegate.d.ts.map
package/dist/delegate.js CHANGED
@@ -1,12 +1,15 @@
1
1
  import { AssistantEntry, configure, defineTool } from '@earendil-works/pi-durable';
2
2
  import { Type } from '@earendil-works/pi-ai';
3
+ const GUARDS = (globalThis[Symbol.for('boring.agent.guards')] ??= new WeakSet()); // approval + sandbox extensions: a delegated child inherits them
4
+ const isGuard = (extension) => GUARDS.has(extension);
3
5
  export const DELEGATE_TOOL = 'delegate';
4
6
  /**
5
7
  * Pi's subagent pattern (pi-durable README "Abort and Subagents") as a tool factory: the child is a conversation owned by the
6
8
  * calling tool task (`ownership: { kind: 'task', taskId }`), so aborting the call aborts the child and the parent is idle only
7
9
  * once the child is. Replay-safe: a rerun of the same tool task (after a crash or eviction) finds its child through the native
8
10
  * ownership index and its submission through the request id, so it never starts a second child or a second turn. Returns only the
9
- * child's final text; `details.conversationId` names the child for a UI that shows it.
11
+ * child's final text. The child inherits the parent's approval and sandbox extensions (appended after its own, in the parent's order, so a
12
+ * sandbox stays last), so a delegate cannot escape the parent's gate; `details.conversationId` names the child for a UI that shows it.
10
13
  */
11
14
  export function createDelegateTool(options) {
12
15
  const name = options.name ?? DELEGATE_TOOL;
@@ -19,12 +22,14 @@ export function createDelegateTool(options) {
19
22
  parameters: Type.Object({ task: Type.String({ minLength: 1, description: 'The complete task; the agent sees nothing else of this conversation.' }) }, { additionalProperties: false }),
20
23
  replay: 'safe',
21
24
  execute: async (args, api, context) => {
25
+ const inherited = (await api.agent(context)).extensions.filter(isGuard);
26
+ const agent = withGuards(child, inherited);
22
27
  const childId = await api.commit(async (tx) => {
23
28
  const existing = (await tx.scanConversations({ ownerTaskId: api.taskId }, 1)).items[0];
24
29
  if (existing !== undefined)
25
30
  return existing.id;
26
31
  const created = await tx.createConversation({ ownership: { kind: 'task', taskId: api.taskId } });
27
- await configure(tx, created.id, child);
32
+ await configure(tx, created.id, agent);
28
33
  return created.id;
29
34
  }, context);
30
35
  await api.details({ conversationId: childId }, context);
@@ -42,3 +47,14 @@ export function createDelegateTool(options) {
42
47
  },
43
48
  });
44
49
  }
50
+ /** `change` with the parent's guard extensions appended to its extension selection (an array or `add`; an unset selection already inherits the parent's). */
51
+ function withGuards(change, guards) {
52
+ const { extensions } = change;
53
+ if (!guards.length || extensions === undefined || extensions === null)
54
+ return change;
55
+ if (Array.isArray(extensions))
56
+ return { ...change, extensions: [...extensions, ...guards.filter(guard => !extensions.some(own => own.name === guard.name))] };
57
+ const selection = extensions;
58
+ const present = selection.add ?? [];
59
+ return { ...change, extensions: { ...selection, add: [...present, ...guards.filter(guard => !present.some(own => own.name === guard.name))] } };
60
+ }
package/dist/mentions.js CHANGED
@@ -113,7 +113,7 @@ export function createMentionResolver(options) {
113
113
  }
114
114
  const cut = bytes.length > room;
115
115
  used += Math.min(bytes.length, room);
116
- added.push({ type: 'text', text: `${MENTION_FILE_PREFIX}${attribute(path)}"${cut ? ` truncated="showing the first ${new TextEncoder().encode(text).length} of ${bytes.length} bytes"` : ''}>\n${text.replace(/<\/file>/g, '<\\/file>')}\n</file>` });
116
+ added.push({ type: 'text', text: `${MENTION_FILE_PREFIX}${attribute(path)}"${cut ? ` truncated="showing the first ${new TextEncoder().encode(text).length} of ${bytes.length} bytes"` : ''}>\n${text.replace(/<(\s*\/?\s*file)\b/gi, '<\\$1')}\n</file>` });
117
117
  }
118
118
  return [...parts, ...added];
119
119
  }
@@ -14,7 +14,9 @@ const produced = new WeakSet();
14
14
  * assistant continuation) at the tail. Pure: the input array is not changed.
15
15
  */
16
16
  export function withRequestContext(messages, text, tag = 'request-context') {
17
- const message = { role: 'user', content: [{ type: 'text', text: `<${tag}>\n${text}\n</${tag}>` }], timestamp: Date.now() };
17
+ // The text may be browser-supplied: neutralise every opening or closing `tag` in it (any case, spaces allowed) so it cannot end the wrapper.
18
+ const inner = text.replace(new RegExp(`<(\\s*/?\\s*${tag.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})(?![\\w-])`, 'gi'), '<\\$1');
19
+ const message = { role: 'user', content: [{ type: 'text', text: `<${tag}>\n${inner}\n</${tag}>` }], timestamp: Date.now() };
18
20
  produced.add(message);
19
21
  let end = messages.length;
20
22
  while (end > 0 && produced.has(messages[end - 1]))
package/dist/sandbox.d.ts CHANGED
@@ -1,6 +1,5 @@
1
1
  import type { Extension } from '@earendil-works/pi-durable';
2
2
  import type { JsonValue } from '@earendil-works/chord';
3
- /** Key under a recorded call's result details: `{ simulated: true, tool, arguments }`. A caller reads it back with `simulatedCalls`. */
4
3
  export declare const SANDBOX_DETAILS = "boring.sandbox";
5
4
  /** What the host declares about one tool. An unset field is not declared: a call is recorded unless the host declares it safe. */
6
5
  export interface SandboxFlags {
package/dist/sandbox.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import { defineExtension, hook, ToolTask } from '@earendil-works/pi-durable';
2
2
  /** Key under a recorded call's result details: `{ simulated: true, tool, arguments }`. A caller reads it back with `simulatedCalls`. */
3
+ const GUARDS = (globalThis[Symbol.for('boring.agent.guards')] ??= new WeakSet()); // approval + sandbox extensions: a delegated child inherits them
4
+ const markGuard = (extension) => { GUARDS.add(extension); return extension; };
3
5
  export const SANDBOX_DETAILS = 'boring.sandbox';
4
6
  /** The acknowledgement a recorded call returns when the host does not supply its own. */
5
7
  export const defaultAck = (toolName) => `Simulated: ${toolName} was recorded and not run. Nothing was changed.`;
@@ -45,13 +47,13 @@ export function createSandboxExtension(options) {
45
47
  const recordOnly = new Set(options.tools.filter(name => sandboxDecision(options.flags?.[name]) === 'record'));
46
48
  const simulate = options.simulate ?? ((toolName) => defaultAck(toolName));
47
49
  const wraps = [...recordOnly].map(name => ({ tool: name, wrap: (tool) => recorded(tool, simulate) }));
48
- return defineExtension({
50
+ return markGuard(defineExtension({
49
51
  name: options.name ?? 'boring.sandbox',
50
52
  wraps,
51
53
  hooks: [hook(ToolTask, {
52
54
  beforeTool: call => covered.has(call.name) ? undefined : { block: `Not run: ${call.name} is not covered by the sandbox.` },
53
55
  })],
54
- });
56
+ }));
55
57
  }
56
58
  /**
57
59
  * Setup-time check over the public `Extension.wraps` shape. Throws when an extension selected after `sandbox` wraps a tool the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hachej/boring-agent",
3
- "version": "0.4.7",
3
+ "version": "0.4.9",
4
4
  "license": "MIT",
5
5
  "private": false,
6
6
  "type": "module",
@@ -156,8 +156,8 @@
156
156
  "@earendil-works/pi-durable": "~1.2.0",
157
157
  "@earendil-works/pi-ai": "~1.2.0",
158
158
  "@earendil-works/chord": "~1.2.0",
159
- "@hachej/boring-files": "^0.4.7",
160
- "@hachej/boring-ui-kit": "^0.4.7",
159
+ "@hachej/boring-files": "^0.4.9",
160
+ "@hachej/boring-ui-kit": "^0.4.9",
161
161
  "xlsx": "^0.20.3"
162
162
  },
163
163
  "peerDependenciesMeta": {