@tealbrick/kit 0.3.0-rc.6 → 0.3.0-rc.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/BOOTSTRAP.md CHANGED
@@ -1,4 +1,4 @@
1
- > Prerelease 0.3.0-rc.6 targets fresh Eve 0.70 installations. Use the explicit candidate version or `next` tag after publication. Scoped `@tealbrick/*` `latest` remains 0.2.7 for existing Eve 0.66 installations; durable cross-version session migration is not validated.
1
+ > Prerelease 0.3.0-rc.7 targets fresh Eve 0.70 installations. Use the explicit candidate version or `next` tag after publication. Scoped `@tealbrick/*` `latest` remains 0.2.7 for existing Eve 0.66 installations; durable cross-version session migration is not validated.
2
2
 
3
3
  # Deployment guide: Eve agent + Teal Brick kit
4
4
 
@@ -19,7 +19,7 @@ For a fresh machine, the sequence is:
19
19
 
20
20
  1. Install Node 24 and npm.
21
21
  2. Create an Eve 0.70.0 project and configure its primary model credentials.
22
- 3. Install `@tealbrick/kit@0.3.0-rc.6` inside that project.
22
+ 3. Install `@tealbrick/kit@0.3.0-rc.7` inside that project.
23
23
  4. Run `tealbrick setup` to sign in, select capabilities and register the card.
24
24
  5. Build and start Eve, then check a real chat from desktop.
25
25
 
@@ -81,7 +81,7 @@ Run this inside the Eve project created in step 2 (or your existing compatible
81
81
  project), not in an empty directory or as a global install:
82
82
 
83
83
  ```bash
84
- npm install --save-exact @tealbrick/kit@0.3.0-rc.6
84
+ npm install --save-exact @tealbrick/kit@0.3.0-rc.7
85
85
  ```
86
86
 
87
87
  This installs Portal, AVM, Voice, Vision, Deliver and the shared provider transport
package/NATIVE.md CHANGED
@@ -1,10 +1,10 @@
1
1
  # Native agent setup — Stage A candidate
2
2
 
3
- Prerelease suite **0.3.0-rc.6** targets the `next` tag. The seven scoped `@tealbrick/*` packages keep `latest` at 0.2.7, which lacks this flow; the unscoped `tealbrick` facade keeps `latest` at 0.3.0-rc.1. The `tealbrick` facade forwards the existing kit CLI; it is not another SDK. Matching Portal runtime setup endpoints must be deployed before native onboarding can succeed. RC4 added `tealbrick native serve` and `native enroll` (see README.md). RC5 keeps an idle serve process's runtime acknowledged in Portal, and lets the runtime reach Portal-provisioned Knowledge with short-lived Portal grants when no local app binding exists. RC6 adds the owner-only `/tealbrick/v1/capabilities` snapshot (Portal-granted apps and Marketplace consents) used by TBD Chat → Plugins, and `tealbrick native` without a subcommand prints usage.
3
+ Prerelease suite **0.3.0-rc.7** targets the `next` tag. The seven scoped `@tealbrick/*` packages keep `latest` at 0.2.7, which lacks this flow; the unscoped `tealbrick` facade keeps `latest` at 0.3.0-rc.1. The `tealbrick` facade forwards the existing kit CLI; it is not another SDK. Matching Portal runtime setup endpoints must be deployed before native onboarding can succeed. RC4 added `tealbrick native serve` and `native enroll` (see README.md). RC5 keeps an idle serve process's runtime acknowledged in Portal, and lets the runtime reach Portal-provisioned Knowledge with short-lived Portal grants when no local app binding exists. RC6 adds the owner-only `/tealbrick/v1/capabilities` snapshot (Portal-granted apps and Marketplace consents) used by TBD Chat → Plugins, and `tealbrick native` without a subcommand prints usage. RC7 loads versioned expert contracts (`claude.contracts`) as SDK subagents with scope = contract ceiling ∩ owner grants, pauses approval operations for the owner's payload approval in TBD, and traces every delegation.
4
4
 
5
5
  ## Install and connect
6
6
 
7
- Install with `npm install --save-exact tealbrick@0.3.0-rc.6` (or `tealbrick@next`) once registry publication is verified. Until then, use the review tarballs with the checksum-verifying `install-candidate.mjs`.
7
+ Install with `npm install --save-exact tealbrick@0.3.0-rc.7` (or `tealbrick@next`) once registry publication is verified. Until then, use the review tarballs with the checksum-verifying `install-candidate.mjs`.
8
8
 
9
9
  From your native agent workspace:
10
10
 
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- > Prerelease 0.3.0-rc.6 targets fresh Eve 0.70 installations. Use the explicit candidate version or `next` tag after publication. Scoped `@tealbrick/*` `latest` remains 0.2.7 for existing Eve 0.66 installations; durable cross-version session migration is not validated.
1
+ > Prerelease 0.3.0-rc.7 targets fresh Eve 0.70 installations. Use the explicit candidate version or `next` tag after publication. Scoped `@tealbrick/*` `latest` remains 0.2.7 for existing Eve 0.66 installations; durable cross-version session migration is not validated.
2
2
 
