@tangle-network/agent-interface 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -4,12 +4,19 @@
4
4
  * Shared types and interfaces for SDK provider adapters.
5
5
  * This package defines the contract between the sidecar and provider implementations.
6
6
  */
7
+ import type { InteractionRequest, InteractionResponse } from "./interaction.js";
7
8
  export type BackendCapabilities = {
8
9
  streaming: boolean;
9
10
  toolUse: boolean;
10
11
  reasoning: boolean;
11
12
  multimodal: boolean;
12
13
  contextWindow: number;
14
+ /**
15
+ * Interaction kinds this provider can originate (e.g. `["question",
16
+ * "permission"]`). Empty/undefined means the provider never asks the user.
17
+ * Consumers use this to decide what human-in-the-loop UI to offer.
18
+ */
19
+ interactions?: string[];
13
20
  };
14
21
  /**
15
22
  * High-signal feature flags surfaced by each provider package. Routes
@@ -198,7 +205,22 @@ export type StreamEvent = MessagePartUpdatedEvent | {
198
205
  created?: number;
199
206
  updated?: number;
200
207
  };
201
- } | {
208
+ }
209
+ /** Agent asks the user; answered via `respondToInteraction`. The generalized
210
+ * human-in-the-loop event (question, permission, plan, …). */
211
+ | {
212
+ type: "interaction";
213
+ request: InteractionRequest;
214
+ }
215
+ /** Agent withdraws an outstanding interaction (no longer needs the answer). */
216
+ | {
217
+ type: "interaction.cancel";
218
+ id: string;
219
+ reason?: string;
220
+ }
221
+ /** @deprecated Use the `interaction` event with `kind: "question"`. Retained
222
+ * so existing emitters/consumers keep working during migration. */
223
+ | {
202
224
  type: "question";
203
225
  questionId: string;
204
226
  questions: Array<{
@@ -214,6 +236,13 @@ export type ToolInvocation = {
214
236
  toolName: string;
215
237
  input: unknown;
216
238
  result?: unknown;
239
+ /**
240
+ * True when the tool call failed (errored, timed out, or was rejected).
241
+ * Failed tools are recorded — not dropped — so the run outcome can reflect
242
+ * them. Consumers deriving success must treat any `isError: true` invocation
243
+ * as a failure signal.
244
+ */
245
+ isError?: boolean;
217
246
  };
218
247
  export type TokenUsage = {
219
248
  inputTokens: number;
@@ -576,9 +605,22 @@ export interface SdkProviderAdapter {
576
605
  listAgentMessages?(agentId: string, options?: BackendListOptions): Promise<unknown>;
577
606
  listArtifacts?(sessionId: string): Promise<BackendArtifact[]>;
578
607
  downloadArtifact?(sessionId: string, path: string): Promise<Uint8Array>;
608
+ /**
609
+ * Respond to an outstanding interaction (question, permission, …). The
610
+ * generalized inbound channel; the adapter translates the response into the
611
+ * provider's native control call to unblock the agent.
612
+ */
613
+ respondToInteraction?(response: InteractionResponse): Promise<void>;
614
+ /**
615
+ * @deprecated Use `respondToInteraction`. Retained for back-compat; an
616
+ * adapter implementing only this still answers `kind: "question"` asks.
617
+ */
579
618
  submitQuestionAnswer?(answers: Record<string, string[]>): Promise<void>;
580
619
  }
620
+ export * from "./interaction.js";
581
621
  export * from "./agent-profile.js";
582
622
  export * from "./harness.js";
583
623
  export * from "./harness-capabilities.js";
584
624
  export * from "./profile-schema.js";
625
+ export * from "./profile-security.js";
626
+ export * from "./sandbox-size.js";
package/dist/index.js CHANGED
@@ -122,7 +122,10 @@ export function resolveNativeWebTools(tools) {
122
122
  }
123
123
  return posture;
124
124
  }
125
+ export * from "./interaction.js";
125
126
  export * from "./agent-profile.js";
126
127
  export * from "./harness.js";
127
128
  export * from "./harness-capabilities.js";
128
129
  export * from "./profile-schema.js";
130
+ export * from "./profile-security.js";
131
+ export * from "./sandbox-size.js";
@@ -0,0 +1,281 @@
1
+ /**
2
+ * Interaction contract — the generalized human-in-the-loop primitive.
3
+ *
4
+ * An agent emits an `InteractionRequest`: a typed ask that carries a
5
+ * self-describing `answerSpec` (the fields and types of a valid answer). A
6
+ * human (or an automated policy) returns an `InteractionResponse` keyed by the
7
+ * same `id`. This subsumes the original question/answer pair and extends it to
8
+ * permissions, plans, and provider-specific asks.
9
+ *
10
+ * Design contract:
11
+ * - The envelope is stable; `kind` is an OPEN label, so new ask types need no
12
+ * change to this contract. Well-known kinds (see `InteractionKind`) get
13
+ * richer rendering and platform handling; unknown kinds render generically
14
+ * from `answerSpec` and still work end-to-end.
15
+ * - `answerSpec` is a small closed set of flat field types, so any consumer can
16
+ * render a form and validate a response without a general schema engine. This
17
+ * mirrors MCP elicitation so MCP-originated asks map onto this 1:1.
18
+ * - `default` + `timeoutMs`/`onTimeout` make unattended resolution explicit and
19
+ * auditable, replacing blanket permission-bypass flags.
20
+ */
21
+ import { z } from "zod";
22
+ export declare const InteractionFieldSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
23
+ type: z.ZodLiteral<"text">;
24
+ multiline: z.ZodOptional<z.ZodBoolean>;
25
+ placeholder: z.ZodOptional<z.ZodString>;
26
+ default: z.ZodOptional<z.ZodString>;
27
+ name: z.ZodString;
28
+ label: z.ZodString;
29
+ required: z.ZodOptional<z.ZodBoolean>;
30
+ }, z.core.$strip>, z.ZodObject<{
31
+ type: z.ZodLiteral<"number">;
32
+ min: z.ZodOptional<z.ZodNumber>;
33
+ max: z.ZodOptional<z.ZodNumber>;
34
+ default: z.ZodOptional<z.ZodNumber>;
35
+ name: z.ZodString;
36
+ label: z.ZodString;
37
+ required: z.ZodOptional<z.ZodBoolean>;
38
+ }, z.core.$strip>, z.ZodObject<{
39
+ type: z.ZodLiteral<"boolean">;
40
+ default: z.ZodOptional<z.ZodBoolean>;
41
+ name: z.ZodString;
42
+ label: z.ZodString;
43
+ required: z.ZodOptional<z.ZodBoolean>;
44
+ }, z.core.$strip>, z.ZodObject<{
45
+ type: z.ZodLiteral<"select">;
46
+ options: z.ZodArray<z.ZodObject<{
47
+ value: z.ZodString;
48
+ label: z.ZodString;
49
+ description: z.ZodOptional<z.ZodString>;
50
+ }, z.core.$strip>>;
51
+ multi: z.ZodOptional<z.ZodBoolean>;
52
+ default: z.ZodOptional<z.ZodArray<z.ZodString>>;
53
+ name: z.ZodString;
54
+ label: z.ZodString;
55
+ required: z.ZodOptional<z.ZodBoolean>;
56
+ }, z.core.$strip>, z.ZodObject<{
57
+ type: z.ZodLiteral<"secret">;
58
+ placeholder: z.ZodOptional<z.ZodString>;
59
+ name: z.ZodString;
60
+ label: z.ZodString;
61
+ required: z.ZodOptional<z.ZodBoolean>;
62
+ }, z.core.$strip>], "type">;
63
+ export type InteractionField = z.infer<typeof InteractionFieldSchema>;
64
+ export declare const InteractionAnswerSpecSchema: z.ZodObject<{
65
+ fields: z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
66
+ type: z.ZodLiteral<"text">;
67
+ multiline: z.ZodOptional<z.ZodBoolean>;
68
+ placeholder: z.ZodOptional<z.ZodString>;
69
+ default: z.ZodOptional<z.ZodString>;
70
+ name: z.ZodString;
71
+ label: z.ZodString;
72
+ required: z.ZodOptional<z.ZodBoolean>;
73
+ }, z.core.$strip>, z.ZodObject<{
74
+ type: z.ZodLiteral<"number">;
75
+ min: z.ZodOptional<z.ZodNumber>;
76
+ max: z.ZodOptional<z.ZodNumber>;
77
+ default: z.ZodOptional<z.ZodNumber>;
78
+ name: z.ZodString;
79
+ label: z.ZodString;
80
+ required: z.ZodOptional<z.ZodBoolean>;
81
+ }, z.core.$strip>, z.ZodObject<{
82
+ type: z.ZodLiteral<"boolean">;
83
+ default: z.ZodOptional<z.ZodBoolean>;
84
+ name: z.ZodString;
85
+ label: z.ZodString;
86
+ required: z.ZodOptional<z.ZodBoolean>;
87
+ }, z.core.$strip>, z.ZodObject<{
88
+ type: z.ZodLiteral<"select">;
89
+ options: z.ZodArray<z.ZodObject<{
90
+ value: z.ZodString;
91
+ label: z.ZodString;
92
+ description: z.ZodOptional<z.ZodString>;
93
+ }, z.core.$strip>>;
94
+ multi: z.ZodOptional<z.ZodBoolean>;
95
+ default: z.ZodOptional<z.ZodArray<z.ZodString>>;
96
+ name: z.ZodString;
97
+ label: z.ZodString;
98
+ required: z.ZodOptional<z.ZodBoolean>;
99
+ }, z.core.$strip>, z.ZodObject<{
100
+ type: z.ZodLiteral<"secret">;
101
+ placeholder: z.ZodOptional<z.ZodString>;
102
+ name: z.ZodString;
103
+ label: z.ZodString;
104
+ required: z.ZodOptional<z.ZodBoolean>;
105
+ }, z.core.$strip>], "type">>;
106
+ }, z.core.$strip>;
107
+ export type InteractionAnswerSpec = z.infer<typeof InteractionAnswerSpecSchema>;
108
+ export declare const InteractionSubjectSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
109
+ type: z.ZodLiteral<"tool">;
110
+ toolName: z.ZodString;
111
+ input: z.ZodOptional<z.ZodUnknown>;
112
+ }, z.core.$strip>, z.ZodObject<{
113
+ type: z.ZodLiteral<"command">;
114
+ command: z.ZodString;
115
+ }, z.core.$strip>, z.ZodObject<{
116
+ type: z.ZodLiteral<"file">;
117
+ path: z.ZodString;
118
+ preview: z.ZodOptional<z.ZodString>;
119
+ }, z.core.$strip>, z.ZodObject<{
120
+ type: z.ZodLiteral<"resource">;
121
+ uri: z.ZodString;
122
+ }, z.core.$strip>], "type">;
123
+ export type InteractionSubject = z.infer<typeof InteractionSubjectSchema>;
124
+ export declare const InteractionOutcomeSchema: z.ZodEnum<{
125
+ accepted: "accepted";
126
+ declined: "declined";
127
+ cancelled: "cancelled";
128
+ }>;
129
+ export type InteractionOutcome = z.infer<typeof InteractionOutcomeSchema>;
130
+ /** Field values keyed by `InteractionField.name`. Validated against `answerSpec`. */
131
+ export declare const InteractionDataSchema: z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean, z.ZodArray<z.ZodString>]>>;
132
+ export type InteractionData = z.infer<typeof InteractionDataSchema>;
133
+ export declare const InteractionResolutionSchema: z.ZodObject<{
134
+ outcome: z.ZodEnum<{
135
+ accepted: "accepted";
136
+ declined: "declined";
137
+ cancelled: "cancelled";
138
+ }>;
139
+ data: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean, z.ZodArray<z.ZodString>]>>>;
140
+ }, z.core.$strip>;
141
+ export type InteractionResolution = z.infer<typeof InteractionResolutionSchema>;
142
+ export declare const InteractionRequestSchema: z.ZodObject<{
143
+ id: z.ZodString;
144
+ kind: z.ZodString;
145
+ title: z.ZodString;
146
+ body: z.ZodOptional<z.ZodString>;
147
+ subject: z.ZodOptional<z.ZodDiscriminatedUnion<[z.ZodObject<{
148
+ type: z.ZodLiteral<"tool">;
149
+ toolName: z.ZodString;
150
+ input: z.ZodOptional<z.ZodUnknown>;
151
+ }, z.core.$strip>, z.ZodObject<{
152
+ type: z.ZodLiteral<"command">;
153
+ command: z.ZodString;
154
+ }, z.core.$strip>, z.ZodObject<{
155
+ type: z.ZodLiteral<"file">;
156
+ path: z.ZodString;
157
+ preview: z.ZodOptional<z.ZodString>;
158
+ }, z.core.$strip>, z.ZodObject<{
159
+ type: z.ZodLiteral<"resource">;
160
+ uri: z.ZodString;
161
+ }, z.core.$strip>], "type">>;
162
+ answerSpec: z.ZodObject<{
163
+ fields: z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
164
+ type: z.ZodLiteral<"text">;
165
+ multiline: z.ZodOptional<z.ZodBoolean>;
166
+ placeholder: z.ZodOptional<z.ZodString>;
167
+ default: z.ZodOptional<z.ZodString>;
168
+ name: z.ZodString;
169
+ label: z.ZodString;
170
+ required: z.ZodOptional<z.ZodBoolean>;
171
+ }, z.core.$strip>, z.ZodObject<{
172
+ type: z.ZodLiteral<"number">;
173
+ min: z.ZodOptional<z.ZodNumber>;
174
+ max: z.ZodOptional<z.ZodNumber>;
175
+ default: z.ZodOptional<z.ZodNumber>;
176
+ name: z.ZodString;
177
+ label: z.ZodString;
178
+ required: z.ZodOptional<z.ZodBoolean>;
179
+ }, z.core.$strip>, z.ZodObject<{
180
+ type: z.ZodLiteral<"boolean">;
181
+ default: z.ZodOptional<z.ZodBoolean>;
182
+ name: z.ZodString;
183
+ label: z.ZodString;
184
+ required: z.ZodOptional<z.ZodBoolean>;
185
+ }, z.core.$strip>, z.ZodObject<{
186
+ type: z.ZodLiteral<"select">;
187
+ options: z.ZodArray<z.ZodObject<{
188
+ value: z.ZodString;
189
+ label: z.ZodString;
190
+ description: z.ZodOptional<z.ZodString>;
191
+ }, z.core.$strip>>;
192
+ multi: z.ZodOptional<z.ZodBoolean>;
193
+ default: z.ZodOptional<z.ZodArray<z.ZodString>>;
194
+ name: z.ZodString;
195
+ label: z.ZodString;
196
+ required: z.ZodOptional<z.ZodBoolean>;
197
+ }, z.core.$strip>, z.ZodObject<{
198
+ type: z.ZodLiteral<"secret">;
199
+ placeholder: z.ZodOptional<z.ZodString>;
200
+ name: z.ZodString;
201
+ label: z.ZodString;
202
+ required: z.ZodOptional<z.ZodBoolean>;
203
+ }, z.core.$strip>], "type">>;
204
+ }, z.core.$strip>;
205
+ default: z.ZodOptional<z.ZodObject<{
206
+ outcome: z.ZodEnum<{
207
+ accepted: "accepted";
208
+ declined: "declined";
209
+ cancelled: "cancelled";
210
+ }>;
211
+ data: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean, z.ZodArray<z.ZodString>]>>>;
212
+ }, z.core.$strip>>;
213
+ timeoutMs: z.ZodOptional<z.ZodNumber>;
214
+ onTimeout: z.ZodOptional<z.ZodEnum<{
215
+ default: "default";
216
+ fail: "fail";
217
+ wait: "wait";
218
+ }>>;
219
+ }, z.core.$strip>;
220
+ export type InteractionRequest = z.infer<typeof InteractionRequestSchema>;
221
+ export declare const InteractionResponseSchema: z.ZodObject<{
222
+ outcome: z.ZodEnum<{
223
+ accepted: "accepted";
224
+ declined: "declined";
225
+ cancelled: "cancelled";
226
+ }>;
227
+ data: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean, z.ZodArray<z.ZodString>]>>>;
228
+ id: z.ZodString;
229
+ }, z.core.$strip>;
230
+ export type InteractionResponse = z.infer<typeof InteractionResponseSchema>;
231
+ export declare const InteractionKind: {
232
+ /** Agent asks the user to answer/choose. Answer = the chosen field values. */
233
+ readonly Question: "question";
234
+ /** Agent requests approval to run a tool/command. Answer = a `PermissionGrant`. */
235
+ readonly Permission: "permission";
236
+ /** Agent shares a plan/todo list for review/approval. */
237
+ readonly Plan: "plan";
238
+ };
239
+ export type WellKnownInteractionKind = (typeof InteractionKind)[keyof typeof InteractionKind];
240
+ /** Field name carrying the grant on a `permission` interaction's response. */
241
+ export declare const PERMISSION_GRANT_FIELD = "grant";
242
+ /** Optional free-text field carrying the user's reason on a `permission` response. */
243
+ export declare const PERMISSION_FEEDBACK_FIELD = "feedback";
244
+ /** Graduated permission decision — the value of the `grant` field. */
245
+ export declare const PermissionGrantSchema: z.ZodEnum<{
246
+ deny: "deny";
247
+ allow_once: "allow_once";
248
+ allow_session: "allow_session";
249
+ allow_always: "allow_always";
250
+ }>;
251
+ export type PermissionGrant = z.infer<typeof PermissionGrantSchema>;
252
+ /** Build the answer spec for a `permission` interaction (graduated grant + feedback). */
253
+ export declare function permissionAnswerSpec(opts?: {
254
+ allowFeedback?: boolean;
255
+ }): InteractionAnswerSpec;
256
+ /** Shape of one legacy question (kept for the back-compat shim). */
257
+ export type LegacyQuestion = {
258
+ question: string;
259
+ options?: Array<{
260
+ label: string;
261
+ description?: string;
262
+ }>;
263
+ multiSelect?: boolean;
264
+ };
265
+ /**
266
+ * Build an answer spec from the legacy `question` event shape. Each question
267
+ * becomes one select field (free text when it declares no options), so the old
268
+ * question/answer path is expressible as a `question` interaction.
269
+ */
270
+ export declare function questionAnswerSpec(questions: LegacyQuestion[]): InteractionAnswerSpec;
271
+ export type InteractionValidation = {
272
+ ok: true;
273
+ } | {
274
+ ok: false;
275
+ errors: string[];
276
+ };
277
+ /**
278
+ * Validate an accepted answer against its spec. Used by the broker before a
279
+ * response reaches the adapter, so malformed answers are rejected centrally.
280
+ */
281
+ export declare function validateInteractionAnswer(spec: InteractionAnswerSpec, data: InteractionData | undefined): InteractionValidation;
@@ -0,0 +1,250 @@
1
+ /**
2
+ * Interaction contract — the generalized human-in-the-loop primitive.
3
+ *
4
+ * An agent emits an `InteractionRequest`: a typed ask that carries a
5
+ * self-describing `answerSpec` (the fields and types of a valid answer). A
6
+ * human (or an automated policy) returns an `InteractionResponse` keyed by the
7
+ * same `id`. This subsumes the original question/answer pair and extends it to
8
+ * permissions, plans, and provider-specific asks.
9
+ *
10
+ * Design contract:
11
+ * - The envelope is stable; `kind` is an OPEN label, so new ask types need no
12
+ * change to this contract. Well-known kinds (see `InteractionKind`) get
13
+ * richer rendering and platform handling; unknown kinds render generically
14
+ * from `answerSpec` and still work end-to-end.
15
+ * - `answerSpec` is a small closed set of flat field types, so any consumer can
16
+ * render a form and validate a response without a general schema engine. This
17
+ * mirrors MCP elicitation so MCP-originated asks map onto this 1:1.
18
+ * - `default` + `timeoutMs`/`onTimeout` make unattended resolution explicit and
19
+ * auditable, replacing blanket permission-bypass flags.
20
+ */
21
+ import { z } from "zod";
22
+ // =============================================================================
23
+ // Answer specification — describes the shape of a valid answer.
24
+ // =============================================================================
25
+ const FieldBase = {
26
+ /** Stable key the answer is returned under in `InteractionResponse.data`. */
27
+ name: z.string().min(1),
28
+ /** Human-readable label for the form control. */
29
+ label: z.string().min(1),
30
+ /** Whether the answer must supply this field to be `accepted`. */
31
+ required: z.boolean().optional(),
32
+ };
33
+ export const InteractionFieldSchema = z.discriminatedUnion("type", [
34
+ z.object({
35
+ ...FieldBase,
36
+ type: z.literal("text"),
37
+ multiline: z.boolean().optional(),
38
+ placeholder: z.string().optional(),
39
+ default: z.string().optional(),
40
+ }),
41
+ z.object({
42
+ ...FieldBase,
43
+ type: z.literal("number"),
44
+ min: z.number().optional(),
45
+ max: z.number().optional(),
46
+ default: z.number().optional(),
47
+ }),
48
+ z.object({
49
+ ...FieldBase,
50
+ type: z.literal("boolean"),
51
+ default: z.boolean().optional(),
52
+ }),
53
+ z.object({
54
+ ...FieldBase,
55
+ type: z.literal("select"),
56
+ options: z
57
+ .array(z.object({
58
+ value: z.string(),
59
+ label: z.string(),
60
+ description: z.string().optional(),
61
+ }))
62
+ .min(1),
63
+ /** When true the user may pick more than one option. */
64
+ multi: z.boolean().optional(),
65
+ default: z.array(z.string()).optional(),
66
+ }),
67
+ /** Like `text` but the value is sensitive (token/key) and must be masked. */
68
+ z.object({
69
+ ...FieldBase,
70
+ type: z.literal("secret"),
71
+ placeholder: z.string().optional(),
72
+ }),
73
+ ]);
74
+ export const InteractionAnswerSpecSchema = z.object({
75
+ fields: z.array(InteractionFieldSchema),
76
+ });
77
+ // =============================================================================
78
+ // Subject — what the request is about (drives preview/permission UX).
79
+ // =============================================================================
80
+ export const InteractionSubjectSchema = z.discriminatedUnion("type", [
81
+ z.object({ type: z.literal("tool"), toolName: z.string(), input: z.unknown().optional() }),
82
+ z.object({ type: z.literal("command"), command: z.string() }),
83
+ z.object({ type: z.literal("file"), path: z.string(), preview: z.string().optional() }),
84
+ z.object({ type: z.literal("resource"), uri: z.string() }),
85
+ ]);
86
+ // =============================================================================
87
+ // Outcome + resolution — the answer.
88
+ // =============================================================================
89
+ export const InteractionOutcomeSchema = z.enum(["accepted", "declined", "cancelled"]);
90
+ /** Field values keyed by `InteractionField.name`. Validated against `answerSpec`. */
91
+ export const InteractionDataSchema = z.record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.array(z.string())]));
92
+ export const InteractionResolutionSchema = z.object({
93
+ outcome: InteractionOutcomeSchema,
94
+ /** Present (and validated) only when `outcome === "accepted"`. */
95
+ data: InteractionDataSchema.optional(),
96
+ });
97
+ // =============================================================================
98
+ // The request envelope.
99
+ // =============================================================================
100
+ export const InteractionRequestSchema = z.object({
101
+ /** Correlation id; unique within a session. The response carries the same id. */
102
+ id: z.string().min(1),
103
+ /**
104
+ * Open label for rendering, handling, and authorization. Well-known values:
105
+ * `question` | `permission` | `plan`. Vendor extensions SHOULD namespace,
106
+ * e.g. `x-pi.choose-extension`.
107
+ */
108
+ kind: z.string().min(1),
109
+ /** Short human-readable prompt. */
110
+ title: z.string().min(1),
111
+ /** Optional longer context (markdown). */
112
+ body: z.string().optional(),
113
+ subject: InteractionSubjectSchema.optional(),
114
+ answerSpec: InteractionAnswerSpecSchema,
115
+ /** Resolution applied when unattended or timed out — explicit, not a bypass flag. */
116
+ default: InteractionResolutionSchema.optional(),
117
+ /** Wait this long for a human before applying `onTimeout`. */
118
+ timeoutMs: z.number().int().positive().optional(),
119
+ /** On timeout: apply `default`, `fail` the turn, or keep `wait`ing. Default `wait`. */
120
+ onTimeout: z.enum(["default", "fail", "wait"]).optional(),
121
+ });
122
+ export const InteractionResponseSchema = InteractionResolutionSchema.extend({
123
+ id: z.string().min(1),
124
+ });
125
+ // =============================================================================
126
+ // Well-known kinds + helpers.
127
+ // =============================================================================
128
+ export const InteractionKind = {
129
+ /** Agent asks the user to answer/choose. Answer = the chosen field values. */
130
+ Question: "question",
131
+ /** Agent requests approval to run a tool/command. Answer = a `PermissionGrant`. */
132
+ Permission: "permission",
133
+ /** Agent shares a plan/todo list for review/approval. */
134
+ Plan: "plan",
135
+ };
136
+ /** Field name carrying the grant on a `permission` interaction's response. */
137
+ export const PERMISSION_GRANT_FIELD = "grant";
138
+ /** Optional free-text field carrying the user's reason on a `permission` response. */
139
+ export const PERMISSION_FEEDBACK_FIELD = "feedback";
140
+ /** Graduated permission decision — the value of the `grant` field. */
141
+ export const PermissionGrantSchema = z.enum([
142
+ "allow_once",
143
+ "allow_session",
144
+ "allow_always",
145
+ "deny",
146
+ ]);
147
+ /** Build the answer spec for a `permission` interaction (graduated grant + feedback). */
148
+ export function permissionAnswerSpec(opts) {
149
+ const fields = [
150
+ {
151
+ type: "select",
152
+ name: PERMISSION_GRANT_FIELD,
153
+ label: "Decision",
154
+ required: true,
155
+ options: [
156
+ { value: "allow_once", label: "Allow once" },
157
+ { value: "allow_session", label: "Allow for this session" },
158
+ { value: "allow_always", label: "Always allow" },
159
+ { value: "deny", label: "Deny" },
160
+ ],
161
+ },
162
+ ];
163
+ if (opts?.allowFeedback !== false) {
164
+ fields.push({
165
+ type: "text",
166
+ name: PERMISSION_FEEDBACK_FIELD,
167
+ label: "Feedback (optional)",
168
+ multiline: true,
169
+ });
170
+ }
171
+ return { fields };
172
+ }
173
+ /**
174
+ * Build an answer spec from the legacy `question` event shape. Each question
175
+ * becomes one select field (free text when it declares no options), so the old
176
+ * question/answer path is expressible as a `question` interaction.
177
+ */
178
+ export function questionAnswerSpec(questions) {
179
+ const fields = questions.map((q, i) => {
180
+ const name = `q${i}`;
181
+ if (q.options && q.options.length > 0) {
182
+ return {
183
+ type: "select",
184
+ name,
185
+ label: q.question,
186
+ required: true,
187
+ multi: q.multiSelect === true,
188
+ options: q.options.map((o) => ({ value: o.label, label: o.label, description: o.description })),
189
+ };
190
+ }
191
+ return { type: "text", name, label: q.question, required: true };
192
+ });
193
+ return { fields };
194
+ }
195
+ /**
196
+ * Validate an accepted answer against its spec. Used by the broker before a
197
+ * response reaches the adapter, so malformed answers are rejected centrally.
198
+ */
199
+ export function validateInteractionAnswer(spec, data) {
200
+ const errors = [];
201
+ const d = data ?? {};
202
+ for (const field of spec.fields) {
203
+ const v = d[field.name];
204
+ const present = v !== undefined && v !== null && !(typeof v === "string" && v === "");
205
+ if (!present) {
206
+ if (field.required)
207
+ errors.push(`missing required field "${field.name}"`);
208
+ continue;
209
+ }
210
+ switch (field.type) {
211
+ case "text":
212
+ case "secret":
213
+ if (typeof v !== "string")
214
+ errors.push(`field "${field.name}" must be a string`);
215
+ break;
216
+ case "number":
217
+ if (typeof v !== "number") {
218
+ errors.push(`field "${field.name}" must be a number`);
219
+ }
220
+ else {
221
+ if (field.min !== undefined && v < field.min)
222
+ errors.push(`field "${field.name}" below min ${field.min}`);
223
+ if (field.max !== undefined && v > field.max)
224
+ errors.push(`field "${field.name}" above max ${field.max}`);
225
+ }
226
+ break;
227
+ case "boolean":
228
+ if (typeof v !== "boolean")
229
+ errors.push(`field "${field.name}" must be a boolean`);
230
+ break;
231
+ case "select": {
232
+ if (!Array.isArray(v)) {
233
+ errors.push(`field "${field.name}" must be an array of option values`);
234
+ break;
235
+ }
236
+ if (!field.multi && v.length > 1)
237
+ errors.push(`field "${field.name}" accepts a single value`);
238
+ if (field.required && v.length === 0)
239
+ errors.push(`field "${field.name}" requires a selection`);
240
+ const allowed = new Set(field.options.map((o) => o.value));
241
+ for (const choice of v) {
242
+ if (!allowed.has(choice))
243
+ errors.push(`field "${field.name}" has invalid option "${choice}"`);
244
+ }
245
+ break;
246
+ }
247
+ }
248
+ }
249
+ return errors.length === 0 ? { ok: true } : { ok: false, errors };
250
+ }
@@ -1,4 +1,6 @@
1
1
  import { z } from "zod";
