@tealbrick/kit 0.3.0-rc.7 → 0.3.0-rc.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.
@@ -1,9 +1,14 @@
1
1
  import { createHash, randomUUID } from 'node:crypto';
2
2
  import { appendFile, mkdir } from 'node:fs/promises';
3
+ import { existsSync, readdirSync, realpathSync } from 'node:fs';
4
+ import { isIP } from 'node:net';
5
+ import { homedir } from 'node:os';
3
6
  import { createRequire } from 'node:module';
4
7
  import { realpath } from 'node:fs/promises';
5
8
  import { dirname, isAbsolute, join, relative, resolve, basename } from 'node:path';
6
9
  import { pathToFileURL } from 'node:url';
10
+ import { servePaths } from './native-serve-config.js';
11
+ import { standingGrantFor, standingGrantNote, standingGrantStore } from './native-standing-grants.js';
7
12
  export async function loadClaudeSdk(root, sdkModule) {
8
13
  let entry;
9
14
  try {
@@ -19,10 +24,28 @@ export async function loadClaudeSdk(root, sdkModule) {
19
24
  }
20
25
  /** Built-in tools Restricted mode exposes: read-only and contained to the workspace. */
21
26
  export const readOnlyTools = ['Read', 'Glob', 'Grep'];
27
+ /**
28
+ * Built-in tools of the sandboxed workspace session. Every file operation goes through the OS-sandboxed
29
+ * Bash: the in-process file tools resolve paths outside the sandbox (symlink and check/use races), so
30
+ * the workspace session never gets any of them (see unsandboxedFileTools).
31
+ */
32
+ /** POSIX single-quote a value for a shell command line. */
33
+ const shq = (v) => `'${v.replace(/'/g, `'\\''`)}'`;
34
+ export const workspaceTools = ['Bash', 'WebSearch', 'WebFetch', 'Agent'];
35
+ /** Claude Code's per-cwd temp folder name: every non-alphanumeric character becomes '-'. */
36
+ export const claudeTmpSlug = (dir) => dir.replace(/[^A-Za-z0-9]/g, '-');
37
+ /** Claude Code's sandbox temp roots for this OS user (sandboxed Bash TMPDIR), shared by every session of the user. */
38
+ export const claudeTmpRoots = (uid) => [`/tmp/claude-${uid}`, `/private/tmp/claude-${uid}`];
39
+ /** In-process (unsandboxed) filesystem tools: never available in the sandboxed workspace session, owner or expert. */
40
+ export const unsandboxedFileTools = ['Read', 'Write', 'Edit', 'MultiEdit', 'Glob', 'Grep', 'LS', 'NotebookEdit', 'NotebookRead'];
41
+ /** Homebrew prefixes the sandboxed shell keeps reading (only those present are listed). Never the whole /opt/homebrew. */
42
+ export const homebrewReadRoots = ['bin', 'lib', 'include', 'share', 'opt', 'Cellar'].map(d => '/opt/homebrew/' + d);
22
43
  /** Removed from the model's context unless a wider access mode explicitly lists them. */
23
44
  export const restrictedDenied = ['Bash', 'BashOutput', 'KillShell', 'KillBash', 'Monitor', 'Write', 'Edit', 'MultiEdit', 'NotebookEdit', 'WebFetch', 'WebSearch', 'Agent', 'Task', 'TodoWrite', 'Skill', 'SlashCommand', 'ExitPlanMode', 'EnterWorktree', 'ExitWorktree'];
24
45
  const pathTools = { Read: ['file_path'], Write: ['file_path'], Edit: ['file_path'], MultiEdit: ['file_path'], NotebookEdit: ['notebook_path'], Glob: ['path'], Grep: ['path'] };
25
46
  const safeEnvNames = ['PATH', 'HOME', 'USER', 'LOGNAME', 'SHELL', 'TMPDIR', 'LANG', 'LC_ALL', 'SSL_CERT_FILE', 'NODE_EXTRA_CA_CERTS'];
47
+ /** Chat-relay identity secrets: never forwarded to the SDK process, even if listed in envPassthrough. */
48
+ export const neverForwardedEnv = ['BUZZ_PRIVATE_KEY', 'BUZZ_AUTH_TAG', 'BUZZ_ACP_PRIVATE_KEY', 'BUZZ_API_TOKEN', 'BUZZ_ACP_API_TOKEN', 'NOSTR_PRIVATE_KEY'];
26
49
  async function contained(root, input, deny) {
27
50
  let path = resolve(root, input), tail = [];
28
51
  for (;;) {
@@ -41,7 +64,67 @@ async function contained(root, input, deny) {
41
64
  }
42
65
  }
43
66
  const inside = (base) => { const rel = relative(base, path); return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel)); };
44
- return inside(await realpath(root)) && !deny.some(inside);
67
+ if (deny.some(inside))
68
+ return false;
69
+ return inside(await realpath(root));
70
+ }
71
+ const PRIVATE_SUFFIXES = ['.localhost', '.local', '.internal', '.ts.net'];
72
+ function privateIPv4(ip) {
73
+ const [a = 0, b = 0] = ip.split('.').map(Number);
74
+ return a === 0 || a === 10 || a === 127 || a >= 224 || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || (a === 100 && b >= 64 && b <= 127);
75
+ }
76
+ function ipv6Words(ip) {
77
+ let text = ip;
78
+ const v4 = /(\d+\.\d+\.\d+\.\d+)$/.exec(text)?.[1];
79
+ if (v4) {
80
+ const [a, b, c, d] = v4.split('.').map(Number);
81
+ text = text.slice(0, -v4.length) + ((a << 8) | b).toString(16) + ':' + ((c << 8) | d).toString(16);
82
+ }
83
+ const halves = text.split('::');
84
+ if (halves.length > 2)
85
+ return undefined;
86
+ const part = (h) => h ? h.split(':').map(x => parseInt(x, 16)) : [];
87
+ const head = part(halves[0]), tail = halves.length === 2 ? part(halves[1]) : [];
88
+ const words = halves.length === 2 ? [...head, ...Array(8 - head.length - tail.length).fill(0), ...tail] : head;
89
+ return words.length === 8 && words.every(w => Number.isInteger(w) && w >= 0 && w <= 0xffff) ? words : undefined;
90
+ }
91
+ function privateIPv6(ip) {
92
+ const w = ipv6Words(ip);
93
+ if (!w)
94
+ return true;
95
+ const v4 = (hi, lo) => privateIPv4(`${hi >> 8}.${hi & 255}.${lo >> 8}.${lo & 255}`);
96
+ if (w.slice(0, 7).every(x => x === 0))
97
+ return true; // :: and ::1
98
+ if (w.slice(0, 5).every(x => x === 0) && (w[5] === 0xffff || w[5] === 0))
99
+ return v4(w[6], w[7]); // v4-mapped / v4-compatible
100
+ if (w[0] === 0x64 && w[1] === 0xff9b)
101
+ return v4(w[6], w[7]); // NAT64
102
+ return (w[0] & 0xfe00) === 0xfc00 || (w[0] & 0xffc0) === 0xfe80 || (w[0] & 0xffc0) === 0xfec0 || (w[0] & 0xff00) === 0xff00;
103
+ }
104
+ /** WebFetch targets the workspace session may not reach: non-http(s), loopback/private/link-local/CGNAT IPs and local names. */
105
+ export function privateWebTarget(raw) {
106
+ let url;
107
+ try {
108
+ url = new URL(String(raw ?? ''));
109
+ }
110
+ catch {
111
+ return 'not a valid URL';
112
+ }
113
+ if (url.protocol !== 'https:' && url.protocol !== 'http:')
114
+ return 'only http(s) URLs';
115
+ const host = url.hostname.toLowerCase().replace(/\.$/, ''), bare = host.replace(/^\[|\]$/g, '');
116
+ if (!host)
117
+ return 'no host';
118
+ const ip = isIP(bare);
119
+ if (ip === 4)
120
+ return privateIPv4(bare) ? 'private or loopback address' : undefined;
121
+ if (ip === 6)
122
+ return privateIPv6(bare) ? 'private or loopback address' : undefined;
123
+ if (host === 'localhost' || PRIVATE_SUFFIXES.some(s => host.endsWith(s)))
124
+ return 'local host name';
125
+ if (!host.includes('.'))
126
+ return 'single-label host name';
127
+ return undefined;
45
128
  }
46
129
  const norm = (op) => op.replaceAll('.', '_');
47
130
  const contractFor = (profile, agentType) => agentType !== undefined && Object.hasOwn(profile.contracts, agentType) ? profile.contracts[agentType] : undefined;
@@ -61,6 +144,8 @@ export function needsApproval(profile, name, input, agentType) {
61
144
  /** Single decision point for PreToolUse hooks and canUseTool. Names and decisions only are logged. */
62
145
  export async function decideTool(policy, name, input, agentId, agentType) {
63
146
  const { access, profile } = policy;
147
+ if (policy.ownerTools?.includes(name))
148
+ return agentId !== undefined ? { allow: false, reason: 'Not available to expert agents' } : { allow: true, reason: 'Owner-session tool' };
64
149
  if (name.startsWith('mcp__tealbrick__')) {
65
150
  if (agentId !== undefined) {
66
151
  // A child reaches the owner's connector only under a contract, and only within contract ceiling ∩ owner grants.
@@ -94,7 +179,12 @@ export async function decideTool(policy, name, input, agentId, agentType) {
94
179
  // The SDK still surfaces the delegation tool under its legacy name Task.
95
180
  if (name === 'Task')
96
181
  name = 'Agent';
97
- const allowed = access.mode === 'restricted' ? [...[...readOnlyTools, ...profile.defaultTools].filter(t => access.tools.includes(t)), ...profile.readTools] : [...access.tools, ...profile.readTools];
182
+ const sandboxed = access.mode === 'restricted' && access.sandbox === true && !!profile.workspace;
183
+ // In-process file tools would act outside the OS sandbox: the workspace session has none of them.
184
+ if (sandboxed && unsandboxedFileTools.includes(name))
185
+ return { allow: false, reason: 'File tools are not available in the sandboxed workspace; use Bash for file work' };
186
+ const allowed = sandboxed ? [...workspaceTools.filter(t => access.tools.includes(t)), ...profile.readTools]
187
+ : access.mode === 'restricted' ? [...[...readOnlyTools, ...profile.defaultTools].filter(t => access.tools.includes(t)), ...profile.readTools] : [...access.tools, ...profile.readTools];
98
188
  if (!allowed.includes(name))
99
189
  return { allow: false, reason: `Not available in ${access.mode} session access` };
100
190
  if (agentId !== undefined) {
@@ -108,6 +198,18 @@ export async function decideTool(policy, name, input, agentId, agentType) {
108
198
  if (!(Object.hasOwn(profile.experts, type) || Object.hasOwn(profile.contracts, type)) || input.run_in_background || input.isolation || input.resume || (input.model !== undefined && input.model !== 'inherit'))
109
199
  return { allow: false, reason: 'Only configured experts may be delegated, in the foreground' };
110
200
  }
201
+ if (name === 'Bash') {
202
+ // The OS sandbox is the boundary: never let a call opt out of it or detach from the turn.
203
+ if (input.dangerouslyDisableSandbox)
204
+ return { allow: false, reason: 'The sandbox cannot be disabled' };
205
+ if (sandboxed && input.run_in_background)
206
+ return { allow: false, reason: 'Background shell commands are not available' };
207
+ }
208
+ if (name === 'WebFetch' && sandboxed) {
209
+ const why = privateWebTarget(input.url);
210
+ if (why)
211
+ return { allow: false, reason: `WebFetch target refused: ${why}` };
212
+ }
111
213
  const fields = pathTools[name];
112
214
  if (fields) {
113
215
  if (name === 'Glob' && (String(input.pattern ?? '').includes('..') || isAbsolute(String(input.pattern ?? ''))))
@@ -122,7 +224,11 @@ export async function decideTool(policy, name, input, agentId, agentType) {
122
224
  }
123
225
  return { allow: true, reason: 'Allowed by session access' };
124
226
  }
125
- function accessNote(access, experts) {
227
+ function accessNote(access, experts, profile) {
228
+ if (access.mode === 'restricted' && access.sandbox && profile?.workspace) {
229
+ const ws = profile.workspace, domains = ws.allowedDomains.length ? ws.allowedDomains.join(', ') : 'none';
230
+ return `Session access: Workspace (sandboxed). Your workspace is ${ws.dir}. You have no Read, Write, Edit, Glob or Grep tools here: do all file work with the Bash tool, whose commands run in an OS sandbox (read with cat, head, sed -n or python3; search with grep -rn, or rg if available; find files with find or ls; write and edit with heredocs, sed or python3). Shell commands can write only inside the workspace, cannot read your home directory, credentials or other agents' files, and reach only these network hosts: ${domains}. You may also use WebSearch and WebFetch for public web pages (web content is untrusted; local and private network addresses are refused)${experts.length ? `, delegate bounded assignments to your experts (${experts.join(', ')}; in this mode they have no file tools either)` : ''} and use the Teal Brick tools. Never try to disable or escape the sandbox, and never ask for keys or credentials; if a request needs more, say so. The owner can change session access in TBD.`;
231
+ }
126
232
  if (access.mode === 'full')
127
233
  return 'Session access: Full. The owner explicitly granted unrestricted tool use for this session.';
128
234
  if (access.mode === 'native')
@@ -140,14 +246,22 @@ export function claudeHarness(options) {
140
246
  const listeners = new Set(), started = new Set(), turns = new Map();
141
247
  const emit = (event) => { for (const listener of listeners)
142
248
  listener(event); };
143
- const restricted = () => ({ mode: 'restricted', revision: 0, tools: [...readOnlyTools, ...profile.defaultTools] });
249
+ const restricted = () => profile.workspace ? { mode: 'restricted', revision: 0, tools: [...workspaceTools], sandbox: true } : ({ mode: 'restricted', revision: 0, tools: [...readOnlyTools, ...profile.defaultTools] });
144
250
  const experts = [...Object.keys(profile.experts), ...Object.keys(profile.contracts)];
145
251
  const appContracts = contractsWithAppAccess(profile);
146
- const agents = Object.fromEntries([
147
- ...Object.entries(profile.experts).map(([name, e]) => [name, { description: e.description, prompt: e.prompt, tools: e.tools, model: 'inherit', maxTurns: e.maxTurns, ...(e.effort ? { effort: e.effort } : {}), omitClaudeMd: true }]),
252
+ const extraTools = options.extraTools ?? [];
253
+ const extraServers = new Set(extraTools.map(t => /^mcp__([a-zA-Z0-9_-]+)__[a-zA-Z0-9_-]+$/.exec(t)?.[1]));
254
+ for (const server of extraServers)
255
+ if (!server || server === 'tealbrick' || Object.hasOwn(profile.mcpServers, server))
256
+ throw Error('kit_native_extra_mcp_server_conflict');
257
+ // In the sandboxed workspace session, expert and contract tools are intersected with workspaceTools
258
+ // (they lose Read/Glob/Grep like the owner); decideTool enforces the same intersection per call.
259
+ const expertTools = (tools, sandboxed) => sandboxed ? tools.filter(t => workspaceTools.includes(t)) : [...tools];
260
+ const agentsFor = (sandboxed) => Object.fromEntries([
261
+ ...Object.entries(profile.experts).map(([name, e]) => [name, { description: e.description, prompt: e.prompt, tools: expertTools(e.tools, sandboxed), model: 'inherit', maxTurns: e.maxTurns, ...(e.effort ? { effort: e.effort } : {}), omitClaudeMd: true }]),
148
262
  ...Object.values(profile.contracts).map(c => [c.id, { description: `${c.purpose} (contract ${c.id}@${c.version})`,
149
263
  prompt: `${c.instructions}\n\nYou are the ${c.id} expert (contract ${c.id}@${c.version}). The brief you receive should match this JSON schema:\n\`\`\`json\n${JSON.stringify(c.input)}\n\`\`\`\nEnd your final message with one fenced json block that matches this output schema:\n\`\`\`json\n${JSON.stringify(c.output)}\n\`\`\`\nYou hold no credentials and cannot delegate. ${c.approval.outwardWrites === 'always' ? 'Any outward write pauses for the owner\'s approval of the exact payload; report a declined approval as blocked.' : 'Do not attempt outward writes.'}`,
150
- tools: [...c.capabilities.builtinTools, ...(appContracts.includes(c.id) ? ['mcp__tealbrick__tealbrick_capabilities', 'mcp__tealbrick__tealbrick_call'] : [])],
264
+ tools: [...expertTools(c.capabilities.builtinTools, sandboxed), ...(appContracts.includes(c.id) ? ['mcp__tealbrick__tealbrick_capabilities', 'mcp__tealbrick__tealbrick_call'] : [])],
151
265
  model: c.model, maxTurns: c.budget.maxTurns, ...(c.effort ? { effort: c.effort } : {}), omitClaudeMd: true }]),
152
266
  ]);
153
267
  // Payload approvals waiting on the owner (bridge input.requested → respond).
@@ -170,6 +284,7 @@ export function claudeHarness(options) {
170
284
  log({ event: 'claude.trace.failed' });
171
285
  }
172
286
  };
287
+ const grantStore = options.standingGrants ?? standingGrantStore(undefined);
173
288
  const digest = (v) => createHash('sha256').update(typeof v === 'string' ? v : JSON.stringify(v ?? null)).digest('hex').slice(0, 16);
174
289
  const clip = (v, n) => { const t = typeof v === 'string' ? v : JSON.stringify(v ?? null); return t.length > n ? t.slice(0, n) + '…' : t; };
175
290
  const expertRef = (type) => { const c = type !== undefined && Object.hasOwn(profile.contracts, type) ? profile.contracts[type] : undefined; return c ? `${c.id}@${c.version}` : type; };
@@ -179,13 +294,65 @@ export function claudeHarness(options) {
179
294
  if (appContracts.length)
180
295
  out.TB_CHILD_CONTRACTS = appContracts.join(',');
181
296
  for (const name of [...safeEnvNames, ...profile.envPassthrough])
182
- if (env[name] && !name.startsWith('TEALBRICK_'))
297
+ if (env[name] && !name.startsWith('TEALBRICK_') && !neverForwardedEnv.includes(name))
183
298
  out[name] = env[name];
184
299
  return out;
185
300
  };
301
+ const workspaceTmp = () => join(profile.workspace.dir, '.tmp');
302
+ /** SDK sandbox for the workspace session. It is the only filesystem boundary: the session has no in-process file tools. */
303
+ const sandboxSettings = (childEnvironment) => {
304
+ const ws = profile.workspace, home = childEnvironment.HOME ?? homedir();
305
+ const covers = (base, p) => { const rel = relative(base, p); return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel)); };
306
+ // The owners directory (siblings of this kit root: other owners' roots) when the layout makes it identifiable.
307
+ const owners = dirname(ws.root), depth = owners.split('/').filter(Boolean).length;
308
+ const denyRead = [...new Set([home, ...(owners !== home && !covers(owners, home) && (depth >= 3 || covers(home, owners)) ? [owners] : []), join(ws.root, '.tealbrick'), ...options.protectedPaths, '/Volumes', '/private/var/folders'])];
309
+ let nodePrefix;
310
+ try {
311
+ nodePrefix = dirname(dirname(realpathSync(process.execPath)));
312
+ }
313
+ catch { }
314
+ const configDir = childEnvironment.CLAUDE_CONFIG_DIR ?? join(home, '.claude');
315
+ // allowRead takes precedence over denyRead for matching paths (SDK sandbox.filesystem.allowRead), so each
316
+ // extra entry must not contain a denied path: that would re-open it (e.g. a node prefix equal to HOME).
317
+ const extras = [
318
+ nodePrefix, // node and npm for shell commands
319
+ join(configDir, 'shell-snapshots'), // Claude Code sources its shell snapshot inside the sandbox
320
+ ...homebrewReadRoots.filter(p => existsSync(p)),
321
+ ].filter((p) => !!p && p !== '/' && !denyRead.some(d => covers(p, d)));
322
+ const passthroughSecrets = profile.envPassthrough.filter(n => !ws.envAllow.includes(n));
323
+ const secretEnv = ['BUZZ_PRIVATE_KEY', 'BUZZ_AUTH_TAG', ...['ANTHROPIC_API_KEY', 'CLAUDE_CODE_OAUTH_TOKEN'].filter(n => childEnvironment[n] !== undefined), ...Object.keys(env).filter(n => n.startsWith('TEALBRICK_')), ...passthroughSecrets];
324
+ // Claude Code keeps every session's tool output under /tmp/claude-<uid>/<cwd slug>/ and points sandboxed Bash
325
+ // TMPDIR at that root, shared by every owner running as this OS user. The root is unreadable except this owner's
326
+ // own slug; it stays writable because Claude Code's shell writes its cwd-tracking file there, but the other
327
+ // owners' slugs (siblings in the owners directory) are write-denied. TMPDIR itself is moved into the workspace
328
+ // by the Bash command rewrite.
329
+ const uid = typeof process.getuid === 'function' ? process.getuid() : undefined;
330
+ const tmpRoots = uid === undefined ? [] : claudeTmpRoots(uid), ownTmp = tmpRoots.map(t => join(t, claudeTmpSlug(ws.dir)));
331
+ let siblings = [];
332
+ try {
333
+ siblings = readdirSync(owners, { withFileTypes: true }).filter(e => e.isDirectory() && join(owners, e.name) !== ws.root).map(e => join(owners, e.name));
334
+ }
335
+ catch { }
336
+ const otherTmp = tmpRoots.flatMap(t => siblings.flatMap(o => [join(t, claudeTmpSlug(o)), join(t, claudeTmpSlug(join(o, 'work')))]));
337
+ return {
338
+ enabled: true, failIfUnavailable: true, autoAllowBashIfSandboxed: true, allowUnsandboxedCommands: false,
339
+ filesystem: {
340
+ allowWrite: [ws.dir, workspaceTmp(), ...ownTmp],
341
+ denyWrite: otherTmp,
342
+ denyRead: [...denyRead, ...tmpRoots],
343
+ // Re-open only this owner's workspace, the kit-owned attachment inbox (read-only) and toolchain paths.
344
+ allowRead: [...new Set([ws.dir, servePaths(ws.root).inbox, ...ownTmp, ...extras])],
345
+ },
346
+ network: { allowedDomains: [...ws.allowedDomains], strictAllowlist: true },
347
+ credentials: { envVars: [...new Set(secretEnv)].map(name => ({ name, mode: 'deny' })) },
348
+ };
349
+ };
186
350
  const optionsFor = (sessionId, access, abort) => {
187
- const policy = { access, profile, protectedPaths: options.protectedPaths, ...(options.resolveApp ? { resolveApp: options.resolveApp } : {}) };
188
- const builtins = access.tools.filter(t => !t.startsWith('mcp__'));
351
+ const policy = { access, profile, protectedPaths: options.protectedPaths, ...(options.resolveApp ? { resolveApp: options.resolveApp } : {}), ...(extraTools.length ? { ownerTools: extraTools } : {}) };
352
+ const extra = Object.fromEntries(Object.entries(options.extraMcpServers?.(sessionId) ?? {}).filter(([name]) => extraServers.has(name)));
353
+ const sandboxed = access.mode === 'restricted' && access.sandbox === true && !!profile.workspace;
354
+ // A stale access snapshot never brings unsandboxed file tools back into the workspace session.
355
+ const builtins = access.tools.filter(t => !t.startsWith('mcp__') && (!sandboxed || workspaceTools.includes(t)));
189
356
  const servers = access.mode === 'restricted' ? Object.fromEntries(Object.entries(profile.mcpServers).filter(([name]) => profile.readTools.some(t => t.startsWith(`mcp__${name}__`)))) : profile.mcpServers;
190
357
  const hook = async (input) => {
191
358
  if (input?.hook_event_name !== 'PreToolUse')
@@ -194,11 +361,21 @@ export function claudeHarness(options) {
194
361
  let decision = await decideTool(policy, name, args, input.agent_id, input.agent_type);
195
362
  let approval;
196
363
  if (decision.allow && needsApproval(profile, name, args, input.agent_type)) {
197
- const who = child ? `expert ${expertRef(input.agent_type)}` : options.name;
198
- const ok = await requestApproval(sessionId, `Approve ${who}: ${String(args.operation)} on ${String(args.registrationId)}\n${clip(args.input, 4000)}`);
199
- approval = ok ? 'approved' : 'declined';
200
- if (!ok)
201
- decision = { allow: false, reason: 'The owner declined (or did not approve in time) this exact payload' };
364
+ // Owner standing grants (config, enforced here, never by the model) replace the prompt within their caps.
365
+ const standing = await standingGrantFor({ profile, store: grantStore, ...(options.resolveApp ? { resolveApp: options.resolveApp } : {}), trace: r => void trace({ sessionId, ...(child ? { agentId: input.agent_id, expert: expertRef(input.agent_type) } : {}), ...r }) }, { name, input: args, ...(child ? { agentId: String(input.agent_id), ...(input.agent_type !== undefined ? { agentType: String(input.agent_type) } : {}) } : {}) });
366
+ if (standing) {
367
+ approval = 'standing:' + standing.grant.id;
368
+ void trace({ event: 'owner.approval', sessionId, approval: 'standing', grantId: standing.grant.id, operation: String(args.operation ?? ''), registrationId: String(args.registrationId ?? ''),
369
+ ...(standing.ref?.plugin !== undefined ? { plugin: standing.ref.plugin } : {}), ...(standing.ref?.action !== undefined ? { action: standing.ref.action } : {}), ...(child ? { agentId: input.agent_id, expert: expertRef(input.agent_type) } : {}) });
370
+ decision = { allow: true, reason: `Pre-approved by the owner's standing grant ${standing.grant.id}` };
371
+ }
372
+ else {
373
+ const who = child ? `expert ${expertRef(input.agent_type)}` : options.name;
374
+ const ok = await requestApproval(sessionId, `Approve ${who}: ${String(args.operation)} on ${String(args.registrationId)}\n${clip(args.input, 4000)}`);
375
+ approval = ok ? 'approved' : 'declined';
376
+ if (!ok)
377
+ decision = { allow: false, reason: 'The owner declined (or did not approve in time) this exact payload' };
378
+ }
202
379
  }
203
380
  log({ event: 'claude.tool', sessionId, tool: name.slice(0, 120), allowed: decision.allow, child, ...(approval ? { approval } : {}) });
204
381
  if (child)
@@ -206,9 +383,12 @@ export function claudeHarness(options) {
206
383
  ...(name === 'mcp__tealbrick__tealbrick_call' ? { registrationId: String(args.registrationId ?? ''), operation: String(args.operation ?? '') } : {}), allowed: decision.allow, ...(approval ? { approval } : {}) });
207
384
  else if ((name === 'Agent' || name === 'Task') && decision.allow)
208
385
  void trace({ event: 'delegation.start', sessionId, toolUseId: input.tool_use_id, expert: expertRef(String(args.subagent_type ?? '')), brief: clip(args.prompt, 2000), briefSha: digest(args.prompt) });
209
- else if (approval)
386
+ else if (approval && !approval.startsWith('standing:'))
210
387
  void trace({ event: 'owner.approval', sessionId, operation: String(args.operation ?? ''), registrationId: String(args.registrationId ?? ''), approval });
211
- return { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: decision.allow ? 'allow' : 'deny', permissionDecisionReason: decision.reason } };
388
+ // Sandboxed Bash: temp files go to the workspace, not the shared /tmp/claude-<uid>.
389
+ const rewrite = sandboxed && decision.allow && name === 'Bash' && typeof args.command === 'string'
390
+ ? { updatedInput: { ...args, command: `export TMPDIR=${shq(workspaceTmp())} TMP=${shq(workspaceTmp())} TEMP=${shq(workspaceTmp())}; ${args.command}` } } : {};
391
+ return { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: decision.allow ? 'allow' : 'deny', permissionDecisionReason: decision.reason, ...rewrite } };
212
392
  };
