@downcity/plugins 1.0.61 → 1.0.64

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.
@@ -19,6 +19,7 @@ import { generateId } from "@downcity/agent/internal/utils/Id.js";
19
19
  import { readChatMetaBySessionId } from "@/chat/runtime/ChatMetaStore.js";
20
20
  import type {
21
21
  ShellActionResponse,
22
+ ShellApprovalStatus,
22
23
  ShellCloseRequest,
23
24
  ShellExecRequest,
24
25
  ShellQueryRequest,
@@ -46,6 +47,12 @@ import {
46
47
  updateSessionSnapshot,
47
48
  } from "./ShellActionRuntimeSupport.js";
48
49
  import { attachShellProcessEventHandlers } from "./ShellProcessEvents.js";
50
+ import {
51
+ listPendingApprovals,
52
+ requestUnrestrictedApproval,
53
+ resolveApproval,
54
+ validateUnrestrictedRequest,
55
+ } from "./ShellApprovalRuntime.js";
49
56
 
50
57
  export { createShellPluginState } from "./ShellActionRuntimeSupport.js";
51
58
 
@@ -72,6 +79,20 @@ export async function closeAllShellSessions(
72
79
  state: ShellPluginState,
73
80
  force = false,
74
81
  ): Promise<void> {
82
+ for (const approval of Array.from(state.approvals.values())) {
83
+ if (state.context) {
84
+ await resolveApproval({
85
+ state,
86
+ context: state.context,
87
+ approvalId: approval.approvalId,
88
+ decision: "expired",
89
+ }).catch(() => undefined);
90
+ continue;
91
+ }
92
+ clearTimeout(approval.timer);
93
+ state.approvals.delete(approval.approvalId);
94
+ approval.resolve("expired");
95
+ }
75
96
  const closing = Array.from(state.sessions.values()).map(async (session) => {
76
97
  if (
77
98
  session.snapshot.status !== "running" &&
@@ -93,6 +114,65 @@ export async function closeAllShellSessions(
93
114
  await Promise.all(closing);
94
115
  }
95
116
 
117
+ function resolveSandboxMode(value: unknown): "safe" | "unrestricted" {
118
+ return value === "unrestricted" ? "unrestricted" : "safe";
119
+ }
120
+
121
+ function buildDeniedApprovalResponse(params: {
122
+ shellId: string;
123
+ ownerContextId?: string;
124
+ cmd: string;
125
+ cwd: string;
126
+ shellPath: string;
127
+ approvalId: string;
128
+ reason: string;
129
+ approvalStatus: ShellApprovalStatus;
130
+ }): ShellActionResponse {
131
+ const now = nowMs();
132
+ const message = params.approvalStatus === "expired"
133
+ ? "Unrestricted sandbox approval expired."
134
+ : "User denied unrestricted sandbox execution.";
135
+ return buildActionResponse({
136
+ shell: {
137
+ shellId: params.shellId,
138
+ ...(params.ownerContextId ? { ownerContextId: params.ownerContextId } : {}),
139
+ cmd: params.cmd,
140
+ cwd: params.cwd,
141
+ shellPath: params.shellPath,
142
+ sandboxed: false,
143
+ sandboxMode: "unrestricted",
144
+ sandboxBackend: "unrestricted-host",
145
+ sandboxNetworkMode: "full",
146
+ approvalStatus: params.approvalStatus,
147
+ approvalId: params.approvalId,
148
+ approvalReason: params.reason,
149
+ stdinWritable: false,
150
+ status: params.approvalStatus === "expired" ? "expired" : "failed",
151
+ startedAt: now,
152
+ updatedAt: now,
153
+ endedAt: now,
154
+ exitCode: -1,
155
+ lastOutputPreview: message,
156
+ outputChars: message.length,
157
+ droppedChars: 0,
158
+ version: 1,
159
+ autoNotifyOnExit: false,
160
+ notificationSent: false,
161
+ externalRefs: [],
162
+ },
163
+ chunk: {
164
+ shellId: params.shellId,
165
+ output: message,
166
+ startCursor: 0,
167
+ endCursor: message.length,
168
+ originalChars: message.length,
169
+ originalLines: 1,
170
+ hasMoreOutput: false,
171
+ },
172
+ note: message,
173
+ });
174
+ }
175
+
96
176
  /**
97
177
  * 启动一个 shell session。
98
178
  */
@@ -111,6 +191,8 @@ export async function startShellSession(
111
191
  const shellPath =
112
192
  String(request.shell || resolveDefaultShellPath()).trim() || resolveDefaultShellPath();
113
193
  const login = request.login !== false;
194
+ const sandboxMode = resolveSandboxMode(request.sandbox);
195
+ const reason = String(request.reason || "").trim();
114
196
  const ownerContextId = resolveOwnerContextId(request.ownerContextId);
115
197
  const canAutoNotifyByContext = ownerContextId
116
198
  ? Boolean(
@@ -124,6 +206,37 @@ export async function startShellSession(
124
206
  await fs.ensureDir(shellDir);
125
207
  await fs.writeFile(outputFilePath, "", "utf-8");
126
208
 
209
+ let approvalId: string | undefined;
210
+ let approvalStatus: ShellApprovalStatus | undefined;
211
+ if (sandboxMode === "unrestricted") {
212
+ const validationError = validateUnrestrictedRequest({ cmd, reason });
213
+ if (validationError) throw new Error(validationError);
214
+ const approval = await requestUnrestrictedApproval({
215
+ state,
216
+ context,
217
+ shellId,
218
+ toolName: request.approvalToolName || "shell_start",
219
+ cmd,
220
+ cwd,
221
+ reason,
222
+ ...(ownerContextId ? { ownerContextId } : {}),
223
+ });
224
+ approvalId = approval.approvalId;
225
+ approvalStatus = approval.status;
226
+ if (approval.status !== "approved") {
227
+ return buildDeniedApprovalResponse({
228
+ shellId,
229
+ ...(ownerContextId ? { ownerContextId } : {}),
230
+ cmd,
231
+ cwd,
232
+ shellPath,
233
+ approvalId: approval.approvalId,
234
+ reason,
235
+ approvalStatus: approval.status,
236
+ });
237
+ }
238
+ }
239
+
127
240
  const spawnResult = await spawnShellProcess({
128
241
  context,
129
242
  shellId,
@@ -133,6 +246,7 @@ export async function startShellSession(
133
246
  shellPath,
134
247
  login,
135
248
  baseEnv: buildShellEnv(context),
249
+ sandboxMode,
136
250
  });
137
251
  const child = spawnResult.child;
138
252
  const actualCwd = spawnResult.cwd;
@@ -150,12 +264,17 @@ export async function startShellSession(
150
264
  cwd: actualCwd,
151
265
  shellPath,
152
266
  sandboxed: spawnResult.sandboxed,
267
+ sandboxMode: spawnResult.sandboxMode || sandboxMode,
153
268
  sandboxBackend: spawnResult.backend,
154
269
  sandboxNetworkMode: spawnResult.networkMode,
155
270
  sandboxDir: spawnResult.sandboxDir,
156
271
  sandboxHomeDir: spawnResult.homeDir,
157
272
  sandboxTmpDir: spawnResult.tmpDir,
158
273
  sandboxCacheDir: spawnResult.cacheDir,
274
+ ...(approvalStatus ? { approvalStatus } : {}),
275
+ ...(approvalId ? { approvalId } : {}),
276
+ ...(reason ? { approvalReason: reason } : {}),
277
+ stdinWritable: sandboxMode === "safe",
159
278
  status: "running",
160
279
  ...(typeof child.pid === "number" ? { pid: child.pid } : {}),
161
280
  startedAt,
@@ -302,6 +421,9 @@ export async function writeShellSession(
302
421
  if (!session.child.stdin.writable) {
303
422
  throw new Error(`shell session ${shellId} stdin is closed`);
304
423
  }
424
+ if (session.snapshot.stdinWritable === false) {
425
+ throw new Error(`shell session ${shellId} does not allow stdin writes`);
426
+ }
305
427
  await new Promise<void>((resolve, reject) => {
306
428
  session.child.stdin.write(chars, (error) => {
307
429
  if (error) {
@@ -460,6 +582,9 @@ export async function execShellCommand(
460
582
  ...(request.cwd ? { cwd: request.cwd } : {}),
461
583
  ...(request.shell ? { shell: request.shell } : {}),
462
584
  login: request.login,
585
+ sandbox: request.sandbox,
586
+ reason: request.reason,
587
+ approvalToolName: "shell_exec",
463
588
  inlineWaitMs: Math.min(state.options.defaultInlineWaitMs, timeoutMs),
464
589
  maxOutputTokens: request.maxOutputTokens,
465
590
  autoNotifyOnExit: false,
@@ -562,3 +687,42 @@ export async function execShellCommand(
562
687
  note: "shell exec completed in one-shot mode",
563
688
  });
564
689
  }
690
+
691
+ /**
692
+ * 列出 pending unrestricted sandbox 审批。
693
+ */
694
+ export function listShellApprovals(state: ShellPluginState) {
695
+ return listPendingApprovals(state);
696
+ }
697
+
698
+ /**
699
+ * 批准 pending unrestricted sandbox 审批。
700
+ */
701
+ export async function approveShellApproval(
702
+ state: ShellPluginState,
703
+ context: AgentContext,
704
+ approvalId: string,
705
+ ): Promise<boolean> {
706
+ return await resolveApproval({
707
+ state,
708
+ context,
709
+ approvalId,
710
+ decision: "approved",
711
+ });
712
+ }
713
+
714
+ /**
715
+ * 拒绝 pending unrestricted sandbox 审批。
716
+ */
717
+ export async function denyShellApproval(
718
+ state: ShellPluginState,
719
+ context: AgentContext,
720
+ approvalId: string,
721
+ ): Promise<boolean> {
722
+ return await resolveApproval({
723
+ state,
724
+ context,
725
+ approvalId,
726
+ decision: "denied",
727
+ });
728
+ }
@@ -46,6 +46,7 @@ const DEFAULT_SHELL_PLUGIN_OPTIONS: ResolvedShellPluginOptions = {
46
46
  defaultInlineWaitMs: 1_200,
47
47
  defaultWaitTimeoutMs: 10_000,
48
48
  defaultExecTimeoutMs: 60_000,
49
+ defaultApprovalTimeoutMs: 120_000,
49
50
  };
50
51
 
51
52
  /**
@@ -121,6 +122,10 @@ export function resolveShellPluginOptions(
121
122
  options.defaultExecTimeoutMs,
122
123
  DEFAULT_SHELL_PLUGIN_OPTIONS.defaultExecTimeoutMs,
123
124
  ),
125
+ defaultApprovalTimeoutMs: readPositiveInteger(
126
+ options.defaultApprovalTimeoutMs,
127
+ DEFAULT_SHELL_PLUGIN_OPTIONS.defaultApprovalTimeoutMs,
128
+ ),
124
129
  };
125
130
  }
126
131
 
@@ -133,6 +138,7 @@ export function createShellPluginState(
133
138
  return {
134
139
  options: resolveShellPluginOptions(options),
135
140
  sessions: new Map<string, ShellSessionRuntimeState>(),
141
+ approvals: new Map(),
136
142
  context: null,
137
143
  };
138
144
  }
@@ -0,0 +1,236 @@
1
+ /**
2
+ * Shell unrestricted sandbox 审批运行时。
3
+ *
4
+ * 关键点(中文)
5
+ * - agent 只能通过 shell tool 请求 unrestricted sandbox;真正执行前必须等待用户确认。
6
+ * - 审批结果最终回到原 tool result;session event 只用于 UI/CLI/Console 展示和操作。
7
+ * - V1 授权粒度固定为单次命令或单次 shell_start 创建的命令会话。
8
+ */
9
+
10
+ import fs from "fs-extra";
11
+ import path from "node:path";
12
+ import { generateId } from "@downcity/agent/internal/utils/Id.js";
13
+ import { getSessionRunContext } from "@downcity/agent/internal/executor/SessionRunScope.js";
14
+ import type { AgentContext } from "@downcity/agent/internal/types/runtime/agent/AgentContext.js";
15
+ import type { ShellApprovalStatus } from "@downcity/agent/internal/executor/tools/shell/types/ShellPlugin.js";
16
+ import type { ShellPluginState } from "@/shell/ShellRuntimeTypes.js";
17
+ import { nowMs } from "./ShellActionRuntimeSupport.js";
18
+
19
+ const DANGEROUS_COMMAND_PATTERNS = [
20
+ /\bsudo\b/,
21
+ /\brm\s+-[^&|;\n]*r[^&|;\n]*f\s+\/(?:\s|$)/,
22
+ /\bchmod\s+-R\s+777\s+\/(?:\s|$)/,
23
+ /\bssh-keygen\b/,
24
+ /\bsecurity\s+(?:add|delete|unlock|set|import|export)-/i,
25
+ /(?:^|[\s;&|])(?:nohup\s+)?[^;&|\n]*(?:&)\s*$/,
26
+ ];
27
+
28
+ function isDangerousCommand(cmd: string): boolean {
29
+ return DANGEROUS_COMMAND_PATTERNS.some((pattern) => pattern.test(cmd));
30
+ }
31
+
32
+ function resolveAuditPath(context: AgentContext): string {
33
+ return path.join(context.rootPath, ".downcity", "logs", "unrestricted-sandbox-audit.jsonl");
34
+ }
35
+
36
+ async function appendAudit(params: {
37
+ context: AgentContext;
38
+ record: Record<string, unknown>;
39
+ }): Promise<void> {
40
+ const filePath = resolveAuditPath(params.context);
41
+ await fs.ensureDir(path.dirname(filePath));
42
+ await fs.appendFile(filePath, `${JSON.stringify(params.record)}\n`, "utf-8");
43
+ }
44
+
45
+ function publishApprovalResult(params: {
46
+ context: AgentContext;
47
+ ownerContextId?: string;
48
+ approvalId: string;
49
+ shellId: string;
50
+ toolName: "shell_exec" | "shell_start";
51
+ decision: ShellApprovalStatus;
52
+ }): void {
53
+ const sessionId = String(params.ownerContextId || "").trim();
54
+ if (!sessionId) return;
55
+ const turnId = String(getSessionRunContext()?.turnId || sessionId).trim();
56
+ try {
57
+ params.context.session.get(sessionId).publishEvent({
58
+ type: "tool-approval-result",
59
+ turnId,
60
+ toolCallId: params.shellId,
61
+ toolName: params.toolName,
62
+ approvalId: params.approvalId,
63
+ decision: params.decision,
64
+ });
65
+ } catch {
66
+ // ignore event delivery failures
67
+ }
68
+ }
69
+
70
+ /**
71
+ * 校验 unrestricted sandbox 请求。
72
+ */
73
+ export function validateUnrestrictedRequest(params: {
74
+ cmd: string;
75
+ reason?: string;
76
+ }): string | null {
77
+ const reason = String(params.reason || "").trim();
78
+ if (!reason) {
79
+ return "unrestricted sandbox requires a non-empty reason";
80
+ }
81
+ if (isDangerousCommand(params.cmd)) {
82
+ return "unrestricted sandbox rejected a dangerous command";
83
+ }
84
+ return null;
85
+ }
86
+
87
+ /**
88
+ * 请求用户批准 unrestricted sandbox 执行。
89
+ */
90
+ export async function requestUnrestrictedApproval(params: {
91
+ state: ShellPluginState;
92
+ context: AgentContext;
93
+ shellId: string;
94
+ toolName: "shell_exec" | "shell_start";
95
+ cmd: string;
96
+ cwd: string;
97
+ reason: string;
98
+ ownerContextId?: string;
99
+ }): Promise<{
100
+ approvalId: string;
101
+ status: ShellApprovalStatus;
102
+ }> {
103
+ const approvalId = `ap_${generateId()}`;
104
+ const createdAt = nowMs();
105
+ const ownerContextId = String(params.ownerContextId || "").trim() || undefined;
106
+
107
+ const status = await new Promise<ShellApprovalStatus>((resolve) => {
108
+ const timer = setTimeout(() => {
109
+ resolveApproval({
110
+ state: params.state,
111
+ context: params.context,
112
+ approvalId,
113
+ decision: "expired",
114
+ }).catch(() => undefined);
115
+ }, params.state.options.defaultApprovalTimeoutMs);
116
+ if (typeof timer.unref === "function") timer.unref();
117
+
118
+ params.state.approvals.set(approvalId, {
119
+ approvalId,
120
+ shellId: params.shellId,
121
+ ...(ownerContextId ? { ownerContextId } : {}),
122
+ toolName: params.toolName,
123
+ cmd: params.cmd,
124
+ cwd: params.cwd,
125
+ reason: params.reason,
126
+ createdAt,
127
+ timer,
128
+ resolve,
129
+ });
130
+
131
+ if (ownerContextId) {
132
+ const turnId = String(getSessionRunContext()?.turnId || ownerContextId).trim();
133
+ try {
134
+ params.context.session.get(ownerContextId).publishEvent({
135
+ type: "tool-approval-request",
136
+ turnId,
137
+ toolCallId: params.shellId,
138
+ toolName: params.toolName,
139
+ approvalId,
140
+ sandbox: "unrestricted",
141
+ cmd: params.cmd,
142
+ cwd: params.cwd,
143
+ reason: params.reason,
144
+ status: "pending",
145
+ });
146
+ } catch {
147
+ // ignore event delivery failures
148
+ }
149
+ }
150
+
151
+ appendAudit({
152
+ context: params.context,
153
+ record: {
154
+ event: "approval_requested",
155
+ approval_id: approvalId,
156
+ session_id: ownerContextId || null,
157
+ tool_call_id: params.shellId,
158
+ agent_id: params.context.config?.id || null,
159
+ cmd: params.cmd,
160
+ cwd: params.cwd,
161
+ reason: params.reason,
162
+ created_at: new Date(createdAt).toISOString(),
163
+ },
164
+ }).catch(() => undefined);
165
+ });
166
+
167
+ return { approvalId, status };
168
+ }
169
+
170
+ /**
171
+ * 兑现 unrestricted sandbox 审批。
172
+ */
173
+ export async function resolveApproval(params: {
174
+ state: ShellPluginState;
175
+ context: AgentContext;
176
+ approvalId: string;
177
+ decision: ShellApprovalStatus;
178
+ }): Promise<boolean> {
179
+ const approval = params.state.approvals.get(params.approvalId);
180
+ if (!approval) return false;
181
+ params.state.approvals.delete(params.approvalId);
182
+ clearTimeout(approval.timer);
183
+ approval.resolve(params.decision);
184
+
185
+ publishApprovalResult({
186
+ context: params.context,
187
+ ownerContextId: approval.ownerContextId,
188
+ approvalId: approval.approvalId,
189
+ shellId: approval.shellId,
190
+ toolName: approval.toolName,
191
+ decision: params.decision,
192
+ });
193
+
194
+ await appendAudit({
195
+ context: params.context,
196
+ record: {
197
+ event: "approval_resolved",
198
+ approval_id: approval.approvalId,
199
+ session_id: approval.ownerContextId || null,
200
+ tool_call_id: approval.shellId,
201
+ agent_id: params.context.config?.id || null,
202
+ cmd: approval.cmd,
203
+ cwd: approval.cwd,
204
+ reason: approval.reason,
205
+ decision: params.decision,
206
+ resolved_at: new Date(nowMs()).toISOString(),
207
+ },
208
+ }).catch(() => undefined);
209
+
210
+ return true;
211
+ }
212
+
213
+ /**
214
+ * 列出 pending unrestricted sandbox 审批。
215
+ */
216
+ export function listPendingApprovals(state: ShellPluginState): Array<{
217
+ approvalId: string;
218
+ shellId: string;
219
+ ownerContextId?: string;
220
+ toolName: "shell_exec" | "shell_start";
221
+ cmd: string;
222
+ cwd: string;
223
+ reason: string;
224
+ createdAt: number;
225
+ }> {
226
+ return Array.from(state.approvals.values()).map((approval) => ({
227
+ approvalId: approval.approvalId,
228
+ shellId: approval.shellId,
229
+ ...(approval.ownerContextId ? { ownerContextId: approval.ownerContextId } : {}),
230
+ toolName: approval.toolName,
231
+ cmd: approval.cmd,
232
+ cwd: approval.cwd,
233
+ reason: approval.reason,
234
+ createdAt: approval.createdAt,
235
+ }));
236
+ }
@@ -59,6 +59,11 @@ export interface ShellPluginOptions {
59
59
  * `shell.exec` 默认总超时,单位毫秒。
60
60
  */
61
61
  defaultExecTimeoutMs?: number;
62
+
63
+ /**
64
+ * unrestricted sandbox 审批默认超时时间,单位毫秒。
65
+ */
66
+ defaultApprovalTimeoutMs?: number;
62
67
  }
63
68
 
64
69
  /**
@@ -109,4 +114,9 @@ export interface ResolvedShellPluginOptions {
109
114
  * `shell.exec` 默认总超时,单位毫秒。
110
115
  */
111
116
  defaultExecTimeoutMs: number;
117
+
118
+ /**
119
+ * unrestricted sandbox 审批默认超时时间,单位毫秒。
120
+ */
121
+ defaultApprovalTimeoutMs: number;
112
122
  }