@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 +43 -1
- package/dist/index.js +3 -0
- package/dist/interaction.d.ts +281 -0
- package/dist/interaction.js +250 -0
- package/dist/profile-schema.d.ts +23 -0
- package/dist/profile-schema.js +6 -0
- package/dist/profile-security.d.ts +71 -0
- package/dist/profile-security.js +179 -0
- package/dist/sandbox-size.d.ts +22 -0
- package/dist/sandbox-size.js +21 -0
- package/package.json +1 -1
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
|
+
}
|
package/dist/profile-schema.d.ts
CHANGED
|
@@ -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>;
|
package/dist/profile-schema.js
CHANGED
|
@@ -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
|
+
];
|