3
3
  # @tealbrick/kit
4
4
 
@@ -9,7 +9,7 @@ availability, not agent permissions. Nothing is mounted by installing the kit.
9
9
  Install inside an existing Eve 0.70.0 project:
10
10
 
11
11
  ```sh
12
- npm install --save-exact @tealbrick/kit@0.3.0-rc.6
12
+ npm install --save-exact @tealbrick/kit@0.3.0-rc.7
13
13
  npx --no-install tealbrick setup
14
14
  ```
15
15
 
@@ -139,6 +139,15 @@ stops the loop with one `native.runtime.sync.stopped` line. The per-turn
139
139
  `Agent` delegates only to the configured `experts`, in the foreground with
140
140
  the session model; each expert runs with its own `tools` (workspace reads,
141
141
  `WebSearch`, `WebFetch`) and never receives Teal Brick tools.
142
+ - **Expert contracts** (`claude.contracts`: absolute paths to
143
+ `tealbrick.expert-contract/v1` JSON files, e.g. from the Agent-creation
144
+ cookbook) run as SDK subagents. Effective scope = contract ceiling ∩ the
145
+ owner's Portal grants ∩ the owner's `tealbrickCalls` ceiling; experts never
146
+ hold credentials or delegate. Payload approvals: `approvalOperations`
147
+ (default `marketplace_execute`) and a contract's outward writes pause the
148
+ exact call as an `input.requested` approval in TBD; decline or timeout denies.
149
+ Each delegation is traced to `.tealbrick/native-serve/traces/*.jsonl`
150
+ (owner, expert@version, brief, capabilities used, output digest).
142
151
  - `GET /tealbrick/v1/capabilities` (same owner auth) reports what Portal
143
152
  currently grants this agent: Knowledge apps from the background runtime sync
144
153
  and, when `tealbrick native marketplace-enable` is set, Marketplace consents
@@ -9,8 +9,13 @@ try {
9
9
  throw Error('oversized_hook');
10
10
  }
11
11
  const input = JSON.parse(text);
12
- if (input.hook_event_name === 'PreToolUse' && typeof input.tool_name === 'string' && input.tool_name.startsWith('mcp__tealbrick__') && input.agent_id === undefined)
13
- output = {};
12
+ if (input.hook_event_name === 'PreToolUse' && typeof input.tool_name === 'string' && input.tool_name.startsWith('mcp__tealbrick__')) {
13
+ // Parent: no override. A child only if native serve admitted its contract type (non-secret env, SDK-authored agent_type);
14
+ // the in-process hook still checks contract ceiling ∩ owner grants on every call.
15
+ const admitted = (process.env.TB_CHILD_CONTRACTS ?? '').split(',').filter(Boolean);
16
+ if (input.agent_id === undefined || (typeof input.agent_type === 'string' && admitted.includes(input.agent_type)))
17
+ output = {};
18
+ }
14
19
  }
15
20
  catch { }
16
21
  process.stdout.write(JSON.stringify(output) + '\n');
@@ -25,11 +25,22 @@ export type ClaudeAccess = {
25
25
  export declare const readOnlyTools: string[];
26
26
  /** Removed from the model's context unless a wider access mode explicitly lists them. */
27
27
  export declare const restrictedDenied: string[];
28
+ /** What a Portal registration id refers to; resolved from the runtime's own capability snapshot. */
29
+ export type AppRef = {
30
+ appId: string;
31
+ plugin?: string;
32
+ action?: string;
33
+ };
28
34
  export interface ToolPolicy {
29
35
  access: ClaudeAccess;
30
36
  profile: ClaudeProfile;
31
37
  protectedPaths: string[];
38
+ resolveApp?: (registrationId: string) => Promise<AppRef | undefined>;
32
39
  }
40
+ /** Contract experts that may reach the owner's Teal Brick connector (still bounded per call by decideTool). */
41
+ export declare const contractsWithAppAccess: (profile: ClaudeProfile) => string[];
42
+ /** Payload-bound approval: owner-wide approval operations, plus a contract's own list and any outward write by a contract expert. */
43
+ export declare function needsApproval(profile: ClaudeProfile, name: string, input: Record<string, unknown>, agentType?: string): boolean;
33
44
  /** Single decision point for PreToolUse hooks and canUseTool. Names and decisions only are logged. */
34
45
  export declare function decideTool(policy: ToolPolicy, name: string, input: Record<string, unknown>, agentId?: string, agentType?: string): Promise<{
35
46
  allow: boolean;
@@ -43,6 +54,12 @@ export interface ClaudeHarnessOptions {
43
54
  protectedPaths: string[];
44
55
  log?: (event: Record<string, unknown>) => void;
45
56
  env?: NodeJS.ProcessEnv;
57
+ /** Resolves registration ids to apps for contract scope checks. */
58
+ resolveApp?: (registrationId: string) => Promise<AppRef | undefined>;
59
+ /** Owner-only directory for per-run delegation trace records (JSONL). */
60
+ traceDir?: string;
61
+ /** How long a payload approval may wait before it is denied. */
62
+ approvalTimeoutMs?: number;
46
63
  }
47
64
  /** Claude Agent SDK adapter. Claude's persisted session transcript is the source of truth;
48
65
  * every turn is a fresh query() that resumes the same session id. */
@@ -1,4 +1,5 @@
1
- import { randomUUID } from 'node:crypto';
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { appendFile, mkdir } from 'node:fs/promises';
2
3
  import { createRequire } from 'node:module';
3
4
  import { realpath } from 'node:fs/promises';
4
5
  import { dirname, isAbsolute, join, relative, resolve, basename } from 'node:path';
@@ -42,12 +43,44 @@ async function contained(root, input, deny) {
42
43
  const inside = (base) => { const rel = relative(base, path); return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel)); };
43
44
  return inside(await realpath(root)) && !deny.some(inside);
44
45
  }