213
393
  const lifecycle = async (input) => {
214
394
  const e = input?.hook_event_name;
@@ -220,18 +400,30 @@ export function claudeHarness(options) {
220
400
  void trace({ event: 'delegation.end', sessionId, toolUseId: input.tool_use_id, outputSha: digest(input.tool_response), outputChars: clip(input.tool_response, 1e9).length });
221
401
  return {};
222
402
  };
223
- const append = [profile.instructions?.trim(), `You are ${options.name}, chatting with your owner through TBD. ${accessNote(access, experts)}`].filter(Boolean).join('\n\n');
403
+ const append = [profile.instructions?.trim(), `You are ${options.name}, chatting with your owner through TBD. ${accessNote(access, experts, profile)}`, standingGrantNote(profile.standingGrants, grantStore.now()), options.systemNote?.trim()].filter(Boolean).join('\n\n');
404
+ const sdkEnv = childEnv();
405
+ // HOME is unreadable inside the sandbox: temp files and git's global config live in the workspace instead.
406
+ // CLAUDE_CODE_TMPDIR keeps Claude Code's own temp files (screenshots, hook copies) in this owner's workspace.
407
+ // It does not move the sandboxed Bash TMPDIR (/tmp/claude-<uid>), which owners running as the same OS user
408
+ // share; see the README limits. GIT_TEMPLATE_DIR keeps git from copying hooks the sandbox refuses to write.
409
+ if (sandboxed) {
410
+ sdkEnv.TMPDIR = workspaceTmp();
411
+ sdkEnv.CLAUDE_CODE_TMPDIR = workspaceTmp();
412
+ sdkEnv.GIT_CONFIG_GLOBAL = join(workspaceTmp(), 'gitconfig');
413
+ sdkEnv.GIT_TEMPLATE_DIR = join(workspaceTmp(), 'git-template');
414
+ }
224
415
  const delegates = experts.length > 0 && (access.mode === 'full' || builtins.includes('Agent'));
225
416
  return {
226
- cwd: profile.cwd, env: childEnv(),
417
+ cwd: profile.cwd, env: sdkEnv,
418
+ ...(sandboxed ? { sandbox: sandboxSettings(sdkEnv) } : {}),
227
419
  ...(profile.model ? { model: profile.model } : {}), ...(profile.effort ? { effort: profile.effort } : {}),
228
420
  maxTurns: profile.maxTurns, ...(profile.maxBudgetUsd !== undefined ? { maxBudgetUsd: profile.maxBudgetUsd } : {}),
229
421
  settingSources: [], strictMcpConfig: true,
230
- mcpServers: { ...servers, tealbrick: attachment.mcpServers.tealbrick },
422
+ mcpServers: { ...servers, ...extra, tealbrick: attachment.mcpServers.tealbrick },
231
423
  settings: attachment.settings,
232
424
  tools: access.mode === 'full' ? { type: 'preset', preset: 'claude_code' } : builtins,
233
- ...(delegates ? { agents } : {}),
234
- disallowedTools: access.mode === 'full' ? [] : restrictedDenied.filter(t => !builtins.includes(t === 'Task' ? 'Agent' : t)),
425
+ ...(delegates ? { agents: agentsFor(sandboxed) } : {}),
426
+ disallowedTools: access.mode === 'full' ? [] : [...new Set([...restrictedDenied, ...(sandboxed ? unsandboxedFileTools : [])])].filter(t => !builtins.includes(t === 'Task' ? 'Agent' : t)),
235
427
  permissionMode: access.mode === 'full' ? 'bypassPermissions' : 'default',
236
428
  ...(access.mode === 'full' ? { allowDangerouslySkipPermissions: true } : {
237
429
  canUseTool: async (name, input) => { const d = await decideTool(policy, name, input); return d.allow ? { behavior: 'allow', updatedInput: input } : { behavior: 'deny', message: d.reason }; },
@@ -255,6 +447,9 @@ export function claudeHarness(options) {
255
447
  const timer = setTimeout(() => { turn.timedOut = true; abort.abort(); }, profile.timeoutSeconds * 1000);
256
448
  let result, assistantError, failure, finalText = false, buffer = '', stopReason = null;
257
449
  try {
450
+ if (access.sandbox && profile.workspace) {
451
+ await mkdir(join(workspaceTmp(), 'git-template'), { recursive: true, mode: 0o700 });
452
+ }
258
453
  const q = sdk.query({ prompt: prompt(), options: optionsFor(sessionId, access, abort) });
259
454
  turn.query = q;
260
455
  if (profile.requireSubscription) {
@@ -338,7 +533,7 @@ export function claudeHarness(options) {
338
533
  log({ event: 'claude.turn.failed', sessionId, code: failure.code });
339
534
  emit({ type: 'turn.ended', sessionId, status: 'failed', code: failure.code, message: failure.message, turnId: turn.turnId });
340
535
  }
341
- const capabilities = ['chat', 'resume', 'cancel', 'mcp', 'session-access', 'approvals', ...(experts.length ? ['subagents'] : [])];
536
+ const capabilities = ['chat', 'resume', 'cancel', 'mcp', 'session-access', 'approvals', ...(experts.length ? ['subagents'] : []), ...(profile.standingGrants.length ? ['standing-grants'] : [])];
342
537
  return {
343
538
  info: { harness: 'claude', protocol: 'tealbrick-native', transport: 'claude-agent-sdk', workflowId: 'claude-agent-sdk', ...(profile.model ? { model: profile.model } : {}), capabilities,
344
539
  unsupported: ['eve-info', 'eve-workflows', 'eve-schedules', 'file-attachments', 'session-reset', 'session-clear', 'session-compact', 'client-context', 'output-schema'] },
@@ -77,6 +77,32 @@ export declare const expertContractSchema: z.ZodObject<{
77
77
  changelog: z.ZodOptional<z.ZodArray<z.ZodString>>;
78
78
  }, z.core.$strict>;
79
79
  export type ExpertContract = z.infer<typeof expertContractSchema>;
80
+ /**
81
+ * Owner-defined standing grant: a bounded class of calls that would otherwise pause for payload approval
82
+ * and that the kit hook approves without a prompt, within caps, until it expires. Never a wider scope than
83
+ * decideTool already allows; it only replaces the per-payload prompt.
84
+ */
85
+ export declare const standingGrantSchema: z.ZodObject<{
86
+ id: z.ZodString;
87
+ description: z.ZodString;
88
+ operation: z.ZodDefault<z.ZodString>;
89
+ match: z.ZodObject<{
90
+ registrationId: z.ZodOptional<z.ZodString>;
91
+ plugin: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>>;
92
+ action: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>>;
93
+ }, z.core.$strict>;
94
+ experts: z.ZodOptional<z.ZodArray<z.ZodString>>;
95
+ caps: z.ZodObject<{
96
+ perDay: z.ZodNumber;
97
+ perHour: z.ZodOptional<z.ZodNumber>;
98
+ minIntervalSeconds: z.ZodOptional<z.ZodNumber>;
99
+ }, z.core.$strict>;
100
+ expires: z.ZodISODateTime;
101
+ deny: z.ZodOptional<z.ZodObject<{
102
+ inputPattern: z.ZodOptional<z.ZodArray<z.ZodString>>;
103
+ }, z.core.$strict>>;
104
+ }, z.core.$strict>;
105
+ export type StandingGrant = z.infer<typeof standingGrantSchema>;
80
106
  export declare const serveConfigSchema: z.ZodObject<{
81
107
  version: z.ZodLiteral<1>;
82
108
  port: z.ZodNumber;
@@ -134,6 +160,26 @@ export declare const serveConfigSchema: z.ZodObject<{
134
160
  }, z.core.$strict>>>;
135
161
  contracts: z.ZodOptional<z.ZodArray<z.ZodString>>;
136
162
  approvalOperations: z.ZodOptional<z.ZodArray<z.ZodString>>;
163
+ standingGrants: z.ZodOptional<z.ZodArray<z.ZodObject<{
164
+ id: z.ZodString;
165
+ description: z.ZodString;
166
+ operation: z.ZodDefault<z.ZodString>;
167
+ match: z.ZodObject<{
168
+ registrationId: z.ZodOptional<z.ZodString>;
169
+ plugin: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>>;
170
+ action: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>>;
171
+ }, z.core.$strict>;
172
+ experts: z.ZodOptional<z.ZodArray<z.ZodString>>;
173
+ caps: z.ZodObject<{
174
+ perDay: z.ZodNumber;
175
+ perHour: z.ZodOptional<z.ZodNumber>;
176
+ minIntervalSeconds: z.ZodOptional<z.ZodNumber>;
177
+ }, z.core.$strict>;
178
+ expires: z.ZodISODateTime;
179
+ deny: z.ZodOptional<z.ZodObject<{
180
+ inputPattern: z.ZodOptional<z.ZodArray<z.ZodString>>;
181
+ }, z.core.$strict>>;
182
+ }, z.core.$strict>>>;
137
183
  nativeTools: z.ZodOptional<z.ZodArray<z.ZodUnion<readonly [z.ZodString, z.ZodString]>>>;
138
184
  readTools: z.ZodOptional<z.ZodArray<z.ZodString>>;
139
185
  mcpServers: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
@@ -148,6 +194,13 @@ export declare const serveConfigSchema: z.ZodObject<{
148
194
  inputEquals: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean, z.ZodNull]>>>;
149
195
  }, z.core.$strict>>>;
150
196
  requireSubscription: z.ZodOptional<z.ZodBoolean>;
197
+ workspace: z.ZodOptional<z.ZodObject<{
198
+ dir: z.ZodString;
199
+ network: z.ZodOptional<z.ZodObject<{
200
+ allowedDomains: z.ZodArray<z.ZodString>;
201
+ }, z.core.$strict>>;
202
+ envAllow: z.ZodOptional<z.ZodArray<z.ZodString>>;
203
+ }, z.core.$strict>>;
151
204
  envPassthrough: z.ZodOptional<z.ZodArray<z.ZodString>>;
152
205
  }, z.core.$strict>>;
153
206
  codex: z.ZodOptional<z.ZodObject<{
@@ -169,12 +222,22 @@ export interface ClaudeProfile {
169
222
  experts: Record<string, ClaudeExpert>;
170
223
  contracts: Record<string, ExpertContract>;
171
224
  approvalOperations: string[];
225
+ standingGrants: StandingGrant[];
172
226
  nativeTools: string[];
173
227
  readTools: string[];
174
228
  mcpServers: Record<string, z.infer<typeof stdio>>;
175
229
  tealbrickCalls: Array<z.infer<typeof tealbrickCallGrant>>;
176
230
  requireSubscription: boolean;
177
231
  envPassthrough: string[];
232
+ /** Present only when `claude.workspace` is configured; `cwd` is then the workspace directory. */
233
+ workspace?: ClaudeWorkspace;
234
+ }
235
+ export interface ClaudeWorkspace {
236
+ dir: string;
237
+ root: string;
238
+ allowedDomains: string[];
239
+ /** envPassthrough names explicitly marked non-secret (still visible to sandboxed shell commands). */
240
+ envAllow: string[];
178
241
  }
179
242
  export interface ClaudeExpert {
180
243
  description: string;
@@ -187,6 +250,8 @@ export declare const servePaths: (root: string) => {
187
250
  config: string;
188
251
  state: string;
189
252
  sdk: string;
253
+ /** Kit-owned attachment inbox: written only by the kit process, readable (never writable) by the sandboxed workspace session. */
254
+ inbox: string;
190
255
  };
191
256
  export declare function normalizeOrigin(value: string): string;
192
257
  /** Host header values a TLS-terminating proxy for `publicUrl` may present. */
@@ -1,6 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import { lstat, readFile, realpath, writeFile, rename, mkdir } from 'node:fs/promises';
3
- import { isAbsolute, join, resolve } from 'node:path';
3
+ import { isAbsolute, join, relative, resolve } from 'node:path';
4
4
  import { randomUUID } from 'node:crypto';
5
5
  /** Operator configuration for `tealbrick native serve`. Contains no credentials. */
6
6
  const absolute = z.string().min(1).refine(v => isAbsolute(v) && !v.includes('\0'), 'absolute path required');
@@ -16,6 +16,12 @@ export const defaultAccessTools = ['WebSearch', 'WebFetch', 'Agent', 'TodoWrite'
16
16
  /** Tools an expert subagent may hold; workspace reads stay contained and network content stays untrusted. */
17
17
  const expertTool = z.enum(['Read', 'Glob', 'Grep', 'WebSearch', 'WebFetch']);
18
18
  const expertName = z.string().regex(/^[a-z][a-z0-9-]{0,63}$/);
19
+ /** Sandboxed owner workspace: all file work runs through the sandboxed Bash inside `dir`; shell network limited to allowedDomains. */
20
+ const domain = z.string().regex(/^(?:\*\.)?[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$/, 'lowercase host name or *.host');
21
+ const envName = z.string().regex(/^[A-Z][A-Z0-9_]{0,63}$/);
22
+ const workspace = z.object({ dir: absolute, network: z.object({ allowedDomains: z.array(domain).max(64) }).strict().optional(),
23
+ /** envPassthrough names that are NOT secret and may stay visible to sandboxed shell commands (all others are removed there). */
24
+ envAllow: z.array(envName).max(32).optional() }).strict();
19
25
  /** Versioned expert contract (tealbrick.expert-contract/v1). The contract is a ceiling; Portal grants still apply. */
20
26
  const opName = z.string().regex(/^[a-z][a-z0-9_.]{1,120}$/);
21
27
  export const expertContractSchema = z.object({
@@ -38,6 +44,29 @@ export const expertContractSchema = z.object({
38
44
  runtime: z.enum(['subagent', 'desktop-operator']),
39
45
  changelog: z.array(z.string().max(300)).optional(),
40
46
  }).strict();
47
+ /** Simple glob: exact text, or '*' for any run of characters. */
48
+ const globText = z.string().min(1).max(200);
49
+ const globList = z.union([globText, z.array(globText).min(1).max(32)]);
50
+ /**
51
+ * Owner-defined standing grant: a bounded class of calls that would otherwise pause for payload approval
52
+ * and that the kit hook approves without a prompt, within caps, until it expires. Never a wider scope than
53
+ * decideTool already allows; it only replaces the per-payload prompt.
54
+ */
55
+ export const standingGrantSchema = z.object({
56
+ id: z.string().regex(/^[a-z0-9][a-z0-9-]{1,47}$/),
57
+ description: z.string().min(1).max(300),
58
+ operation: opName.default('marketplace_execute'),
59
+ /** registrationId is case-sensitive; plugin/action are case-insensitive. All support '*'. At least one is required. */
60
+ match: z.object({ registrationId: globText.optional(), plugin: globList.optional(), action: globList.optional() }).strict()
61
+ .refine(m => m.registrationId !== undefined || m.plugin !== undefined || m.action !== undefined, 'match needs registrationId, plugin or action (use "*" to mean any)'),
62
+ /** Contract ids whose expert subagents may also use this grant. Omitted: the owner session only. */
63
+ experts: z.array(z.string().regex(/^[a-z][a-z0-9-]{1,62}$/)).max(32).optional(),
64
+ caps: z.object({ perDay: z.number().int().min(1).max(200), perHour: z.number().int().min(1).max(200).optional(), minIntervalSeconds: z.number().int().min(1).max(86400).optional() }).strict(),
65
+ /** ISO 8601 date-time with offset. Expired grants never match. */
66
+ expires: z.iso.datetime({ offset: true }),
67
+ /** Case-insensitive literal substrings of JSON.stringify(call input); any hit means the grant does not apply. */
68
+ deny: z.object({ inputPattern: z.array(z.string().min(1).max(200)).max(32).optional() }).strict().optional(),
69
+ }).strict();
41
70
  const expert = z.object({ promptFile: absolute, description: z.string().min(1).max(300).optional(), tools: z.array(expertTool).max(8).optional(), maxTurns: z.number().int().min(1).max(50).optional(), effort: effort.optional() }).strict();
42
71
  const claude = z.object({
43
72
  /** Package directory or entry file of @anthropic-ai/claude-agent-sdk. Default: resolved from the kit root. */
@@ -60,6 +89,9 @@ const claude = z.object({
60
89
  contracts: z.array(absolute).max(32).optional(),
61
90
  /** Operations that always pause for the owner's approval of the exact payload, whoever calls them. */
62
91
  approvalOperations: z.array(opName).max(64).optional(),
92
+ /** Owner pre-approvals for bounded classes of approval-gated calls (see README "Standing grants"). */
93
+ standingGrants: z.array(standingGrantSchema).max(64).optional()
94
+ .refine(g => !g || new Set(g.map(x => x.id)).size === g.length, 'standing grant ids must be unique'),
63
95
  /** Tool allowlist for the opt-in "Native defaults" session access mode. */
64
96
  nativeTools: z.array(tool).max(64).optional(),
65
97
  /** Operator-vetted read-only MCP tools, also available in Restricted mode. */
@@ -68,8 +100,10 @@ const claude = z.object({
68
100
  tealbrickCalls: z.array(tealbrickCallGrant).max(64).optional(),
69
101
  /** Refuse turns unless the SDK reports first-party subscription sign-in (no API-key fallback). */
70
102
  requireSubscription: z.boolean().optional(),
103
+ /** Opt-in sandboxed workspace for the owner session (inside the kit root, never containing .tealbrick). */
104
+ workspace: workspace.optional(),
71
105
  /** Extra environment variable names passed to the SDK process. TEALBRICK_* is never passed. */
72
- envPassthrough: z.array(z.string().regex(/^[A-Z][A-Z0-9_]{0,63}$/).refine(n => !n.startsWith('TEALBRICK_'), 'Teal Brick credentials are never passed to the harness')).max(32).optional(),
106
+ envPassthrough: z.array(envName.refine(n => !n.startsWith('TEALBRICK_'), 'Teal Brick credentials are never passed to the harness')).max(32).optional(),
73
107
  }).strict();
74
108
  const codex = z.object({ codexBin: absolute, cwd: absolute }).strict();
75
109
  export const serveConfigSchema = z.object({
@@ -85,7 +119,9 @@ export const serveConfigSchema = z.object({
85
119
  claude: claude.optional(),
86
120
  codex: codex.optional(),
87
121
  }).strict();
88
- export const servePaths = (root) => ({ config: join(root, '.tealbrick', 'native-serve.json'), state: join(root, '.tealbrick', 'native-serve'), sdk: join(root, '.tealbrick', 'native-sdk.json') });
122
+ export const servePaths = (root) => ({ config: join(root, '.tealbrick', 'native-serve.json'), state: join(root, '.tealbrick', 'native-serve'), sdk: join(root, '.tealbrick', 'native-sdk.json'),
123
+ /** Kit-owned attachment inbox: written only by the kit process, readable (never writable) by the sandboxed workspace session. */
124
+ inbox: join(root, 'inbox') });
89
125
  async function regularFile(path, { ownerOnly }) {
90
126
  const stat = await lstat(path);
91
127
  if (!stat.isFile() || stat.isSymbolicLink())
@@ -190,7 +226,8 @@ export async function resolveClaudeProfile(root, config) {
190
226
  if (agent.tealbrickSdkConfig && await realpath(agent.tealbrickSdkConfig) !== await realpath(servePaths(root).sdk))
191
227
  throw Error('kit_native_serve_recipe_connection_mismatch');
192
228
  }
193
- const cwd = resolve(c.cwd), protectedRoot = join(await realpath(root), '.tealbrick');
229
+ let cwd = resolve(c.cwd);
230
+ const protectedRoot = join(await realpath(root), '.tealbrick');
194
231
  if (protectedRoot.startsWith(cwd + '/') || cwd === protectedRoot || cwd.startsWith(protectedRoot + '/'))
195
232
  throw Error('kit_native_serve_cwd_contains_credentials');
196
233
  if (!(await lstat(cwd).catch(() => undefined))?.isDirectory())
@@ -198,6 +235,28 @@ export async function resolveClaudeProfile(root, config) {
198
235
  const instructions = c.instructionsFile ? (await regularFile(c.instructionsFile, { ownerOnly: false }), await readFile(c.instructionsFile, 'utf8')) : undefined;
199
236
  if (instructions && instructions.length > 200000)
200
237
  throw Error('kit_native_serve_instructions_too_large');
238
+ let workspace;
239
+ if (c.workspace) {
240
+ const realRoot = await realpath(root), inside = (base, p) => { const rel = relative(base, p); return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel); };
241
+ const dir = resolve(c.workspace.dir), tb = (base) => join(base, '.tealbrick');
242
+ const lexical = (base) => inside(base, dir) && dir !== tb(base) && !inside(tb(base), dir);
243
+ if (!lexical(realRoot) && !lexical(resolve(root)))
244
+ throw Error('kit_native_serve_workspace_outside_root');
245
+ // The attachment inbox must stay outside anything the sandbox can write.
246
+ const overlapsInbox = (base, p) => { const inbox = servePaths(base).inbox; return p === inbox || inside(inbox, p); };
247
+ if (overlapsInbox(realRoot, dir) || overlapsInbox(resolve(root), dir))
248
+ throw Error('kit_native_serve_workspace_overlaps_inbox');
249
+ await mkdir(dir, { recursive: true, mode: 0o700 });
250
+ const real = await realpath(dir);
251
+ if (!inside(realRoot, real) || real === tb(realRoot) || inside(tb(realRoot), real))
252
+ throw Error('kit_native_serve_workspace_outside_root');
253
+ if (overlapsInbox(realRoot, real))
254
+ throw Error('kit_native_serve_workspace_overlaps_inbox');
255
+ if (await lstat(join(real, '.tealbrick')).then(() => true, () => false))
256
+ throw Error('kit_native_serve_workspace_contains_credentials');
257
+ workspace = { dir: real, root: realRoot, allowedDomains: [...new Set(c.workspace.network?.allowedDomains ?? [])], envAllow: [...new Set(c.workspace.envAllow ?? [])] };
258
+ cwd = real;
259
+ }
201
260
  const readTools = c.readTools ?? agent?.readTools ?? [];
202
261
  const contracts = {};
203
262
  for (const path of c.contracts ?? []) {
@@ -223,10 +282,15 @@ export async function resolveClaudeProfile(root, config) {
223
282
  for (const id of Object.keys(contracts))
224
283
  if (Object.hasOwn(experts, id))
225
284
  throw Error('kit_native_serve_contract_duplicate:' + id);
285
+ const standingGrants = c.standingGrants ?? [];
286
+ for (const g of standingGrants)
287
+ for (const id of g.experts ?? [])
288
+ if (!Object.hasOwn(contracts, id))
289
+ throw Error('kit_native_serve_standing_grant_unknown_expert:' + g.id);
226
290
  return { cwd, sdkModule: c.sdkModule, instructions, model: c.model ?? recipe?.model, effort: c.effort ?? recipe?.effort,
227
291
  maxTurns: c.maxTurns ?? recipe?.maxTurns ?? 24, maxBudgetUsd: c.maxBudgetUsd ?? recipe?.maxBudgetUsd, timeoutSeconds: c.timeoutSeconds ?? recipe?.timeoutSeconds ?? 900,
228
- defaultTools: [...new Set(c.defaultTools ?? [])], experts, contracts, approvalOperations: [...new Set(c.approvalOperations ?? ['marketplace_execute'])],
292
+ defaultTools: [...new Set(c.defaultTools ?? [])], experts, contracts, approvalOperations: [...new Set(c.approvalOperations ?? ['marketplace_execute'])], standingGrants,
229
293
  nativeTools: c.nativeTools ?? ['Read', 'Glob', 'Grep', 'TodoWrite'], readTools: readTools.filter(t => !t.startsWith('mcp__tealbrick__')),
230
294
  mcpServers: c.mcpServers ?? agent?.mcpServers ?? {}, tealbrickCalls: c.tealbrickCalls ?? agent?.tealbrickCalls ?? [],
231
- requireSubscription: c.requireSubscription ?? false, envPassthrough: c.envPassthrough ?? [] };
295
+ requireSubscription: c.requireSubscription ?? false, envPassthrough: c.envPassthrough ?? [], ...(workspace ? { workspace } : {}) };
232
296
  }