2
+ import type { AgentProfile } from "./agent-profile.js";
3
+ import { type SandboxSizePreset } from "./sandbox-size.js";
2
4
  export declare const agentProfilePermissionValueSchema: z.ZodEnum<{
3
5
  allow: "allow";
4
6
  ask: "ask";
@@ -378,3 +380,24 @@ export declare const agentProfileSchema: z.ZodObject<{
378
380
  metadata: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
379
381
  extensions: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodRecord<z.ZodString, z.ZodUnknown>, z.ZodUndefined]>>>;
380
382
  }, z.core.$strip>;
383
+ /**
384
+ * A registered capability: a stable id paired with its canonical
385
+ * {@link AgentProfile}. `definition` is the full profile (prompt/model/tools/
386
+ * mcp/permissions), so a capability carries the whole agent shape, not just a
387
+ * system prompt. The platform capability registry validates and stores these.
388
+ */
389
+ export interface Capability {
390
+ /** Stable, deterministic id — the key a workflow's `agent.run.profile` names. */
391
+ id: string;
392
+ /** The canonical agent profile (prompt/model/tools/mcp/permissions). */
393
+ definition: AgentProfile;
394
+ /**
395
+ * Recommended compute tier for a sandbox running this capability. A dispatcher
396
+ * uses it as the size DEFAULT when the caller does not pick one — so a
397
+ * capability that only ever does thin work defaults to a small box instead of
398
+ * a maxed one. The caller may always override it per dispatch. Omitted → the
399
+ * dispatcher's own default tier.
400
+ */
401
+ recommendedSize?: SandboxSizePreset;
402
+ }
403
+ export declare const capabilitySchema: z.ZodType<Capability>;
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { SANDBOX_SIZE_PRESET_NAMES, } from "./sandbox-size.js";
2
3
  export const agentProfilePermissionValueSchema = z.enum([
3
4
  "allow",
4
5
  "deny",
@@ -126,3 +127,8 @@ export const agentProfileSchema = z.object({
126
127
  });
127
128
  const _agentProfileSchemaMatchesInterface = true;
128
129
  void _agentProfileSchemaMatchesInterface;
130
+ export const capabilitySchema = z.object({
131
+ id: z.string().min(1),
132
+ definition: agentProfileSchema,
133
+ recommendedSize: z.enum(SANDBOX_SIZE_PRESET_NAMES).optional(),
134
+ });
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Security validation for inline {@link AgentProfile} values.
3
+ *
4
+ * When a caller sends a full profile inline (rather than naming a curated,
5
+ * trusted capability), the profile is author-controlled and may declare surfaces
6
+ * that execute code OUTSIDE the agent's own reasoning — a stdio/local MCP server
7
+ * (an arbitrary command spawned at startup) or a hook (a shell command run
8
+ * automatically around the turn). A sandbox isolates the workload, but these
9
+ * surfaces run unattended on the owner's behalf, so a cloud dispatcher must gate
10
+ * them before materializing the profile.
11
+ *
12
+ * This validates the CANONICAL agent-interface shape (`mcp` keyed by
13
+ * `AgentProfileMcpServer`, `hooks` keyed by `AgentProfileHookCommand[]`) — the
14
+ * provider-neutral contract every backend translates from. The opencode-native
15
+ * `validateProfileSecurity` (in `sdk-provider-opencode`) operates on opencode's
16
+ * own profile shape and is a different layer; this is the one to use at the
17
+ * application boundary where profiles are still provider-neutral.
18
+ */
19
+ import type { AgentProfile, AgentProfileValidationResult } from "./agent-profile.js";
20
+ /** Policy for {@link validateAgentProfileSecurity}. */
21
+ export interface AgentProfileSecurityPolicy {
22
+ /**
23
+ * Allow stdio/local MCP servers (a spawned local process). Off in cloud: an
24
+ * inline profile must not start arbitrary commands at MCP init.
25
+ */
26
+ allowLocalMcp: boolean;
27
+ /**
28
+ * Allow lifecycle hooks (shell commands run automatically around the turn).
29
+ * Off in cloud: hooks are author-controlled shell outside the agent's loop.
30
+ */
31
+ allowHooks: boolean;
32
+ /**
33
+ * Glob allowlist for REMOTE MCP hosts (http/sse `url` hostnames). A set list
34
+ * rejects any host that matches no pattern; an empty list (`[]`) blocks ALL
35
+ * remote MCP.
36
+ *
37
+ * SECURITY: `undefined` leaves remote MCP hosts UNRESTRICTED — it does NOT
38
+ * protect against SSRF (an inline profile could point an MCP server at, e.g.,
39
+ * `http://169.254.169.254/` cloud metadata or an internal address). When this
40
+ * is `undefined`, the caller MUST enforce SSRF/egress controls at the network
41
+ * layer, or set a restrictive allowlist (or `[]`) to fail closed.
42
+ */
43
+ allowedMcpHosts?: string[];
44
+ /**
45
+ * Allow `connections` (hub-managed integration grants). `undefined` leaves them
46
+ * allowed — a connection is a legitimate inline-profile feature where the host
47
+ * wires it. A host that does NOT support inline connection grants (e.g. a
48
+ * surface that grants hub access through a separate, audited path) sets this
49
+ * `false` so an inline profile cannot smuggle hub access through the profile.
50
+ */
51
+ allowConnections?: boolean;
52
+ }
53
+ /**
54
+ * Default cloud policy: block the two unattended-code surfaces (local MCP,
55
+ * hooks); leave remote MCP and everything else to the profile. Deliberately
56
+ * narrow — it gates code execution paths, not the agent's normal tools/edits,
57
+ * which the sandbox already isolates.
58
+ *
59
+ * NOTE: this default leaves `allowedMcpHosts` undefined, so REMOTE MCP hosts are
60
+ * unrestricted — it is NOT an SSRF guard (see `allowedMcpHosts`). A surface that
61
+ * must fail closed against arbitrary MCP egress should set `allowedMcpHosts`
62
+ * (e.g. `[]` to block all remote MCP), as the workflow inline-profile policy does.
63
+ */
64
+ export declare const DEFAULT_CLOUD_AGENT_PROFILE_SECURITY_POLICY: AgentProfileSecurityPolicy;
65
+ /**
66
+ * Validate an inline profile against a security policy. Returns `ok: false` with
67
+ * `error`-level issues when the profile declares a blocked surface; warnings do
68
+ * not fail. Pure and synchronous — safe to run at author (compile) time and
69
+ * again at dispatch as defense in depth.
70
+ */
71
+ export declare function validateAgentProfileSecurity(profile: AgentProfile, policy?: AgentProfileSecurityPolicy): AgentProfileValidationResult;
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Security validation for inline {@link AgentProfile} values.
3
+ *
4
+ * When a caller sends a full profile inline (rather than naming a curated,
5
+ * trusted capability), the profile is author-controlled and may declare surfaces
6
+ * that execute code OUTSIDE the agent's own reasoning — a stdio/local MCP server
7
+ * (an arbitrary command spawned at startup) or a hook (a shell command run
8
+ * automatically around the turn). A sandbox isolates the workload, but these
9
+ * surfaces run unattended on the owner's behalf, so a cloud dispatcher must gate
10
+ * them before materializing the profile.
11
+ *
12
+ * This validates the CANONICAL agent-interface shape (`mcp` keyed by
13
+ * `AgentProfileMcpServer`, `hooks` keyed by `AgentProfileHookCommand[]`) — the
14
+ * provider-neutral contract every backend translates from. The opencode-native
15
+ * `validateProfileSecurity` (in `sdk-provider-opencode`) operates on opencode's
16
+ * own profile shape and is a different layer; this is the one to use at the
17
+ * application boundary where profiles are still provider-neutral.
18
+ */
19
+ /**
20
+ * Default cloud policy: block the two unattended-code surfaces (local MCP,
21
+ * hooks); leave remote MCP and everything else to the profile. Deliberately
22
+ * narrow — it gates code execution paths, not the agent's normal tools/edits,
23
+ * which the sandbox already isolates.
24
+ *
25
+ * NOTE: this default leaves `allowedMcpHosts` undefined, so REMOTE MCP hosts are
26
+ * unrestricted — it is NOT an SSRF guard (see `allowedMcpHosts`). A surface that
27
+ * must fail closed against arbitrary MCP egress should set `allowedMcpHosts`
28
+ * (e.g. `[]` to block all remote MCP), as the workflow inline-profile policy does.
29
+ */
30
+ export const DEFAULT_CLOUD_AGENT_PROFILE_SECURITY_POLICY = {
31
+ allowLocalMcp: false,
32
+ allowHooks: false,
33
+ };
34
+ /**
35
+ * Match one DNS label against one pattern label, where `*` matches any run of
36
+ * characters WITHIN the label (never a `.`). Split-on-`*` with linear
37
+ * prefix/suffix/in-order scanning — deliberately NOT a constructed `RegExp`, so
38
+ * on this security boundary it stays provably linear with no catastrophic-
39
+ * backtracking surface whatever pattern an allowlist carries.
40
+ */
41
+ function matchLabel(label, pattern) {
42
+ const segments = pattern.split("*");
43
+ if (segments.length === 1)
44
+ return label === segments[0]; // no wildcard
45
+ const first = segments[0];
46
+ const last = segments[segments.length - 1];
47
+ if (!label.startsWith(first) || !label.endsWith(last))
48
+ return false;
49
+ // Prefix and suffix may not overlap (e.g. `aa*aa` must not match `aaa`).
50
+ if (first.length + last.length > label.length)
51
+ return false;
52
+ let cursor = first.length;
53
+ const suffixStart = label.length - last.length;
54
+ for (let i = 1; i < segments.length - 1; i += 1) {
55
+ const seg = segments[i];
56
+ if (seg.length === 0)
57
+ continue;
58
+ const found = label.indexOf(seg, cursor);
59
+ if (found === -1 || found + seg.length > suffixStart)
60
+ return false;
61
+ cursor = found + seg.length;
62
+ }
63
+ return true;
64
+ }
65
+ /**
66
+ * Case-insensitive host glob (`*` only) for allowlists, matched at DNS LABEL
67
+ * granularity: the host and pattern are split on `.` and matched label-by-label,
68
+ * and a `*` matches within a single label only — it never crosses a `.`. So
69
+ * `*.example.com` matches `api.example.com` but NOT `evil-example.com` (no dot
70
+ * boundary), `a.b.example.com` (extra label), or the bare apex `example.com`
71
+ * (missing label). This prevents a wildcard from reaching an unintended sibling
72
+ * or deeper domain on the security boundary.
73
+ *
74
+ * An IPv6 host (the hostname contains `:`) is matched EXACTLY — never split or
75
+ * globbed — since `.`-label semantics don't apply to it (and an IPv4-mapped form
76
+ * like `::ffff:1.2.3.4` contains dots that would otherwise glob unpredictably).
77
+ */
78
+ function matchHostGlob(host, pattern) {
79
+ const h = host.toLowerCase();
80
+ const p = pattern.toLowerCase();
81
+ if (h.includes(":") || p.includes(":"))
82
+ return h === p;
83
+ const hostLabels = h.split(".");
84
+ const patternLabels = p.split(".");
85
+ if (hostLabels.length !== patternLabels.length)
86
+ return false;
87
+ return patternLabels.every((label, i) => matchLabel(hostLabels[i], label));
88
+ }
89
+ /**
90
+ * A local/stdio MCP server spawns a process; a remote one connects over the
91
+ * network. ANY `command` makes it local — a spawnable process command is the
92
+ * thing being gated, and it stays dangerous whatever `transport` is declared
93
+ * alongside it (pairing `command` with `transport: "sse"`/`"http"` must not slip
94
+ * it past as "remote"). `transport: "stdio"` is local even with no command.
95
+ */
96
+ function isLocalMcpServer(server) {
97
+ return server.command !== undefined || server.transport === "stdio";
98
+ }
99
+ /**
100
+ * Validate an inline profile against a security policy. Returns `ok: false` with
101
+ * `error`-level issues when the profile declares a blocked surface; warnings do
102
+ * not fail. Pure and synchronous — safe to run at author (compile) time and
103
+ * again at dispatch as defense in depth.
104
+ */
105
+ export function validateAgentProfileSecurity(profile, policy = DEFAULT_CLOUD_AGENT_PROFILE_SECURITY_POLICY) {
106
+ const issues = [];
107
+ for (const [name, server] of Object.entries(profile.mcp ?? {})) {
108
+ if (isLocalMcpServer(server)) {
109
+ if (!policy.allowLocalMcp) {
110
+ issues.push({
111
+ level: "error",
112
+ code: "BLOCKED_LOCAL_MCP",
113
+ message: `local/stdio MCP server '${name}' is not allowed (it spawns an arbitrary process)`,
114
+ path: `mcp.${name}`,
115
+ });
116
+ }
117
+ continue;
118
+ }
119
+ if (policy.allowedMcpHosts) {
120
+ // An allowlist is active, so a non-local server MUST present a matchable
121
+ // host — a missing/empty `url` cannot be allowlist-checked, so it fails
122
+ // closed rather than slipping through unvalidated.
123
+ if (!server.url) {
124
+ issues.push({
125
+ level: "error",
126
+ code: "INVALID_MCP_URL",
127
+ message: `remote MCP server '${name}' has no url to check against the allowlist`,
128
+ path: `mcp.${name}`,
129
+ });
130
+ continue;
131
+ }
132
+ let host;
133
+ try {
134
+ host = new URL(server.url).hostname;
135
+ }
136
+ catch {
137
+ issues.push({
138
+ level: "error",
139
+ code: "INVALID_MCP_URL",
140
+ message: `MCP server '${name}' has an invalid url: ${server.url}`,
141
+ path: `mcp.${name}`,
142
+ });
143
+ continue;
144
+ }
145
+ if (!policy.allowedMcpHosts.some((p) => matchHostGlob(host, p))) {
146
+ issues.push({
147
+ level: "error",
148
+ code: "BLOCKED_REMOTE_MCP_HOST",
149
+ message: `remote MCP host '${host}' is not in the allowlist`,
150
+ path: `mcp.${name}`,
151
+ });
152
+ }
153
+ }
154
+ }
155
+ if (!policy.allowHooks &&
156
+ profile.hooks &&
157
+ Object.keys(profile.hooks).length > 0) {
158
+ issues.push({
159
+ level: "error",
160
+ code: "BLOCKED_HOOKS",
161
+ message: "hooks are not allowed (they run author-controlled shell commands automatically)",
162
+ path: "hooks",
163
+ });
164
+ }
165
+ if (policy.allowConnections === false &&
166
+ profile.connections &&
167
+ profile.connections.length > 0) {
168
+ issues.push({
169
+ level: "error",
170
+ code: "BLOCKED_CONNECTIONS",
171
+ message: "hub connections are not allowed in an inline profile here — grant hub access through this surface's supported connection path instead",
172
+ path: "connections",
173
+ });
174
+ }
175
+ return {
176
+ ok: !issues.some((i) => i.level === "error"),
177
+ issues,
178
+ };
179
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Portable sandbox size vocabulary.
3
+ *
4
+ * A size preset is a provider-neutral NAME for a compute tier (cpu / memory /
5
+ * disk). This module owns only the vocabulary — the names and their smallest→
6
+ * largest ordering — so the lowest shared layer can reference a size without
7
+ * depending on the sandbox SDK (`Capability.recommendedSize` lives here, and
8
+ * agent-interface must not depend on `@tangle-network/sandbox`).
9
+ *
10
+ * The concrete cpu/memory/disk numbers for each preset are the sandbox SDK's
11
+ * single source of truth (`@tangle-network/sandbox` → `SANDBOX_SIZE_PRESETS`),
12
+ * which imports these names. Mirrors how this package owns the `ReasoningEffort`
13
+ * vocabulary while backends own its native mapping.
14
+ */
15
+ /** Compute tiers, ordered smallest → largest. */
16
+ export declare const SANDBOX_SIZE_PRESET_NAMES: readonly ["nano", "small", "medium", "large"];
17
+ /**
18
+ * A named compute tier for a sandbox. `nano` suits thin glue work (a single API
19
+ * call, a notify); `large` suits heavy builds over big repositories. Sizing is a
20
+ * per-task decision — a thin workflow step should not provision a maxed box.
21
+ */
22
+ export type SandboxSizePreset = (typeof SANDBOX_SIZE_PRESET_NAMES)[number];
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Portable sandbox size vocabulary.
3
+ *
4
+ * A size preset is a provider-neutral NAME for a compute tier (cpu / memory /
5
+ * disk). This module owns only the vocabulary — the names and their smallest→
6
+ * largest ordering — so the lowest shared layer can reference a size without
7
+ * depending on the sandbox SDK (`Capability.recommendedSize` lives here, and
8
+ * agent-interface must not depend on `@tangle-network/sandbox`).
9
+ *
10
+ * The concrete cpu/memory/disk numbers for each preset are the sandbox SDK's
11
+ * single source of truth (`@tangle-network/sandbox` → `SANDBOX_SIZE_PRESETS`),
12
+ * which imports these names. Mirrors how this package owns the `ReasoningEffort`
13
+ * vocabulary while backends own its native mapping.
14
+ */
15
+ /** Compute tiers, ordered smallest → largest. */
16
+ export const SANDBOX_SIZE_PRESET_NAMES = [
17
+ "nano",
18
+ "small",
19
+ "medium",
20
+ "large",
21
+ ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tangle-network/agent-interface",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "main": "./dist/index.js",