46
+ const norm = (op) => op.replaceAll('.', '_');
47
+ const contractFor = (profile, agentType) => agentType !== undefined && Object.hasOwn(profile.contracts, agentType) ? profile.contracts[agentType] : undefined;
48
+ /** Contract experts that may reach the owner's Teal Brick connector (still bounded per call by decideTool). */
49
+ export const contractsWithAppAccess = (profile) => Object.values(profile.contracts).filter(c => c.capabilities.apps.length || c.capabilities.marketplace.length).map(c => c.id);
50
+ /** Payload-bound approval: owner-wide approval operations, plus a contract's own list and any outward write by a contract expert. */
51
+ export function needsApproval(profile, name, input, agentType) {
52
+ if (name !== 'mcp__tealbrick__tealbrick_call' || typeof input.operation !== 'string')
53
+ return false;
54
+ const op = norm(input.operation), contract = contractFor(profile, agentType);
55
+ if (profile.approvalOperations.some(o => norm(o) === op))
56
+ return true;
57
+ if (contract && ((op === 'marketplace_execute' && contract.approval.outwardWrites === 'always') || contract.approval.operations.some(o => norm(o) === op)))
58
+ return true;
59
+ return false;
60
+ }
45
61
  /** Single decision point for PreToolUse hooks and canUseTool. Names and decisions only are logged. */
46
62
  export async function decideTool(policy, name, input, agentId, agentType) {
47
63
  const { access, profile } = policy;
48
64
  if (name.startsWith('mcp__tealbrick__')) {
49
- if (agentId !== undefined)
50
- return { allow: false, reason: 'Teal Brick authority is not delegated to child agents' };
65
+ if (agentId !== undefined) {
66
+ // A child reaches the owner's connector only under a contract, and only within contract ceiling ∩ owner grants.
67
+ const contract = contractFor(profile, agentType);
68
+ if (!contract || !(contract.capabilities.apps.length || contract.capabilities.marketplace.length))
69
+ return { allow: false, reason: 'Teal Brick authority is not delegated to child agents' };
70
+ if (name === 'mcp__tealbrick__tealbrick_call') {
71
+ const ref = typeof input.registrationId === 'string' ? await policy.resolveApp?.(input.registrationId) : undefined;
72
+ if (!ref)
73
+ return { allow: false, reason: 'Unknown app registration for this expert' };
74
+ if (input.operation === 'marketplace_execute') {
75
+ if (ref.appId !== 'marketplace' || !contract.capabilities.marketplace.some(m => m.plugin === ref.plugin && m.actions.includes(String(ref.action))))
76
+ return { allow: false, reason: 'Outside the expert contract' };
77
+ }
78
+ else if (!contract.capabilities.apps.some(a => a.appId === ref.appId && a.operations.includes(String(input.operation))))
79
+ return { allow: false, reason: 'Outside the expert contract' };
80
+ }
81
+ else if (name !== 'mcp__tealbrick__tealbrick_capabilities')
82
+ return { allow: false, reason: 'Outside the expert contract' };
83
+ }
51
84
  if (name === 'mcp__tealbrick__tealbrick_call' && profile.tealbrickCalls.length) {
52
85
  const args = input.input;
53
86
  const ok = !Object.keys(input).some(k => !['registrationId', 'operation', 'input'].includes(k)) && !!args && typeof args === 'object' && !Array.isArray(args) && profile.tealbrickCalls.some(g => g.registrationId === input.registrationId && g.operation === input.operation && Object.entries(g.inputEquals).every(([k, v]) => Object.hasOwn(args, k) && args[k] === v));
@@ -64,11 +97,15 @@ export async function decideTool(policy, name, input, agentId, agentType) {
64
97
  const allowed = access.mode === 'restricted' ? [...[...readOnlyTools, ...profile.defaultTools].filter(t => access.tools.includes(t)), ...profile.readTools] : [...access.tools, ...profile.readTools];
65
98
  if (!allowed.includes(name))
66
99
  return { allow: false, reason: `Not available in ${access.mode} session access` };
67
- if (agentId !== undefined && !(agentType !== undefined && Object.hasOwn(profile.experts, agentType) ? [profile.experts[agentType]] : Object.values(profile.experts)).some(e => e.tools.includes(name)))
68
- return { allow: false, reason: 'Not available to expert agents' };
100
+ if (agentId !== undefined) {
101
+ const contract = contractFor(profile, agentType);
102
+ const tools = contract ? contract.capabilities.builtinTools : (agentType !== undefined && Object.hasOwn(profile.experts, agentType) ? [profile.experts[agentType]] : Object.values(profile.experts)).flatMap(e => e.tools);
103
+ if (!tools.includes(name))
104
+ return { allow: false, reason: 'Not available to expert agents' };
105
+ }
69
106
  if (name === 'Agent') {
70
107
  const type = String(input.subagent_type ?? '');
71
- if (!Object.hasOwn(profile.experts, type) || input.run_in_background || input.isolation || input.resume || (input.model !== undefined && input.model !== 'inherit'))
108
+ 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'))
72
109
  return { allow: false, reason: 'Only configured experts may be delegated, in the foreground' };
73
110
  }
74
111
  const fields = pathTools[name];
@@ -104,26 +141,85 @@ export function claudeHarness(options) {
104
141
  const emit = (event) => { for (const listener of listeners)
105
142
  listener(event); };
106
143
  const restricted = () => ({ mode: 'restricted', revision: 0, tools: [...readOnlyTools, ...profile.defaultTools] });
107
- const experts = Object.keys(profile.experts);
108
- const agents = Object.fromEntries(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 }]));
144
+ const experts = [...Object.keys(profile.experts), ...Object.keys(profile.contracts)];
145
+ 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 }]),
148
+ ...Object.values(profile.contracts).map(c => [c.id, { description: `${c.purpose} (contract ${c.id}@${c.version})`,
149
+ 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'] : [])],
151
+ model: c.model, maxTurns: c.budget.maxTurns, ...(c.effort ? { effort: c.effort } : {}), omitClaudeMd: true }]),
152
+ ]);
153
+ // Payload approvals waiting on the owner (bridge input.requested → respond).
154
+ const approvals = new Map();
155
+ const approvalTimeout = options.approvalTimeoutMs ?? 15 * 60_000;
156
+ const requestApproval = (sessionId, prompt) => new Promise(resolve => {
157
+ const requestId = randomUUID(), timer = setTimeout(() => { approvals.delete(requestId); resolve(false); }, approvalTimeout);
158
+ timer.unref?.();
159
+ approvals.set(requestId, { resolve, timer });
160
+ emit({ type: 'input.requested', sessionId, requestId, prompt });
161
+ });
162
+ const trace = async (record) => {
163
+ if (!options.traceDir)
164
+ return;
165
+ try {
166
+ await mkdir(options.traceDir, { recursive: true, mode: 0o700 });
167
+ await appendFile(join(options.traceDir, new Date().toISOString().slice(0, 10) + '.jsonl'), JSON.stringify({ ts: new Date().toISOString(), owner: options.name, ...record }) + '\n', { mode: 0o600 });
168
+ }
169
+ catch {
170
+ log({ event: 'claude.trace.failed' });
171
+ }
172
+ };
173
+ const digest = (v) => createHash('sha256').update(typeof v === 'string' ? v : JSON.stringify(v ?? null)).digest('hex').slice(0, 16);
174
+ const clip = (v, n) => { const t = typeof v === 'string' ? v : JSON.stringify(v ?? null); return t.length > n ? t.slice(0, n) + '…' : t; };
175
+ const expertRef = (type) => { const c = type !== undefined && Object.hasOwn(profile.contracts, type) ? profile.contracts[type] : undefined; return c ? `${c.id}@${c.version}` : type; };
109
176
  const childEnv = () => {
110
177
  const out = { CLAUDE_AGENT_SDK_CLIENT_APP: 'tealbrick-kit/native-serve' };
178
+ // Non-secret: lets the generated child guard admit contract experts; decideTool still bounds every call.
179
+ if (appContracts.length)
180
+ out.TB_CHILD_CONTRACTS = appContracts.join(',');
111
181
  for (const name of [...safeEnvNames, ...profile.envPassthrough])
112
182
  if (env[name] && !name.startsWith('TEALBRICK_'))
113
183
  out[name] = env[name];
114
184
  return out;
115
185
  };
116
186
  const optionsFor = (sessionId, access, abort) => {
117
- const policy = { access, profile, protectedPaths: options.protectedPaths };
187
+ const policy = { access, profile, protectedPaths: options.protectedPaths, ...(options.resolveApp ? { resolveApp: options.resolveApp } : {}) };
118
188
  const builtins = access.tools.filter(t => !t.startsWith('mcp__'));
119
189
  const servers = access.mode === 'restricted' ? Object.fromEntries(Object.entries(profile.mcpServers).filter(([name]) => profile.readTools.some(t => t.startsWith(`mcp__${name}__`)))) : profile.mcpServers;
120
190
  const hook = async (input) => {
121
191
  if (input?.hook_event_name !== 'PreToolUse')
122
192
  return {};
123
- const decision = await decideTool(policy, String(input.tool_name), input.tool_input ?? {}, input.agent_id, input.agent_type);
124
- log({ event: 'claude.tool', sessionId, tool: String(input.tool_name).slice(0, 120), allowed: decision.allow, child: input.agent_id !== undefined });
193
+ const name = String(input.tool_name), args = input.tool_input ?? {}, child = input.agent_id !== undefined;
194
+ let decision = await decideTool(policy, name, args, input.agent_id, input.agent_type);
195
+ let approval;
196
+ 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' };
202
+ }
203
+ log({ event: 'claude.tool', sessionId, tool: name.slice(0, 120), allowed: decision.allow, child, ...(approval ? { approval } : {}) });
204
+ if (child)
205
+ void trace({ event: 'expert.tool', sessionId, agentId: input.agent_id, expert: expertRef(input.agent_type), tool: name.slice(0, 120),
206
+ ...(name === 'mcp__tealbrick__tealbrick_call' ? { registrationId: String(args.registrationId ?? ''), operation: String(args.operation ?? '') } : {}), allowed: decision.allow, ...(approval ? { approval } : {}) });
207
+ else if ((name === 'Agent' || name === 'Task') && decision.allow)
208
+ 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)
210
+ void trace({ event: 'owner.approval', sessionId, operation: String(args.operation ?? ''), registrationId: String(args.registrationId ?? ''), approval });
125
211
  return { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: decision.allow ? 'allow' : 'deny', permissionDecisionReason: decision.reason } };
126
212
  };
213
+ const lifecycle = async (input) => {
214
+ const e = input?.hook_event_name;
215
+ if (e === 'SubagentStart')
216
+ void trace({ event: 'expert.start', sessionId, agentId: input.agent_id, expert: expertRef(input.agent_type) });
217
+ else if (e === 'SubagentStop')
218
+ void trace({ event: 'expert.stop', sessionId, agentId: input.agent_id, expert: expertRef(input.agent_type), output: clip(input.last_assistant_message, 4000), outputSha: digest(input.last_assistant_message) });
219
+ else if (e === 'PostToolUse' && (input.tool_name === 'Agent' || input.tool_name === 'Task') && input.agent_id === undefined)
220
+ void trace({ event: 'delegation.end', sessionId, toolUseId: input.tool_use_id, outputSha: digest(input.tool_response), outputChars: clip(input.tool_response, 1e9).length });
221
+ return {};
222
+ };
127
223
  const append = [profile.instructions?.trim(), `You are ${options.name}, chatting with your owner through TBD. ${accessNote(access, experts)}`].filter(Boolean).join('\n\n');
128
224
  const delegates = experts.length > 0 && (access.mode === 'full' || builtins.includes('Agent'));
129
225
  return {
@@ -140,7 +236,7 @@ export function claudeHarness(options) {
140
236
  ...(access.mode === 'full' ? { allowDangerouslySkipPermissions: true } : {
141
237
  canUseTool: async (name, input) => { const d = await decideTool(policy, name, input); return d.allow ? { behavior: 'allow', updatedInput: input } : { behavior: 'deny', message: d.reason }; },
142
238
  }),
143
- hooks: { PreToolUse: [{ hooks: [hook] }] },
239
+ hooks: { PreToolUse: [{ hooks: [hook] }], SubagentStart: [{ hooks: [lifecycle] }], SubagentStop: [{ hooks: [lifecycle] }], PostToolUse: [{ hooks: [lifecycle] }] },
144
240
  systemPrompt: { type: 'preset', preset: 'claude_code', append },
145
241
  persistSession: true, includePartialMessages: true,
146
242
  ...(started.has(sessionId) ? { resume: sessionId } : { sessionId }),
@@ -242,10 +338,10 @@ export function claudeHarness(options) {
242
338
  log({ event: 'claude.turn.failed', sessionId, code: failure.code });
243
339
  emit({ type: 'turn.ended', sessionId, status: 'failed', code: failure.code, message: failure.message, turnId: turn.turnId });
244
340
  }
245
- const capabilities = ['chat', 'resume', 'cancel', 'mcp', 'session-access'];
341
+ const capabilities = ['chat', 'resume', 'cancel', 'mcp', 'session-access', 'approvals', ...(experts.length ? ['subagents'] : [])];
246
342
  return {
247
343
  info: { harness: 'claude', protocol: 'tealbrick-native', transport: 'claude-agent-sdk', workflowId: 'claude-agent-sdk', ...(profile.model ? { model: profile.model } : {}), capabilities,
248
- unsupported: ['eve-info', 'eve-workflows', 'eve-schedules', 'file-attachments', 'approvals', 'subagents', 'session-reset', 'session-clear', 'session-compact', 'client-context', 'output-schema'] },
344
+ unsupported: ['eve-info', 'eve-workflows', 'eve-schedules', 'file-attachments', 'session-reset', 'session-clear', 'session-compact', 'client-context', 'output-schema'] },
249
345
  restricted,
250
346
  subscribe: listener => { listeners.add(listener); },
251
347
  async create() { return randomUUID(); },
@@ -279,7 +375,12 @@ export function claudeHarness(options) {
279
375
  return { mode, revision, tools: [...profile.nativeTools] };
280
376
  },
281
377
  },
282
- async close() { for (const turn of turns.values()) {
378
+ respond(requestId, optionId) { const p = approvals.get(requestId); if (!p)
379
+ return; approvals.delete(requestId); clearTimeout(p.timer); p.resolve(optionId === 'accept'); },
380
+ async close() { for (const p of approvals.values()) {
381
+ clearTimeout(p.timer);
382
+ p.resolve(false);
383
+ } approvals.clear(); for (const turn of turns.values()) {
283
384
  turn.cancelled = true;
284
385
  turn.abort?.abort();
285
386
  } },
@@ -20,6 +20,63 @@ declare const effort: z.ZodEnum<{
20
20
  }>;
21
21
  /** Tools an agent may add to its default (Restricted) session access: research and expert delegation, never shell or writes. */
22
22
  export declare const defaultAccessTools: readonly ["WebSearch", "WebFetch", "Agent", "TodoWrite"];
23
+ export declare const expertContractSchema: z.ZodObject<{
24
+ schema: z.ZodLiteral<"tealbrick.expert-contract/v1">;
25
+ id: z.ZodString;
26
+ version: z.ZodString;
27
+ purpose: z.ZodString;
28
+ instructions: z.ZodString;
29
+ input: z.ZodObject<{
30
+ type: z.ZodLiteral<"object">;
31
+ }, z.core.$loose>;
32
+ output: z.ZodObject<{
33
+ type: z.ZodLiteral<"object">;
34
+ }, z.core.$loose>;
35
+ capabilities: z.ZodObject<{
36
+ builtinTools: z.ZodArray<z.ZodEnum<{
37
+ WebSearch: "WebSearch";
38
+ WebFetch: "WebFetch";
39
+ Read: "Read";
40
+ Glob: "Glob";
41
+ Grep: "Grep";
42
+ }>>;
43
+ apps: z.ZodArray<z.ZodObject<{
44
+ appId: z.ZodString;
45
+ operations: z.ZodArray<z.ZodString>;
46
+ }, z.core.$strict>>;
47
+ marketplace: z.ZodArray<z.ZodObject<{
48
+ plugin: z.ZodString;
49
+ actions: z.ZodArray<z.ZodString>;
50
+ }, z.core.$strict>>;
51
+ }, z.core.$strict>;
52
+ approval: z.ZodObject<{
53
+ outwardWrites: z.ZodEnum<{
54
+ always: "always";
55
+ "never-allowed": "never-allowed";
56
+ }>;
57
+ operations: z.ZodArray<z.ZodString>;
58
+ }, z.core.$strict>;
59
+ model: z.ZodEnum<{
60
+ haiku: "haiku";
61
+ inherit: "inherit";
62
+ opus: "opus";
63
+ sonnet: "sonnet";
64
+ }>;
65
+ effort: z.ZodOptional<z.ZodEnum<{
66
+ low: "low";
67
+ high: "high";
68
+ medium: "medium";
69
+ }>>;
70
+ budget: z.ZodObject<{
71
+ maxTurns: z.ZodNumber;
72
+ }, z.core.$strict>;
73
+ runtime: z.ZodEnum<{
74
+ subagent: "subagent";
75
+ "desktop-operator": "desktop-operator";
76
+ }>;
77
+ changelog: z.ZodOptional<z.ZodArray<z.ZodString>>;
78
+ }, z.core.$strict>;
79
+ export type ExpertContract = z.infer<typeof expertContractSchema>;
23
80
  export declare const serveConfigSchema: z.ZodObject<{
24
81
  version: z.ZodLiteral<1>;
25
82
  port: z.ZodNumber;
@@ -75,6 +132,8 @@ export declare const serveConfigSchema: z.ZodObject<{
75
132
  max: "max";
76
133
  }>>;
77
134
  }, z.core.$strict>>>;
135
+ contracts: z.ZodOptional<z.ZodArray<z.ZodString>>;
136
+ approvalOperations: z.ZodOptional<z.ZodArray<z.ZodString>>;
78
137
  nativeTools: z.ZodOptional<z.ZodArray<z.ZodUnion<readonly [z.ZodString, z.ZodString]>>>;
79
138
  readTools: z.ZodOptional<z.ZodArray<z.ZodString>>;
80
139
  mcpServers: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
@@ -108,6 +167,8 @@ export interface ClaudeProfile {
108
167
  timeoutSeconds: number;
109
168
  defaultTools: string[];
110
169
  experts: Record<string, ClaudeExpert>;
170
+ contracts: Record<string, ExpertContract>;
171
+ approvalOperations: string[];
111
172
  nativeTools: string[];
112
173
  readTools: string[];
113
174
  mcpServers: Record<string, z.infer<typeof stdio>>;
@@ -16,6 +16,28 @@ 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
+ /** Versioned expert contract (tealbrick.expert-contract/v1). The contract is a ceiling; Portal grants still apply. */
20
+ const opName = z.string().regex(/^[a-z][a-z0-9_.]{1,120}$/);
21
+ export const expertContractSchema = z.object({
22
+ schema: z.literal('tealbrick.expert-contract/v1'),
23
+ id: z.string().regex(/^[a-z][a-z0-9-]{1,62}$/),
24
+ version: z.string().regex(/^\d+\.\d+\.\d+$/),
25
+ purpose: z.string().min(10).max(300),
26
+ instructions: z.string().min(40).max(8000),
27
+ input: z.object({ type: z.literal('object') }).passthrough(),
28
+ output: z.object({ type: z.literal('object') }).passthrough(),
29
+ capabilities: z.object({
30
+ builtinTools: z.array(expertTool).max(8),
31
+ apps: z.array(z.object({ appId: z.string().regex(/^[a-z][a-z0-9-]{1,40}$/), operations: z.array(z.string().regex(/^[a-z][a-z0-9_]{1,80}$/)).min(1) }).strict()).max(12),
32
+ marketplace: z.array(z.object({ plugin: z.string().regex(/^[a-z0-9][a-z0-9-]{1,80}$/), actions: z.array(z.string().regex(/^[a-z0-9][a-z0-9._-]{1,120}$/)).min(1) }).strict()).max(12),
33
+ }).strict(),
34
+ approval: z.object({ outwardWrites: z.enum(['always', 'never-allowed']), operations: z.array(opName) }).strict(),
35
+ model: z.enum(['inherit', 'opus', 'sonnet', 'haiku']),
36
+ effort: z.enum(['low', 'medium', 'high']).optional(),
37
+ budget: z.object({ maxTurns: z.number().int().min(1).max(50) }).strict(),
38
+ runtime: z.enum(['subagent', 'desktop-operator']),
39
+ changelog: z.array(z.string().max(300)).optional(),
40
+ }).strict();
19
41
  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();
20
42
  const claude = z.object({
21
43
  /** Package directory or entry file of @anthropic-ai/claude-agent-sdk. Default: resolved from the kit root. */
@@ -34,6 +56,10 @@ const claude = z.object({
34
56
  defaultTools: z.array(z.enum(defaultAccessTools)).max(4).optional(),
35
57
  /** Expert subagents the Agent tool may delegate to, foreground only, inheriting the session model. */
36
58
  experts: z.record(expertName, expert).optional(),
59
+ /** Versioned expert contracts (JSON files) run as SDK subagents. Effective scope = contract ceiling ∩ owner grants. */
60
+ contracts: z.array(absolute).max(32).optional(),
61
+ /** Operations that always pause for the owner's approval of the exact payload, whoever calls them. */
62
+ approvalOperations: z.array(opName).max(64).optional(),
37
63
  /** Tool allowlist for the opt-in "Native defaults" session access mode. */
38
64
  nativeTools: z.array(tool).max(64).optional(),
39
65
  /** Operator-vetted read-only MCP tools, also available in Restricted mode. */
@@ -173,6 +199,19 @@ export async function resolveClaudeProfile(root, config) {
173
199
  if (instructions && instructions.length > 200000)
174
200
  throw Error('kit_native_serve_instructions_too_large');
175
201
  const readTools = c.readTools ?? agent?.readTools ?? [];
202
+ const contracts = {};
203
+ for (const path of c.contracts ?? []) {
204
+ await regularFile(path, { ownerOnly: false });
205
+ const text = await readFile(path, 'utf8');
206
+ if (text.length > 65536)
207
+ throw Error('kit_native_serve_contract_too_large');
208
+ const contract = parsed(expertContractSchema, text, 'kit_native_serve_contract_invalid');
209
+ if (contract.runtime !== 'subagent')
210
+ throw Error('kit_native_serve_contract_runtime_unsupported:' + contract.id);
211
+ if (Object.hasOwn(contracts, contract.id))
212
+ throw Error('kit_native_serve_contract_duplicate:' + contract.id);
213
+ contracts[contract.id] = contract;
214
+ }
176
215
  const experts = {};
177
216
  for (const [name, e] of Object.entries(c.experts ?? {})) {
178
217
  await regularFile(e.promptFile, { ownerOnly: false });
@@ -181,9 +220,12 @@ export async function resolveClaudeProfile(root, config) {
181
220
  throw Error('kit_native_serve_expert_prompt_invalid:' + name);
182
221
  experts[name] = { description: e.description ?? name.replaceAll('-', ' ') + ' for a bounded assignment', prompt, tools: [...new Set(e.tools ?? [])], maxTurns: e.maxTurns ?? 8, ...(e.effort ? { effort: e.effort } : {}) };
183
222
  }
223
+ for (const id of Object.keys(contracts))
224
+ if (Object.hasOwn(experts, id))
225
+ throw Error('kit_native_serve_contract_duplicate:' + id);
184
226
  return { cwd, sdkModule: c.sdkModule, instructions, model: c.model ?? recipe?.model, effort: c.effort ?? recipe?.effort,
185
227
  maxTurns: c.maxTurns ?? recipe?.maxTurns ?? 24, maxBudgetUsd: c.maxBudgetUsd ?? recipe?.maxBudgetUsd, timeoutSeconds: c.timeoutSeconds ?? recipe?.timeoutSeconds ?? 900,
186
- defaultTools: [...new Set(c.defaultTools ?? [])], experts,
228
+ defaultTools: [...new Set(c.defaultTools ?? [])], experts, contracts, approvalOperations: [...new Set(c.approvalOperations ?? ['marketplace_execute'])],
187
229
  nativeTools: c.nativeTools ?? ['Read', 'Glob', 'Grep', 'TodoWrite'], readTools: readTools.filter(t => !t.startsWith('mcp__tealbrick__')),
188
230
  mcpServers: c.mcpServers ?? agent?.mcpServers ?? {}, tealbrickCalls: c.tealbrickCalls ?? agent?.tealbrickCalls ?? [],
189
231
  requireSubscription: c.requireSubscription ?? false, envPassthrough: c.envPassthrough ?? [] };
@@ -13,6 +13,7 @@ export interface ServeDependencies {
13
13
  runtimeSync?: RuntimeSyncOptions | false;
14
14
  /** Test seams for the owner-only /tealbrick/v1/capabilities snapshot. */
15
15
  appCapabilities?: AppCapabilitiesOptions;
16
+ approvalTimeoutMs?: number;
16
17
  }
17
18
  /** Build (but do not bind) the owner-authenticated chat endpoint for this kit root. */
18
19
  export declare function createNativeServe(root: string, deps?: ServeDependencies): Promise<{
@@ -35,9 +35,20 @@ export async function createNativeServe(root, deps = {}) {
35
35
  await mkdir(paths.state, { recursive: true, mode: 0o700 });
36
36
  let adapter;
37
37
  let codex;
38
+ let runtime;
39
+ const appCapabilities = appCapabilitiesProvider(root, () => runtime, deps.appCapabilities);
38
40
  if (kit.native.harness === 'claude') {
39
41
  const profile = await resolveClaudeProfile(root, config);
40
- adapter = claudeHarness({ name: config.agent.name, profile, attachment: await readClaudeAttachment(root), sdk: deps.claudeSdk ?? await loadClaudeSdk(root, profile.sdkModule), protectedPaths: [join(root, '.tealbrick')], log, env: deps.env });
42
+ // Contract scope checks resolve registration ids from this runtime's own Portal-granted snapshot.
43
+ const resolveApp = async (registrationId) => {
44
+ const snap = await appCapabilities();
45
+ const app = snap.apps.find(a => a.registrationId === registrationId);
46
+ if (app)
47
+ return { appId: app.appId };
48
+ const grant = snap.marketplace.grants.find(g => g.registrationId === registrationId);
49
+ return grant ? { appId: 'marketplace', plugin: grant.plugin, action: grant.action } : undefined;
50
+ };
51
+ adapter = claudeHarness({ name: config.agent.name, profile, attachment: await readClaudeAttachment(root), sdk: deps.claudeSdk ?? await loadClaudeSdk(root, profile.sdkModule), protectedPaths: [join(root, '.tealbrick')], log, env: deps.env, resolveApp, traceDir: join(paths.state, 'traces'), ...(deps.approvalTimeoutMs ? { approvalTimeoutMs: deps.approvalTimeoutMs } : {}) });
41
52
  }
42
53
  else if (kit.native.harness === 'codex') {
43
54
  if (!config.codex)
@@ -50,8 +61,6 @@ export async function createNativeServe(root, deps = {}) {
50
61
  else
51
62
  adapter = langchainHarness();
52
63
  const authenticate = deps.authenticate ?? createPortalVerifier({ issuer: kit.runtime.issuer, org: kit.runtime.org, agent: config.agent.name }).authenticate;
53
- let runtime;
54
- const appCapabilities = appCapabilitiesProvider(root, () => runtime, deps.appCapabilities);
55
64
  const bridge = await createNativeBridge({ name: config.agent.name, ownerId: config.agent.ownerId, root: paths.state, port: config.port, allowedHosts: publicHosts(config), authenticate, adapter, maxActiveTurns: config.maxActiveTurns ?? 2, log, appCapabilities });
56
65
  // Portal marks a runtime expired unless it re-acknowledges its signed config within the 300s lease;
57
66
  // an idle serve process has no per-turn MCP child doing that, so it keeps the lease itself.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tealbrick/kit",
3
- "version": "0.3.0-rc.6",
3
+ "version": "0.3.0-rc.7",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "Complete Teal Brick package kit with explicit Eve capability activation",
@@ -43,11 +43,11 @@
43
43
  "test": "node --test test/*.test.mjs"
44
44
  },
45
45
  "dependencies": {
46
- "@tealbrick/portal": "0.3.0-rc.6",
47
- "@tealbrick/avm": "0.3.0-rc.6",
48
- "@tealbrick/voice": "0.3.0-rc.6",
49
- "@tealbrick/vision": "0.3.0-rc.6",
50
- "@tealbrick/deliver": "0.3.0-rc.6",
46
+ "@tealbrick/portal": "0.3.0-rc.7",
47
+ "@tealbrick/avm": "0.3.0-rc.7",
48
+ "@tealbrick/voice": "0.3.0-rc.7",
49
+ "@tealbrick/vision": "0.3.0-rc.7",
50
+ "@tealbrick/deliver": "0.3.0-rc.7",
51
51
  "@inquirer/prompts": "8.7.2",
52
52
  "zod": "^4.0.0"
53
53
  },