diff --git a/crates/agent-gateway/internal/chatcmd/chatcmd.go b/crates/agent-gateway/internal/chatcmd/chatcmd.go index 30c93ce7a..408c5da2f 100644 --- a/crates/agent-gateway/internal/chatcmd/chatcmd.go +++ b/crates/agent-gateway/internal/chatcmd/chatcmd.go @@ -64,6 +64,7 @@ func NormalizeRequestBody(body *handler.ChatRequestBody) error { body.ClientRequestID = strings.TrimSpace(body.ClientRequestID) body.ExecutionMode = handler.NormalizeExecutionMode(body.ExecutionMode) body.Workdir = handler.NormalizeWorkdir(body.Workdir) + body.CommandSafetyMode = handler.NormalizeCommandSafetyMode(body.CommandSafetyMode) body.QueuePolicy = normalizeQueuePolicy(body.QueuePolicy) body.UploadedFiles = handler.NormalizeChatUploadedFiles(body.UploadedFiles) body.RuntimeControls = handler.NormalizeChatRuntimeControls(body.RuntimeControls) @@ -274,6 +275,7 @@ func buildUserMessageAppendedPayload( "uploaded_files": body.UploadedFiles, "execution_mode": body.ExecutionMode, "workdir": body.Workdir, + "command_safety_mode": body.CommandSafetyMode, "runtime_controls": body.RuntimeControls, "selected_model": body.SelectedModel, } @@ -324,6 +326,7 @@ func buildProtoRequest(body handler.ChatRequestBody) *gatewayv2.ChatRequest { RuntimeControls: handler.ToProtoChatRuntimeControls(body.RuntimeControls), ExecutionMode: body.ExecutionMode, Workdir: body.Workdir, + CommandSafetyMode: body.CommandSafetyMode, UploadedFiles: handler.ToProtoChatUploadedFiles(body.UploadedFiles), QueuePolicy: body.QueuePolicy, } @@ -356,6 +359,7 @@ func RequestBodyFromProto(req *gatewayv2.ChatRequest) handler.ChatRequestBody { Message: req.GetMessage(), ExecutionMode: req.GetExecutionMode(), Workdir: req.GetWorkdir(), + CommandSafetyMode: req.GetCommandSafetyMode(), QueuePolicy: req.GetQueuePolicy(), } if selected := req.GetSelectedModel(); selected != nil { diff --git a/crates/agent-gateway/internal/handler/types.go b/crates/agent-gateway/internal/handler/types.go index 84feec87f..c554458ec 100644 --- a/crates/agent-gateway/internal/handler/types.go +++ b/crates/agent-gateway/internal/handler/types.go @@ -35,6 +35,7 @@ type ChatRequestBody struct { RuntimeControls *ChatRuntimeControlsBody `json:"runtime_controls,omitempty"` ExecutionMode string `json:"execution_mode,omitempty"` Workdir string `json:"workdir,omitempty"` + CommandSafetyMode string `json:"command_safety_mode,omitempty"` UploadedFiles []ChatUploadedFileBody `json:"uploaded_files,omitempty"` QueuePolicy string `json:"queue_policy,omitempty"` } @@ -143,6 +144,17 @@ func NormalizeWorkdir(value string) string { return normalizeTrimmedText(value) } +// NormalizeCommandSafetyMode 归一化命令安全模式。仅放行四个合法值;空串或未知值 +// 归为空串,表示"远端未指定",桌面端据此回落到本地 settings.system.commandSafetyMode。 +func NormalizeCommandSafetyMode(value string) string { + switch normalizeTrimmedText(value) { + case "ask", "auto", "sandbox", "sandboxOffline": + return normalizeTrimmedText(value) + default: + return "" + } +} + func NormalizeChatUploadedFiles(input []ChatUploadedFileBody) []ChatUploadedFileBody { out := make([]ChatUploadedFileBody, 0, len(input)) seen := make(map[string]struct{}, len(input)) diff --git a/crates/agent-gateway/internal/handler/types_test.go b/crates/agent-gateway/internal/handler/types_test.go index 9527eb6b5..d8b5109b2 100644 --- a/crates/agent-gateway/internal/handler/types_test.go +++ b/crates/agent-gateway/internal/handler/types_test.go @@ -23,6 +23,28 @@ func TestNormalizeExecutionMode(t *testing.T) { } } +func TestNormalizeCommandSafetyMode(t *testing.T) { + t.Parallel() + + // 空串/未知值归为空串(表示"远端未指定"),桌面端据此回落本地设置; + // 绝不默认成某个具体模式,以免静默下调桌面端已选的更严格模式。 + cases := map[string]string{ + "": "", + "unknown": "", + " ask ": "ask", + "ask": "ask", + "auto": "auto", + "sandbox": "sandbox", + "sandboxOffline": "sandboxOffline", + } + + for input, want := range cases { + if got := NormalizeCommandSafetyMode(input); got != want { + t.Fatalf("NormalizeCommandSafetyMode(%q) = %q, want %q", input, got, want) + } + } +} + func TestNormalizeChatSelectedModelAcceptsGemini(t *testing.T) { t.Parallel() diff --git a/crates/agent-gateway/internal/proto/v2/gateway.pb.go b/crates/agent-gateway/internal/proto/v2/gateway.pb.go index 05b0b90c6..0cdad7f8d 100644 --- a/crates/agent-gateway/internal/proto/v2/gateway.pb.go +++ b/crates/agent-gateway/internal/proto/v2/gateway.pb.go @@ -6669,8 +6669,11 @@ type ChatRequest struct { ClientRequestId string `protobuf:"bytes,8,opt,name=client_request_id,json=clientRequestId,proto3" json:"client_request_id,omitempty"` RuntimeControls *ChatRuntimeControls `protobuf:"bytes,9,opt,name=runtime_controls,json=runtimeControls,proto3" json:"runtime_controls,omitempty"` QueuePolicy string `protobuf:"bytes,10,opt,name=queue_policy,json=queuePolicy,proto3" json:"queue_policy,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // 命令安全模式(ask/auto/sandbox/sandboxOffline)。远端 WebUI 直带,桌面端据此 + // 覆盖本地 settings.system.commandSafetyMode;空串表示未指定(回落本地设置)。 + CommandSafetyMode string `protobuf:"bytes,11,opt,name=command_safety_mode,json=commandSafetyMode,proto3" json:"command_safety_mode,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *ChatRequest) Reset() { @@ -6766,6 +6769,13 @@ func (x *ChatRequest) GetQueuePolicy() string { return "" } +func (x *ChatRequest) GetCommandSafetyMode() string { + if x != nil { + return x.CommandSafetyMode + } + return "" +} + type ChatMessageRef struct { state protoimpl.MessageState `protogen:"open.v1"` SegmentIndex int32 `protobuf:"varint,1,opt,name=segment_index,json=segmentIndex,proto3" json:"segment_index,omitempty"` @@ -14571,7 +14581,7 @@ const file_proto_v2_gateway_proto_rawDesc = "" + "\vGitResponse\x12\x16\n" + "\x06action\x18\x01 \x01(\tR\x06action\x12\x1f\n" + "\vresult_json\x18\x02 \x01(\tR\n" + - "resultJson\"\xf2\x03\n" + + "resultJson\"\xa2\x04\n" + "\vChatRequest\x12'\n" + "\x0fconversation_id\x18\x01 \x01(\tR\x0econversationId\x12\x18\n" + "\amessage\x18\x02 \x01(\tR\amessage\x12N\n" + @@ -14582,7 +14592,8 @@ const file_proto_v2_gateway_proto_rawDesc = "" + "\x11client_request_id\x18\b \x01(\tR\x0fclientRequestId\x12T\n" + "\x10runtime_controls\x18\t \x01(\v2).liveagent.gateway.v2.ChatRuntimeControlsR\x0fruntimeControls\x12!\n" + "\fqueue_policy\x18\n" + - " \x01(\tR\vqueuePolicyJ\x04\b\x06\x10\aR\x15selected_system_tools\"\xcf\x01\n" + + " \x01(\tR\vqueuePolicy\x12.\n" + + "\x13command_safety_mode\x18\v \x01(\tR\x11commandSafetyModeJ\x04\b\x06\x10\aR\x15selected_system_tools\"\xcf\x01\n" + "\x0eChatMessageRef\x12#\n" + "\rsegment_index\x18\x01 \x01(\x05R\fsegmentIndex\x12#\n" + "\rmessage_index\x18\x02 \x01(\x05R\fmessageIndex\x12\x1d\n" + diff --git a/crates/agent-gateway/internal/stt/http.go b/crates/agent-gateway/internal/stt/http.go index 197976841..a256eb0f0 100644 --- a/crates/agent-gateway/internal/stt/http.go +++ b/crates/agent-gateway/internal/stt/http.go @@ -124,11 +124,14 @@ func (m *Manager) WebSocketHandler(token string) http.Handler { } }() defer func() { + // Stop the writer before cancelling the adapter. Cancel can still + // emit error/closed events; if the writer is running those frames + // race out after a protocol violation and look like a live session. + close(writerStop) + <-writerDone if activeID != "" { m.Cancel(activeID) } - close(writerStop) - <-writerDone }() windowStart := time.Now() frames := 0 @@ -172,6 +175,7 @@ func (m *Manager) WebSocketHandler(token string) http.Handler { nextSequence = 0 case *gatewayv2.SttClientFrame_Audio: if activeID == "" || payload.Audio.GetSessionId() != activeID || payload.Audio.GetSequence() != nextSequence || len(payload.Audio.GetPcm()) == 0 || len(payload.Audio.GetPcm())%2 != 0 || len(payload.Audio.GetPcm()) > 6400 || m.Send(activeID, Command{Audio: &AudioChunk{Sequence: payload.Audio.GetSequence(), PCM: append([]byte(nil), payload.Audio.GetPcm()...)}}) != nil { + _ = conn.Close() return } nextSequence++ diff --git a/crates/agent-gateway/internal/stt/stt_test.go b/crates/agent-gateway/internal/stt/stt_test.go index 0b76cbf95..79678fd47 100644 --- a/crates/agent-gateway/internal/stt/stt_test.go +++ b/crates/agent-gateway/internal/stt/stt_test.go @@ -364,8 +364,12 @@ func TestSttServerEventMappingAndWebSocketSequenceValidation(t *testing.T) { t.Fatal(err) } conn.SetReadDeadline(time.Now().Add(time.Second)) - if _, _, err := conn.ReadMessage(); err == nil { - t.Fatal("duplicate sequence must close the connection") + kind, data, err := conn.ReadMessage() + if err == nil { + t.Fatalf("duplicate sequence must close the connection, got kind=%d payload=%x", kind, data) + } + if netErr, ok := err.(net.Error); ok && netErr.Timeout() { + t.Fatal("duplicate sequence left the connection open until the read deadline") } } diff --git a/crates/agent-gateway/proto/v2/gateway.proto b/crates/agent-gateway/proto/v2/gateway.proto index f66598f19..12cff1bc3 100644 --- a/crates/agent-gateway/proto/v2/gateway.proto +++ b/crates/agent-gateway/proto/v2/gateway.proto @@ -678,6 +678,9 @@ message ChatRequest { string client_request_id = 8; ChatRuntimeControls runtime_controls = 9; string queue_policy = 10; + // 命令安全模式(ask/auto/sandbox/sandboxOffline)。远端 WebUI 直带,桌面端据此 + // 覆盖本地 settings.system.commandSafetyMode;空串表示未指定(回落本地设置)。 + string command_safety_mode = 11; } message ChatMessageRef { diff --git a/crates/agent-gateway/test/websocket/v2_chat_terminal_test.go b/crates/agent-gateway/test/websocket/v2_chat_terminal_test.go index 967bab806..7c414886e 100644 --- a/crates/agent-gateway/test/websocket/v2_chat_terminal_test.go +++ b/crates/agent-gateway/test/websocket/v2_chat_terminal_test.go @@ -30,9 +30,10 @@ func TestV2ChatCommandAcceptedFlow(t *testing.T) { ChatCommand: &gatewayv2.ChatCommandRequest{ Type: "chat.submit", Request: &gatewayv2.ChatRequest{ - ConversationId: "conv-cmd", - ClientRequestId: "client-cmd-1", - Message: "hello v2", + ConversationId: "conv-cmd", + ClientRequestId: "client-cmd-1", + Message: "hello v2", + CommandSafetyMode: "sandboxOffline", }, }, }, @@ -53,6 +54,9 @@ func TestV2ChatCommandAcceptedFlow(t *testing.T) { if command.GetType() != "chat.submit" || command.GetRequest().GetMessage() != "hello v2" { t.Fatalf("agent chat command = %#v, want chat.submit hello v2", command) } + if got := command.GetRequest().GetCommandSafetyMode(); got != "sandboxOffline" { + t.Fatalf("agent command_safety_mode = %q, want sandboxOffline", got) + } } func TestV2TerminalBrowserRequiresAgentID(t *testing.T) { diff --git a/crates/agent-gateway/test/webui/gateway-socket-client.test.mjs b/crates/agent-gateway/test/webui/gateway-socket-client.test.mjs index 7f297d663..0517c5847 100644 --- a/crates/agent-gateway/test/webui/gateway-socket-client.test.mjs +++ b/crates/agent-gateway/test/webui/gateway-socket-client.test.mjs @@ -1301,8 +1301,9 @@ test("GatewayWebSocketClient chatCommand sends the command frame and parses the clientRequestId: "req-1", queuePolicy: "append", systemSettings: { - executionMode: "agent", + executionMode: "tools", workdir: "/workspace/project", + commandSafetyMode: "sandboxOffline", }, }); const socket = await connectAndAuth(codec); @@ -1314,6 +1315,11 @@ test("GatewayWebSocketClient chatCommand sends the command frame and parses the assert.equal(command.json.chat_command.request.client_request_id, "req-1"); assert.equal(command.json.chat_command.request.queue_policy, "append"); assert.equal(command.json.chat_command.request.workdir, "/workspace/project"); + assert.equal( + command.json.chat_command.request.command_safety_mode, + "sandboxOffline", + "the WebUI must serialize the selected sandbox mode into chat_command", + ); socket.receiveBinary( codec.encodeServerFrame({ diff --git a/crates/agent-gateway/web/src/agent-ui-adapters/sandboxCapability.ts b/crates/agent-gateway/web/src/agent-ui-adapters/sandboxCapability.ts new file mode 100644 index 000000000..614295174 --- /dev/null +++ b/crates/agent-gateway/web/src/agent-ui-adapters/sandboxCapability.ts @@ -0,0 +1,13 @@ +export type SandboxCapability = { + supported: boolean; + mechanism: string; + platform: string; + /** 是否支持断网变体(sandboxOffline);由桌面端运行时探测得出。 */ + network_control: boolean; + reason?: string; +}; + +/** WebUI:沙箱在桌面端执行,浏览器侧无从探测;null 表示能力未知(由桌面端裁决)。 */ +export function useSandboxCapability(): SandboxCapability | null { + return null; +} diff --git a/crates/agent-gateway/web/src/app/GatewayAppView.tsx b/crates/agent-gateway/web/src/app/GatewayAppView.tsx index 3ee7485a6..8f3bccada 100644 --- a/crates/agent-gateway/web/src/app/GatewayAppView.tsx +++ b/crates/agent-gateway/web/src/app/GatewayAppView.tsx @@ -52,6 +52,7 @@ import type { SttProviderId } from "@/lib/settings"; import { getNextTheme, updateExecutionModeFromChatSelection, + updateSystem, updateWorkspaceResourceSettings, } from "@/lib/settings"; import { createWebSttSettingsService } from "@/lib/stt/webSttSettingsService"; @@ -785,6 +786,14 @@ export function GatewayAppView({ viewModel }: { viewModel: GatewayAppViewModel } modelOptions={modelOptions} selectedValue={selectedValue} chatRuntimeControls={chatRuntimeControlsForCurrentProvider} + commandSafetyMode={settings.system.commandSafetyMode} + onCommandSafetyModeChange={(mode) => + setSettings((prev) => + prev.system.commandSafetyMode === mode + ? prev + : updateSystem(prev, { commandSafetyMode: mode }), + ) + } reasoningOptions={chatRuntimeReasoningOptions} thinkingAlwaysOn={chatRuntimeThinkingAlwaysOn} contextUsageTokensSource={contextUsageTokensSource} diff --git a/crates/agent-gateway/web/src/app/chatEventUtils.ts b/crates/agent-gateway/web/src/app/chatEventUtils.ts index 497d0a97e..378d87d9c 100644 --- a/crates/agent-gateway/web/src/app/chatEventUtils.ts +++ b/crates/agent-gateway/web/src/app/chatEventUtils.ts @@ -103,5 +103,6 @@ export function buildGatewaySystemSettings(settings: AppSettings, workdirOverrid return { executionMode: settings.system.executionMode, workdir: workdirOverride ?? settings.system.workdir.trim(), + commandSafetyMode: settings.system.commandSafetyMode, }; } diff --git a/crates/agent-gateway/web/src/index.css b/crates/agent-gateway/web/src/index.css index f93a77a67..a68800cc1 100644 --- a/crates/agent-gateway/web/src/index.css +++ b/crates/agent-gateway/web/src/index.css @@ -88,18 +88,21 @@ inset 0 0 0 1px rgba(167, 139, 250, 0.12); } - .composer-branch-dropdown[data-state="open"] { + .composer-branch-dropdown[data-state="open"], + .composer-safety-dropdown[data-state="open"] { animation: composerReasoningDropdownIn 0.18s cubic-bezier(0.16, 1, 0.3, 1); transform-origin: var(--transform-origin, bottom left); } - .composer-branch-dropdown[data-state="closed"] { + .composer-branch-dropdown[data-state="closed"], + .composer-safety-dropdown[data-state="closed"] { animation: composerReasoningDropdownOut 0.12s cubic-bezier(0.4, 0, 1, 1) forwards; transform-origin: var(--transform-origin, bottom left); } @media (prefers-reduced-motion: reduce) { - .composer-branch-dropdown { + .composer-branch-dropdown, + .composer-safety-dropdown { animation: none !important; } diff --git a/crates/agent-gateway/web/src/lib/gatewaySocketShared.ts b/crates/agent-gateway/web/src/lib/gatewaySocketShared.ts index 0197dd268..857d28ddc 100644 --- a/crates/agent-gateway/web/src/lib/gatewaySocketShared.ts +++ b/crates/agent-gateway/web/src/lib/gatewaySocketShared.ts @@ -67,6 +67,7 @@ export type GatewayRequestOptions = { export type GatewayChatSystemSettings = { executionMode?: string; workdir?: string; + commandSafetyMode?: string; }; export type GatewayChatCommandInput = { @@ -469,6 +470,7 @@ export function buildChatCommandPayload(input: GatewayChatCommandInput) { client_request_id: clientRequestId, execution_mode: systemSettings?.executionMode?.trim() || "text", workdir: systemSettings?.workdir?.trim() || "", + command_safety_mode: systemSettings?.commandSafetyMode?.trim() || "", uploaded_files: input.uploadedFiles?.map((file) => ({ relative_path: file.relativePath, diff --git a/crates/agent-gateway/web/src/lib/gatewaySocketV2/adapters.ts b/crates/agent-gateway/web/src/lib/gatewaySocketV2/adapters.ts index 826956590..46989a9a0 100644 --- a/crates/agent-gateway/web/src/lib/gatewaySocketV2/adapters.ts +++ b/crates/agent-gateway/web/src/lib/gatewaySocketV2/adapters.ts @@ -340,6 +340,7 @@ function buildChatCommand(body: J) { : undefined, executionMode: str(inner.execution_mode), workdir: str(inner.workdir), + commandSafetyMode: str(inner.command_safety_mode), uploadedFiles: uploadedFiles.map((file) => { const raw = rec(file); return create(ChatUploadedFileSchema, { diff --git a/crates/agent-gateway/web/src/lib/proto/gen/proto/v2/gateway_pb.ts b/crates/agent-gateway/web/src/lib/proto/gen/proto/v2/gateway_pb.ts index c039e4787..0177bfe46 100644 --- a/crates/agent-gateway/web/src/lib/proto/gen/proto/v2/gateway_pb.ts +++ b/crates/agent-gateway/web/src/lib/proto/gen/proto/v2/gateway_pb.ts @@ -10,7 +10,7 @@ import type { Message } from "@bufbuild/protobuf"; * Describes the file proto/v2/gateway.proto. */ export const file_proto_v2_gateway: GenFile = /*@__PURE__*/ - fileDesc("ChZwcm90by92Mi9nYXRld2F5LnByb3RvEhRsaXZlYWdlbnQuZ2F0ZXdheS52MiKjHgoPR2F0ZXdheUVudmVsb3BlEhIKCnJlcXVlc3RfaWQYASABKAkSEQoJdGltZXN0YW1wGAIgASgDEkAKDGNoYXRfY29tbWFuZBgKIAEoCzIoLmxpdmVhZ2VudC5nYXRld2F5LnYyLkNoYXRDb21tYW5kUmVxdWVzdEgAEj4KC2Nyb25fbWFuYWdlGBQgASgLMicubGl2ZWFnZW50LmdhdGV3YXkudjIuQ3Jvbk1hbmFnZVJlcXVlc3RIABJACgxoaXN0b3J5X2xpc3QYHiABKAsyKC5saXZlYWdlbnQuZ2F0ZXdheS52Mi5IaXN0b3J5TGlzdFJlcXVlc3RIABI+CgtoaXN0b3J5X2dldBgfIAEoCzInLmxpdmVhZ2VudC5nYXRld2F5LnYyLkhpc3RvcnlHZXRSZXF1ZXN0SAASRAoOaGlzdG9yeV9yZW5hbWUYICABKAsyKi5saXZlYWdlbnQuZ2F0ZXdheS52Mi5IaXN0b3J5UmVuYW1lUmVxdWVzdEgAEkQKDmhpc3RvcnlfZGVsZXRlGCEgASgLMioubGl2ZWFnZW50LmdhdGV3YXkudjIuSGlzdG9yeURlbGV0ZVJlcXVlc3RIABJECg5oaXN0b3J5X3ByZWZpeBgiIAEoCzIqLmxpdmVhZ2VudC5nYXRld2F5LnYyLkhpc3RvcnlQcmVmaXhSZXF1ZXN0SAASPgoLaGlzdG9yeV9waW4YIyABKAsyJy5saXZlYWdlbnQuZ2F0ZXdheS52Mi5IaXN0b3J5UGluUmVxdWVzdEgAEkkKEWhpc3Rvcnlfc2hhcmVfZ2V0GCQgASgLMiwubGl2ZWFnZW50LmdhdGV3YXkudjIuSGlzdG9yeVNoYXJlR2V0UmVxdWVzdEgAEkkKEWhpc3Rvcnlfc2hhcmVfc2V0GCUgASgLMiwubGl2ZWFnZW50LmdhdGV3YXkudjIuSGlzdG9yeVNoYXJlU2V0UmVxdWVzdEgAElEKFWhpc3Rvcnlfc2hhcmVfcmVzb2x2ZRgmIAEoCzIwLmxpdmVhZ2VudC5nYXRld2F5LnYyLkhpc3RvcnlTaGFyZVJlc29sdmVSZXF1ZXN0SAASSAoQaGlzdG9yeV93b3JrZGlycxgnIAEoCzIsLmxpdmVhZ2VudC5nYXRld2F5LnYyLkhpc3RvcnlXb3JrZGlyc1JlcXVlc3RIABJCCg1wcm92aWRlcl9saXN0GCggASgLMikubGl2ZWFnZW50LmdhdGV3YXkudjIuUHJvdmlkZXJMaXN0UmVxdWVzdEgAEkAKDHNldHRpbmdzX2dldBgpIAEoCzIoLmxpdmVhZ2VudC5nYXRld2F5LnYyLlNldHRpbmdzR2V0UmVxdWVzdEgAEkYKD3NldHRpbmdzX3VwZGF0ZRgqIAEoCzIrLmxpdmVhZ2VudC5nYXRld2F5LnYyLlNldHRpbmdzVXBkYXRlUmVxdWVzdEgAEkcKEHNraWxsX2ZpbGVzX2xpc3QYKyABKAsyKy5saXZlYWdlbnQuZ2F0ZXdheS52Mi5Ta2lsbEZpbGVzTGlzdFJlcXVlc3RIABJNChNza2lsbF9tZXRhZGF0YV9yZWFkGCwgASgLMi4ubGl2ZWFnZW50LmdhdGV3YXkudjIuU2tpbGxNZXRhZGF0YVJlYWRSZXF1ZXN0SAASRQoPc2tpbGxfdGV4dF9yZWFkGC0gASgLMioubGl2ZWFnZW50LmdhdGV3YXkudjIuU2tpbGxUZXh0UmVhZFJlcXVlc3RIABJJChFmaWxlX21lbnRpb25fbGlzdBguIAEoCzIsLmxpdmVhZ2VudC5nYXRld2F5LnYyLkZpbGVNZW50aW9uTGlzdFJlcXVlc3RIABJRChV1cGxvYWRfcmVhZGFibGVfZmlsZXMYLyABKAsyMC5saXZlYWdlbnQuZ2F0ZXdheS52Mi5VcGxvYWRSZWFkYWJsZUZpbGVzUmVxdWVzdEgAEjgKCGZzX3Jvb3RzGDAgASgLMiQubGl2ZWFnZW50LmdhdGV3YXkudjIuRnNSb290c1JlcXVlc3RIABI/Cgxmc19saXN0X2RpcnMYMSABKAsyJy5saXZlYWdlbnQuZ2F0ZXdheS52Mi5Gc0xpc3REaXJzUmVxdWVzdEgAEjEKBHBpbmcYMiABKAsyIS5saXZlYWdlbnQuZ2F0ZXdheS52Mi5QaW5nUmVxdWVzdEgAElMKFnVwbG9hZGVkX2ltYWdlX3ByZXZpZXcYMyABKAsyMS5saXZlYWdlbnQuZ2F0ZXdheS52Mi5VcGxvYWRlZEltYWdlUHJldmlld1JlcXVlc3RIABJCCg1tZW1vcnlfbWFuYWdlGDQgASgLMikubGl2ZWFnZW50LmdhdGV3YXkudjIuTWVtb3J5TWFuYWdlUmVxdWVzdEgAEkAKDHNraWxsX21hbmFnZRg1IAEoCzIoLmxpdmVhZ2VudC5nYXRld2F5LnYyLlNraWxsTWFuYWdlUmVxdWVzdEgAElYKGGZzX2NyZWF0ZV9wcm9qZWN0X2ZvbGRlchg2IAEoCzIyLmxpdmVhZ2VudC5nYXRld2F5LnYyLkZzQ3JlYXRlUHJvamVjdEZvbGRlclJlcXVlc3RIABJBChB0ZXJtaW5hbF9yZXF1ZXN0GDcgASgLMiUubGl2ZWFnZW50LmdhdGV3YXkudjIuVGVybWluYWxSZXF1ZXN0SAASNgoHZnNfbGlzdBg4IAEoCzIjLmxpdmVhZ2VudC5nYXRld2F5LnYyLkZzTGlzdFJlcXVlc3RIABJBCg1mc193cml0ZV90ZXh0GDkgASgLMigubGl2ZWFnZW50LmdhdGV3YXkudjIuRnNXcml0ZVRleHRSZXF1ZXN0SAASQQoNZnNfY3JlYXRlX2Rpchg6IAEoCzIoLmxpdmVhZ2VudC5nYXRld2F5LnYyLkZzQ3JlYXRlRGlyUmVxdWVzdEgAEjoKCWZzX3JlbmFtZRg7IAEoCzIlLmxpdmVhZ2VudC5nYXRld2F5LnYyLkZzUmVuYW1lUmVxdWVzdEgAEjoKCWZzX2RlbGV0ZRg8IAEoCzIlLmxpdmVhZ2VudC5nYXRld2F5LnYyLkZzRGVsZXRlUmVxdWVzdEgAEjcKC2dpdF9yZXF1ZXN0GD0gASgLMiAubGl2ZWFnZW50LmdhdGV3YXkudjIuR2l0UmVxdWVzdEgAElAKFWZzX3JlYWRfZWRpdGFibGVfdGV4dBg+IAEoCzIvLmxpdmVhZ2VudC5nYXRld2F5LnYyLkZzUmVhZEVkaXRhYmxlVGV4dFJlcXVlc3RIABJUChdmc19yZWFkX3dvcmtzcGFjZV9pbWFnZRg/IAEoCzIxLmxpdmVhZ2VudC5nYXRld2F5LnYyLkZzUmVhZFdvcmtzcGFjZUltYWdlUmVxdWVzdEgAEjkKDHNmdHBfcmVxdWVzdBhAIAEoCzIhLmxpdmVhZ2VudC5nYXRld2F5LnYyLlNmdHBSZXF1ZXN0SAASRgoPcHJvdmlkZXJfbW9kZWxzGEEgASgLMisubGl2ZWFnZW50LmdhdGV3YXkudjIuUHJvdmlkZXJNb2RlbHNSZXF1ZXN0SAASXwodc2V0dGluZ3NfcmVzZXRfc3NoX2tub3duX2hvc3QYSCABKAsyNi5saXZlYWdlbnQuZ2F0ZXdheS52Mi5TZXR0aW5nc1Jlc2V0U3NoS25vd25Ib3N0UmVxdWVzdEgAEjwKCmNoYXRfcXVldWUYSSABKAsyJi5saXZlYWdlbnQuZ2F0ZXdheS52Mi5DaGF0UXVldWVSZXF1ZXN0SAASQAoQY2hhdF9pbmdyZXNzX2FjaxhLIAEoCzIkLmxpdmVhZ2VudC5nYXRld2F5LnYyLkNoYXRJbmdyZXNzQWNrSAASQQoMdHVubmVsX3N0YXRlGFAgASgLMikubGl2ZWFnZW50LmdhdGV3YXkudjIuVHVubmVsU3RhdGVTbmFwc2hvdEgAEj8KD3R1bm5lbF9tdXRhdGlvbhhRIAEoCzIkLmxpdmVhZ2VudC5nYXRld2F5LnYyLlR1bm5lbE11dGF0aW9uSAASOQoMdHVubmVsX2ZyYW1lGFIgASgLMiEubGl2ZWFnZW50LmdhdGV3YXkudjIuVHVubmVsRnJhbWVIABJGCg93b3Jrc3BhY2Vfd2F0Y2gYWiABKAsyKy5saXZlYWdlbnQuZ2F0ZXdheS52Mi5Xb3Jrc3BhY2VXYXRjaFJlcXVlc3RIABJOChdtYW5hZ2VkX3Byb2Nlc3NfcmVxdWVzdBhbIAEoCzIrLmxpdmVhZ2VudC5nYXRld2F5LnYyLk1hbmFnZWRQcm9jZXNzUmVxdWVzdEgAEkQKDmhpc3RvcnlfYnJhbmNoGFwgASgLMioubGl2ZWFnZW50LmdhdGV3YXkudjIuSGlzdG9yeUJyYW5jaFJlcXVlc3RIABJECg5wcm92aWRlcl91c2FnZRhdIAEoCzIqLmxpdmVhZ2VudC5nYXRld2F5LnYyLlByb3ZpZGVyVXNhZ2VSZXF1ZXN0SAASQwoOY2hhdF9maWxlX29wZW4YXiABKAsyKS5saXZlYWdlbnQuZ2F0ZXdheS52Mi5DaGF0RmlsZU9wZW5SZXF1ZXN0SAASRQoPaGlzdG9yeV9zZXRfY3dkGF8gASgLMioubGl2ZWFnZW50LmdhdGV3YXkudjIuSGlzdG9yeVNldEN3ZFJlcXVlc3RIABJRChV3b3Jrc3BhY2Vfcm9vdF9ncmFudHMYYCABKAsyMC5saXZlYWdlbnQuZ2F0ZXdheS52Mi5Xb3Jrc3BhY2VSb290R3JhbnRzUmVxdWVzdEgAEkgKEGltcG9ydF9kaXJlY3RvcnkYYSABKAsyLC5saXZlYWdlbnQuZ2F0ZXdheS52Mi5JbXBvcnREaXJlY3RvcnlSZXF1ZXN0SAASPQoKY2hlY2twb2ludBhiIAEoCzInLmxpdmVhZ2VudC5nYXRld2F5LnYyLkNoZWNrcG9pbnRSZXF1ZXN0SAASSAoQdHJhamVjdG9yeV9mZXRjaBhjIAEoCzIsLmxpdmVhZ2VudC5nYXRld2F5LnYyLlRyYWplY3RvcnlGZXRjaFJlcXVlc3RIAEIJCgdwYXlsb2FkSgQIQxBESgQIRBBFSgQIRRBGSgQIShBLIqUnCg1BZ2VudEVudmVsb3BlEhIKCnJlcXVlc3RfaWQYASABKAkSEQoJdGltZXN0YW1wGAIgASgDEjUKCmNoYXRfZXZlbnQYCiABKAsyHy5saXZlYWdlbnQuZ2F0ZXdheS52Mi5DaGF0RXZlbnRIABJEChBjcm9uX21hbmFnZV9yZXNwGBQgASgLMigubGl2ZWFnZW50LmdhdGV3YXkudjIuQ3Jvbk1hbmFnZVJlc3BvbnNlSAASRgoRaGlzdG9yeV9saXN0X3Jlc3AYHiABKAsyKS5saXZlYWdlbnQuZ2F0ZXdheS52Mi5IaXN0b3J5TGlzdFJlc3BvbnNlSAASRAoQaGlzdG9yeV9nZXRfcmVzcBgfIAEoCzIoLmxpdmVhZ2VudC5nYXRld2F5LnYyLkhpc3RvcnlHZXRSZXNwb25zZUgAEkoKE2hpc3RvcnlfcmVuYW1lX3Jlc3AYICABKAsyKy5saXZlYWdlbnQuZ2F0ZXdheS52Mi5IaXN0b3J5UmVuYW1lUmVzcG9uc2VIABJKChNoaXN0b3J5X2RlbGV0ZV9yZXNwGCEgASgLMisubGl2ZWFnZW50LmdhdGV3YXkudjIuSGlzdG9yeURlbGV0ZVJlc3BvbnNlSAASPgoMaGlzdG9yeV9zeW5jGCIgASgLMiYubGl2ZWFnZW50LmdhdGV3YXkudjIuSGlzdG9yeVN5bmNFdmVudEgAEkoKE2hpc3RvcnlfcHJlZml4X3Jlc3AYIyABKAsyKy5saXZlYWdlbnQuZ2F0ZXdheS52Mi5IaXN0b3J5UHJlZml4UmVzcG9uc2VIABJEChBoaXN0b3J5X3Bpbl9yZXNwGCQgASgLMigubGl2ZWFnZW50LmdhdGV3YXkudjIuSGlzdG9yeVBpblJlc3BvbnNlSAASTwoWaGlzdG9yeV9zaGFyZV9nZXRfcmVzcBglIAEoCzItLmxpdmVhZ2VudC5nYXRld2F5LnYyLkhpc3RvcnlTaGFyZUdldFJlc3BvbnNlSAASTwoWaGlzdG9yeV9zaGFyZV9zZXRfcmVzcBgmIAEoCzItLmxpdmVhZ2VudC5nYXRld2F5LnYyLkhpc3RvcnlTaGFyZVNldFJlc3BvbnNlSAASVwoaaGlzdG9yeV9zaGFyZV9yZXNvbHZlX3Jlc3AYJyABKAsyMS5saXZlYWdlbnQuZ2F0ZXdheS52Mi5IaXN0b3J5U2hhcmVSZXNvbHZlUmVzcG9uc2VIABJOChVoaXN0b3J5X3dvcmtkaXJzX3Jlc3AYOCABKAsyLS5saXZlYWdlbnQuZ2F0ZXdheS52Mi5IaXN0b3J5V29ya2RpcnNSZXNwb25zZUgAEkgKEnByb3ZpZGVyX2xpc3RfcmVzcBgoIAEoCzIqLmxpdmVhZ2VudC5nYXRld2F5LnYyLlByb3ZpZGVyTGlzdFJlc3BvbnNlSAASRgoRc2V0dGluZ3NfZ2V0X3Jlc3AYKSABKAsyKS5saXZlYWdlbnQuZ2F0ZXdheS52Mi5TZXR0aW5nc0dldFJlc3BvbnNlSAASTAoUc2V0dGluZ3NfdXBkYXRlX3Jlc3AYKiABKAsyLC5saXZlYWdlbnQuZ2F0ZXdheS52Mi5TZXR0aW5nc1VwZGF0ZVJlc3BvbnNlSAASQAoNc2V0dGluZ3Nfc3luYxgrIAEoCzInLmxpdmVhZ2VudC5nYXRld2F5LnYyLlNldHRpbmdzU3luY0V2ZW50SAASTQoVc2tpbGxfZmlsZXNfbGlzdF9yZXNwGCwgASgLMiwubGl2ZWFnZW50LmdhdGV3YXkudjIuU2tpbGxGaWxlc0xpc3RSZXNwb25zZUgAElMKGHNraWxsX21ldGFkYXRhX3JlYWRfcmVzcBgtIAEoCzIvLmxpdmVhZ2VudC5nYXRld2F5LnYyLlNraWxsTWV0YWRhdGFSZWFkUmVzcG9uc2VIABJLChRza2lsbF90ZXh0X3JlYWRfcmVzcBguIAEoCzIrLmxpdmVhZ2VudC5nYXRld2F5LnYyLlNraWxsVGV4dFJlYWRSZXNwb25zZUgAEk8KFmZpbGVfbWVudGlvbl9saXN0X3Jlc3AYLyABKAsyLS5saXZlYWdlbnQuZ2F0ZXdheS52Mi5GaWxlTWVudGlvbkxpc3RSZXNwb25zZUgAElcKGnVwbG9hZF9yZWFkYWJsZV9maWxlc19yZXNwGDAgASgLMjEubGl2ZWFnZW50LmdhdGV3YXkudjIuVXBsb2FkUmVhZGFibGVGaWxlc1Jlc3BvbnNlSAASPgoNZnNfcm9vdHNfcmVzcBgxIAEoCzIlLmxpdmVhZ2VudC5nYXRld2F5LnYyLkZzUm9vdHNSZXNwb25zZUgAEjIKBHBvbmcYMiABKAsyIi5saXZlYWdlbnQuZ2F0ZXdheS52Mi5Qb25nUmVzcG9uc2VIABJFChFmc19saXN0X2RpcnNfcmVzcBgzIAEoCzIoLmxpdmVhZ2VudC5nYXRld2F5LnYyLkZzTGlzdERpcnNSZXNwb25zZUgAElkKG3VwbG9hZGVkX2ltYWdlX3ByZXZpZXdfcmVzcBg0IAEoCzIyLmxpdmVhZ2VudC5nYXRld2F5LnYyLlVwbG9hZGVkSW1hZ2VQcmV2aWV3UmVzcG9uc2VIABJIChJtZW1vcnlfbWFuYWdlX3Jlc3AYNSABKAsyKi5saXZlYWdlbnQuZ2F0ZXdheS52Mi5NZW1vcnlNYW5hZ2VSZXNwb25zZUgAEkYKEXNraWxsX21hbmFnZV9yZXNwGDYgASgLMikubGl2ZWFnZW50LmdhdGV3YXkudjIuU2tpbGxNYW5hZ2VSZXNwb25zZUgAElwKHWZzX2NyZWF0ZV9wcm9qZWN0X2ZvbGRlcl9yZXNwGDcgASgLMjMubGl2ZWFnZW50LmdhdGV3YXkudjIuRnNDcmVhdGVQcm9qZWN0Rm9sZGVyUmVzcG9uc2VIABJDChF0ZXJtaW5hbF9yZXNwb25zZRg5IAEoCzImLmxpdmVhZ2VudC5nYXRld2F5LnYyLlRlcm1pbmFsUmVzcG9uc2VIABI9Cg50ZXJtaW5hbF9ldmVudBg6IAEoCzIjLmxpdmVhZ2VudC5nYXRld2F5LnYyLlRlcm1pbmFsRXZlbnRIABI8Cgxmc19saXN0X3Jlc3AYOyABKAsyJC5saXZlYWdlbnQuZ2F0ZXdheS52Mi5Gc0xpc3RSZXNwb25zZUgAEkcKEmZzX3dyaXRlX3RleHRfcmVzcBg8IAEoCzIpLmxpdmVhZ2VudC5nYXRld2F5LnYyLkZzV3JpdGVUZXh0UmVzcG9uc2VIABJHChJmc19jcmVhdGVfZGlyX3Jlc3AYPSABKAsyKS5saXZlYWdlbnQuZ2F0ZXdheS52Mi5Gc0NyZWF0ZURpclJlc3BvbnNlSAASQAoOZnNfcmVuYW1lX3Jlc3AYPiABKAsyJi5saXZlYWdlbnQuZ2F0ZXdheS52Mi5Gc1JlbmFtZVJlc3BvbnNlSAASQAoOZnNfZGVsZXRlX3Jlc3AYPyABKAsyJi5saXZlYWdlbnQuZ2F0ZXdheS52Mi5Gc0RlbGV0ZVJlc3BvbnNlSAASOQoMZ2l0X3Jlc3BvbnNlGEAgASgLMiEubGl2ZWFnZW50LmdhdGV3YXkudjIuR2l0UmVzcG9uc2VIABJWChpmc19yZWFkX2VkaXRhYmxlX3RleHRfcmVzcBhBIAEoCzIwLmxpdmVhZ2VudC5nYXRld2F5LnYyLkZzUmVhZEVkaXRhYmxlVGV4dFJlc3BvbnNlSAASWgocZnNfcmVhZF93b3Jrc3BhY2VfaW1hZ2VfcmVzcBhCIAEoCzIyLmxpdmVhZ2VudC5nYXRld2F5LnYyLkZzUmVhZFdvcmtzcGFjZUltYWdlUmVzcG9uc2VIABI7Cg1zZnRwX3Jlc3BvbnNlGEkgASgLMiIubGl2ZWFnZW50LmdhdGV3YXkudjIuU2Z0cFJlc3BvbnNlSAASNQoKc2Z0cF9ldmVudBhKIAEoCzIfLmxpdmVhZ2VudC5nYXRld2F5LnYyLlNmdHBFdmVudEgAEkIKD2NoYXRfcXVldWVfcmVzcBhLIAEoCzInLmxpdmVhZ2VudC5nYXRld2F5LnYyLkNoYXRRdWV1ZVJlc3BvbnNlSAASQAoQY2hhdF9xdWV1ZV9ldmVudBhMIAEoCzIkLmxpdmVhZ2VudC5nYXRld2F5LnYyLkNoYXRRdWV1ZUV2ZW50SAASPgoMY2hhdF9jb250cm9sGEYgASgLMiYubGl2ZWFnZW50LmdhdGV3YXkudjIuQ2hhdENvbnRyb2xFdmVudEgAEkIKDnJ1bnRpbWVfc3RhdHVzGEcgASgLMigubGl2ZWFnZW50LmdhdGV3YXkudjIuUnVudGltZVN0YXR1c0V2ZW50SAASZQoic2V0dGluZ3NfcmVzZXRfc3NoX2tub3duX2hvc3RfcmVzcBhIIAEoCzI3LmxpdmVhZ2VudC5nYXRld2F5LnYyLlNldHRpbmdzUmVzZXRTc2hLbm93bkhvc3RSZXNwb25zZUgAEkoKFWNoYXRfcnVudGltZV9zbmFwc2hvdBhNIAEoCzIpLmxpdmVhZ2VudC5nYXRld2F5LnYyLkNoYXRSdW50aW1lU25hcHNob3RIABJMChRwcm92aWRlcl9tb2RlbHNfcmVzcBhPIAEoCzIsLmxpdmVhZ2VudC5nYXRld2F5LnYyLlByb3ZpZGVyTW9kZWxzUmVzcG9uc2VIABJCCg50dW5uZWxfZGVzaXJlZBhQIAEoCzIoLmxpdmVhZ2VudC5nYXRld2F5LnYyLlR1bm5lbERlc2lyZWRTdGF0ZUgAEkwKFnR1bm5lbF9tdXRhdGlvbl9yZXN1bHQYUSABKAsyKi5saXZlYWdlbnQuZ2F0ZXdheS52Mi5UdW5uZWxNdXRhdGlvblJlc3VsdEgAEjkKDHR1bm5lbF9mcmFtZRhSIAEoCzIhLmxpdmVhZ2VudC5nYXRld2F5LnYyLlR1bm5lbEZyYW1lSAASRgoTdHVubmVsX3Byb2JlX3JlcG9ydBhTIAEoCzInLmxpdmVhZ2VudC5nYXRld2F5LnYyLlR1bm5lbFByb2JlUmVwb3J0SAASSgoSd29ya3NwYWNlX2FjdGl2aXR5GFogASgLMiwubGl2ZWFnZW50LmdhdGV3YXkudjIuV29ya3NwYWNlQWN0aXZpdHlFdmVudEgAElAKGG1hbmFnZWRfcHJvY2Vzc19yZXNwb25zZRhbIAEoCzIsLmxpdmVhZ2VudC5nYXRld2F5LnYyLk1hbmFnZWRQcm9jZXNzUmVzcG9uc2VIABJQChhtYW5hZ2VkX3Byb2Nlc3Nfc25hcHNob3QYXCABKAsyLC5saXZlYWdlbnQuZ2F0ZXdheS52Mi5NYW5hZ2VkUHJvY2Vzc1NuYXBzaG90SAASSgoTaGlzdG9yeV9icmFuY2hfcmVzcBhdIAEoCzIrLmxpdmVhZ2VudC5nYXRld2F5LnYyLkhpc3RvcnlCcmFuY2hSZXNwb25zZUgAEkoKE3Byb3ZpZGVyX3VzYWdlX3Jlc3AYXiABKAsyKy5saXZlYWdlbnQuZ2F0ZXdheS52Mi5Qcm92aWRlclVzYWdlUmVzcG9uc2VIABJEChJjaGF0X2luZ3Jlc3NfYmF0Y2gYXyABKAsyJi5saXZlYWdlbnQuZ2F0ZXdheS52Mi5DaGF0SW5ncmVzc0JhdGNoSAASRgoTY2hhdF9pbmdyZXNzX3Jlc3VtZRhgIAEoCzInLmxpdmVhZ2VudC5nYXRld2F5LnYyLkNoYXRJbmdyZXNzUmVzdW1lSAASSgoVY2hhdF9pbmdyZXNzX2ZyYWdtZW50GGEgASgLMikubGl2ZWFnZW50LmdhdGV3YXkudjIuQ2hhdEluZ3Jlc3NGcmFnbWVudEgAEkkKE2NoYXRfZmlsZV9vcGVuX3Jlc3AYYiABKAsyKi5saXZlYWdlbnQuZ2F0ZXdheS52Mi5DaGF0RmlsZU9wZW5SZXNwb25zZUgAEjQKBWVycm9yGGMgASgLMiMubGl2ZWFnZW50LmdhdGV3YXkudjIuRXJyb3JSZXNwb25zZUgAEksKFGhpc3Rvcnlfc2V0X2N3ZF9yZXNwGGQgASgLMisubGl2ZWFnZW50LmdhdGV3YXkudjIuSGlzdG9yeVNldEN3ZFJlc3BvbnNlSAASVwoad29ya3NwYWNlX3Jvb3RfZ3JhbnRzX3Jlc3AYZSABKAsyMS5saXZlYWdlbnQuZ2F0ZXdheS52Mi5Xb3Jrc3BhY2VSb290R3JhbnRzUmVzcG9uc2VIABJOChVpbXBvcnRfZGlyZWN0b3J5X3Jlc3AYZiABKAsyLS5saXZlYWdlbnQuZ2F0ZXdheS52Mi5JbXBvcnREaXJlY3RvcnlSZXNwb25zZUgAEkMKD2NoZWNrcG9pbnRfcmVzcBhnIAEoCzIoLmxpdmVhZ2VudC5nYXRld2F5LnYyLkNoZWNrcG9pbnRSZXNwb25zZUgAEk4KFXRyYWplY3RvcnlfZmV0Y2hfcmVzcBhoIAEoCzItLmxpdmVhZ2VudC5nYXRld2F5LnYyLlRyYWplY3RvcnlGZXRjaFJlc3BvbnNlSABCCQoHcGF5bG9hZEoECEMQREoECEQQRUoECEUQRkoECE4QTyJVChFDaGF0U2VsZWN0ZWRNb2RlbBIaChJjdXN0b21fcHJvdmlkZXJfaWQYASABKAkSDQoFbW9kZWwYAiABKAkSFQoNcHJvdmlkZXJfdHlwZRgDIAEoCSJlChNDaGF0UnVudGltZUNvbnRyb2xzEhgKEHRoaW5raW5nX2VuYWJsZWQYASABKAgSIQoZbmF0aXZlX3dlYl9zZWFyY2hfZW5hYmxlZBgCIAEoCBIRCglyZWFzb25pbmcYAyABKAkidQoQQ2hhdFVwbG9hZGVkRmlsZRIVCg1yZWxhdGl2ZV9wYXRoGAEgASgJEhUKDWFic29sdXRlX3BhdGgYAiABKAkSEQoJZmlsZV9uYW1lGAMgASgJEgwKBGtpbmQYBCABKAkSEgoKc2l6ZV9ieXRlcxgFIAEoAyJLChJVcGxvYWRSZWFkYWJsZUZpbGUSEQoJZmlsZV9uYW1lGAEgASgJEhEKCW1pbWVfdHlwZRgCIAEoCRIPCgdjb250ZW50GAMgASgMImYKGlVwbG9hZFJlYWRhYmxlRmlsZXNSZXF1ZXN0Eg8KB3dvcmtkaXIYASABKAkSNwoFZmlsZXMYAiADKAsyKC5saXZlYWdlbnQuZ2F0ZXdheS52Mi5VcGxvYWRSZWFkYWJsZUZpbGUiZQobVXBsb2FkUmVhZGFibGVGaWxlc1Jlc3BvbnNlEjUKBWZpbGVzGAEgAygLMiYubGl2ZWFnZW50LmdhdGV3YXkudjIuQ2hhdFVwbG9hZGVkRmlsZRIPCgdza2lwcGVkGAIgAygJIj0KE0ltcG9ydERpcmVjdG9yeUZpbGUSFQoNcmVsYXRpdmVfcGF0aBgBIAEoCRIPCgdjb250ZW50GAIgASgMIsMCChZJbXBvcnREaXJlY3RvcnlSZXF1ZXN0EgwKBG5hbWUYASABKAkSDgoGdGFyZ2V0GAIgASgJEjwKBWZpbGVzGAMgAygLMikubGl2ZWFnZW50LmdhdGV3YXkudjIuSW1wb3J0RGlyZWN0b3J5RmlsZUICGAESEwoLdHJhbnNmZXJfaWQYBCABKAkSQQoJb3BlcmF0aW9uGAUgASgOMi4ubGl2ZWFnZW50LmdhdGV3YXkudjIuSW1wb3J0RGlyZWN0b3J5T3BlcmF0aW9uEhMKC3RvdGFsX2ZpbGVzGAYgASgNEhMKC3RvdGFsX2J5dGVzGAcgASgEEhUKDXJlbGF0aXZlX3BhdGgYCCABKAkSDgoGb2Zmc2V0GAkgASgEEg0KBWNodW5rGAogASgMEhUKDWZpbGVfY29tcGxldGUYCyABKAgifgoXSW1wb3J0RGlyZWN0b3J5UmVzcG9uc2USEQoJcm9vdF9wYXRoGAEgASgJEhIKCmZpbGVfY291bnQYAiABKAUSDwoHc2tpcHBlZBgDIAMoCRITCgt0cmFuc2Zlcl9pZBgEIAEoCRIWCg5yZWNlaXZlZF9ieXRlcxgFIAEoBCJFChtVcGxvYWRlZEltYWdlUHJldmlld1JlcXVlc3QSDwoHd29ya2RpchgBIAEoCRIVCg1hYnNvbHV0ZV9wYXRoGAIgASgJIj8KHFVwbG9hZGVkSW1hZ2VQcmV2aWV3UmVzcG9uc2USEQoJbWltZV90eXBlGAEgASgJEgwKBGRhdGEYAiABKAkiewoKVHVubmVsU3BlYxIKCgJpZBgBIAEoCRIRCglzbHVnX2hpbnQYAiABKAkSDAoEbmFtZRgDIAEoCRISCgp0YXJnZXRfdXJsGAQgASgJEhIKCmV4cGlyZXNfYXQYBSABKAMSGAoQcHJvamVjdF9wYXRoX2tleRgGIAEoCSJZChJUdW5uZWxEZXNpcmVkU3RhdGUSMQoHdHVubmVscxgBIAMoCzIgLmxpdmVhZ2VudC5nYXRld2F5LnYyLlR1bm5lbFNwZWMSEAoIcmV2aXNpb24YAiABKAQiZgoMVHVubmVsSGVhbHRoEg4KBnN0YXR1cxgBIAEoCRITCgtodHRwX3N0YXR1cxgCIAEoDRINCgVlcnJvchgDIAEoCRISCgpjaGVja2VkX2F0GAQgASgDEg4KBnJ0dF9tcxgFIAEoDSLwAQoMVHVubmVsU3RhdHVzEgoKAmlkGAEgASgJEgwKBHNsdWcYAiABKAkSDAoEbmFtZRgDIAEoCRISCgp0YXJnZXRfdXJsGAQgASgJEhMKC3B1YmxpY19wYXRoGAUgASgJEhIKCmNyZWF0ZWRfYXQYBiABKAMSEgoKZXhwaXJlc19hdBgHIAEoAxIaChJhY3RpdmVfY29ubmVjdGlvbnMYCCABKA0SGAoQcHJvamVjdF9wYXRoX2tleRgJIAEoCRIxCgVsb2NhbBgKIAEoCzIiLmxpdmVhZ2VudC5nYXRld2F5LnYyLlR1bm5lbEhlYWx0aCKlAQoTVHVubmVsU3RhdGVTbmFwc2hvdBIzCgd0dW5uZWxzGAEgAygLMiIubGl2ZWFnZW50LmdhdGV3YXkudjIuVHVubmVsU3RhdHVzEhAKCHJldmlzaW9uGAIgASgEEhQKDGFnZW50X29ubGluZRgDIAEoCBIxCgVyZWxheRgEIAEoCzIiLmxpdmVhZ2VudC5nYXRld2F5LnYyLlR1bm5lbEhlYWx0aCKZAQoOVHVubmVsTXV0YXRpb24SDgoGYWN0aW9uGAEgASgJEhEKCXR1bm5lbF9pZBgCIAEoCRISCgp0YXJnZXRfdXJsGAMgASgJEgwKBG5hbWUYBCABKAkSGAoLdHRsX3NlY29uZHMYBSABKA1IAIgBARIYChBwcm9qZWN0X3BhdGhfa2V5GAYgASgJQg4KDF90dGxfc2Vjb25kcyJUChRUdW5uZWxNdXRhdGlvblJlc3VsdBIRCgl0dW5uZWxfaWQYASABKAkSEgoKZXJyb3JfY29kZRgCIAEoCRIVCg1lcnJvcl9tZXNzYWdlGAMgASgJIlkKEVR1bm5lbFByb2JlUmVzdWx0EhEKCXR1bm5lbF9pZBgBIAEoCRIxCgVsb2NhbBgCIAEoCzIiLmxpdmVhZ2VudC5nYXRld2F5LnYyLlR1bm5lbEhlYWx0aCJNChFUdW5uZWxQcm9iZVJlcG9ydBI4CgdyZXN1bHRzGAEgAygLMicubGl2ZWFnZW50LmdhdGV3YXkudjIuVHVubmVsUHJvYmVSZXN1bHQiKwoMVHVubmVsSGVhZGVyEgwKBG5hbWUYASABKAkSDQoFdmFsdWUYAiABKAki9QIKC1R1bm5lbEZyYW1lEhEKCXN0cmVhbV9pZBgBIAEoCRIzCgRraW5kGAIgASgOMiUubGl2ZWFnZW50LmdhdGV3YXkudjIuVHVubmVsRnJhbWVLaW5kEhIKCnRhcmdldF91cmwYAyABKAkSDgoGbWV0aG9kGAQgASgJEgwKBHBhdGgYBSABKAkSMwoHaGVhZGVycxgGIAMoCzIiLmxpdmVhZ2VudC5nYXRld2F5LnYyLlR1bm5lbEhlYWRlchIOCgZzdGF0dXMYByABKA0SDAoEYm9keRgIIAEoDBINCgVlcnJvchgJIAEoCRJCCg93c19tZXNzYWdlX3R5cGUYCiABKA4yKS5saXZlYWdlbnQuZ2F0ZXdheS52Mi5UdW5uZWxXc01lc3NhZ2VUeXBlEhYKDndzX3N1YnByb3RvY29sGAsgASgJEhUKDXdzX2Nsb3NlX2NvZGUYDCABKA0SFwoPd3NfY2xvc2VfcmVhc29uGA0gASgJIikKFVdvcmtzcGFjZVdhdGNoUmVxdWVzdBIQCgh3b3JrZGlycxgBIAMoCSJ+ChZXb3Jrc3BhY2VBY3Rpdml0eUV2ZW50Eg8KB3dvcmtkaXIYASABKAkSEAoIcmV2aXNpb24YAiABKAQSCgoCZnMYAyABKAgSCwoDZ2l0GAQgASgIEhUKDWNoYW5nZWRfcGF0aHMYBSADKAkSEQoJdHJ1bmNhdGVkGAYgASgIIpYCChRNYW5hZ2VkUHJvY2Vzc1JlY29yZBIKCgJpZBgBIAEoCRINCgVsYWJlbBgCIAEoCRIPCgdjb21tYW5kGAMgASgJEgsKA2N3ZBgEIAEoCRINCgVzaGVsbBgFIAEoCRILCgNwaWQYBiABKA0SEAoIbG9nX3BhdGgYByABKAkSEgoKc3RhcnRlZF9hdBgIIAEoAxIYCgtmaW5pc2hlZF9hdBgJIAEoA0gAiAEBEhYKCWV4aXRfY29kZRgKIAEoBUgBiAEBEg8KB3J1bm5pbmcYCyABKAgSEAoIaXNvbGF0ZWQYDCABKAgSEAoIcmVzdG9yZWQYDSABKAhCDgoMX2ZpbmlzaGVkX2F0QgwKCl9leGl0X2NvZGUiaQoWTWFuYWdlZFByb2Nlc3NTbmFwc2hvdBI9Cglwcm9jZXNzZXMYASADKAsyKi5saXZlYWdlbnQuZ2F0ZXdheS52Mi5NYW5hZ2VkUHJvY2Vzc1JlY29yZBIQCghyZXZpc2lvbhgCIAEoBCJOChVNYW5hZ2VkUHJvY2Vzc1JlcXVlc3QSDgoGYWN0aW9uGAEgASgJEhIKCnByb2Nlc3NfaWQYAiABKAkSEQoJbWF4X2J5dGVzGAMgASgNIrcBChZNYW5hZ2VkUHJvY2Vzc1Jlc3BvbnNlEg4KBmFjdGlvbhgBIAEoCRI+CghzbmFwc2hvdBgCIAEoCzIsLmxpdmVhZ2VudC5nYXRld2F5LnYyLk1hbmFnZWRQcm9jZXNzU25hcHNob3QSEwoLbG9nX2NvbnRlbnQYAyABKAkSEAoIbG9nX3BhdGgYBCABKAkSFQoNbG9nX3RydW5jYXRlZBgFIAEoCBIPCgdzdG9wcGVkGAYgASgIIjkKE01lbW9yeU1hbmFnZVJlcXVlc3QSDwoHY29tbWFuZBgBIAEoCRIRCglhcmdzX2pzb24YAiABKAkiKwoUTWVtb3J5TWFuYWdlUmVzcG9uc2USEwoLcmVzdWx0X2pzb24YASABKAkimAMKD1Rlcm1pbmFsUmVxdWVzdBIOCgZhY3Rpb24YASABKAkSEgoKc2Vzc2lvbl9pZBgCIAEoCRIYChBwcm9qZWN0X3BhdGhfa2V5GAMgASgJEgsKA2N3ZBgEIAEoCRINCgVzaGVsbBgFIAEoCRINCgV0aXRsZRgGIAEoCRIMCgRkYXRhGAcgASgJEgwKBGNvbHMYCCABKA0SDAoEcm93cxgJIAEoDRIRCgltYXhfYnl0ZXMYCiABKA0SEwoLc3NoX2hvc3RfaWQYCyABKAkSEQoJcHJvbXB0X2lkGAwgASgJEhUKDXByb21wdF9hbnN3ZXIYDSABKAkSFgoOdHJ1c3RfaG9zdF9rZXkYDiABKAgSFAoMc2Z0cF9lbmFibGVkGA8gASgIEg4KBnRhYl9pZBgQIAEoCRIQCgh0YWJfa2luZBgRIAEoCRITCgtyZW1vdGVfaG9zdBgSIAEoCRITCgtyZW1vdGVfcG9ydBgTIAEoDRISCgpsb2NhbF9wb3J0GBQgASgNEhIKCmZvcndhcmRfaWQYFSABKAkisgIKD1Rlcm1pbmFsU2Vzc2lvbhIKCgJpZBgBIAEoCRIYChBwcm9qZWN0X3BhdGhfa2V5GAIgASgJEgsKA2N3ZBgDIAEoCRINCgVzaGVsbBgEIAEoCRINCgV0aXRsZRgFIAEoCRILCgNwaWQYBiABKA0SDAoEY29scxgHIAEoDRIMCgRyb3dzGAggASgNEhIKCmNyZWF0ZWRfYXQYCSABKAQSEgoKdXBkYXRlZF9hdBgKIAEoBBITCgtmaW5pc2hlZF9hdBgLIAEoBBIRCglleGl0X2NvZGUYDCABKAUSDwoHcnVubmluZxgNIAEoCBIMCgRraW5kGA4gASgJEjYKA3NzaBgPIAEoCzIpLmxpdmVhZ2VudC5nYXRld2F5LnYyLlRlcm1pbmFsU3NoTWV0YWRhdGEi2wEKE1Rlcm1pbmFsU3NoTWV0YWRhdGESDwoHaG9zdF9pZBgBIAEoCRIRCglob3N0X25hbWUYAiABKAkSEAoIdXNlcm5hbWUYAyABKAkSDAoEaG9zdBgEIAEoCRIMCgRwb3J0GAUgASgNEhEKCWF1dGhfdHlwZRgGIAEoCRIOCgZzdGF0dXMYByABKAkSGQoRcmVjb25uZWN0X2F0dGVtcHQYCCABKA0SHgoWcmVjb25uZWN0X21heF9hdHRlbXB0cxgJIAEoDRIUCgxzZnRwX2VuYWJsZWQYCiABKAgikQMKC1NmdHBSZXF1ZXN0Eg4KBmFjdGlvbhgBIAEoCRISCgpzZXNzaW9uX2lkGAIgASgJEhgKEHByb2plY3RfcGF0aF9rZXkYAyABKAkSDwoHd29ya2RpchgEIAEoCRISCgpsb2NhbF9wYXRoGAUgASgJEhMKC3JlbW90ZV9wYXRoGAYgASgJEhEKCWZyb21fcGF0aBgHIAEoCRIPCgd0b19wYXRoGAggASgJEhEKCWRpcmVjdGlvbhgJIAEoCRITCgt0YXJnZXRfcGF0aBgKIAEoCRIRCglyZWN1cnNpdmUYCyABKAgSEQoJb3ZlcndyaXRlGAwgASgIEg8KB2NvbnRlbnQYDSABKAkSDgoGb2Zmc2V0GA4gASgEEhEKCW1heF9ieXRlcxgPIAEoBBITCgtzdHJpY3RfdXRmOBgQIAEoCBIWCg5leHBlY3RlZF9tdGltZRgRIAEoBBIbChNleHBlY3RlZF9zaXplX2J5dGVzGBIgASgEEhoKEmNyZWF0ZV9wYXJlbnRfZGlycxgTIAEoCCJYCglTZnRwRW50cnkSDAoEcGF0aBgBIAEoCRIMCgRuYW1lGAIgASgJEgwKBGtpbmQYAyABKAkSEgoKc2l6ZV9ieXRlcxgEIAEoBBINCgVtdGltZRgFIAEoBCLyAQoMU2Z0cFRyYW5zZmVyEgoKAmlkGAEgASgJEhIKCnNlc3Npb25faWQYAiABKAkSEQoJZGlyZWN0aW9uGAMgASgJEg4KBnN0YXR1cxgEIAEoCRITCgtzb3VyY2VfcGF0aBgFIAEoCRITCgt0YXJnZXRfcGF0aBgGIAEoCRIUCgxjdXJyZW50X3BhdGgYByABKAkSEgoKYnl0ZXNfZG9uZRgIIAEoBBITCgtieXRlc190b3RhbBgJIAEoBBISCgpmaWxlc19kb25lGAogASgNEhMKC2ZpbGVzX3RvdGFsGAsgASgNEg0KBWVycm9yGAwgASgJIrACCgxTZnRwUmVzcG9uc2USDgoGYWN0aW9uGAEgASgJEgwKBHBhdGgYAiABKAkSMAoHZW50cmllcxgDIAMoCzIfLmxpdmVhZ2VudC5nYXRld2F5LnYyLlNmdHBFbnRyeRIuCgVlbnRyeRgEIAEoCzIfLmxpdmVhZ2VudC5nYXRld2F5LnYyLlNmdHBFbnRyeRIOCgZleGlzdHMYBSABKAgSNAoIdHJhbnNmZXIYBiABKAsyIi5saXZlYWdlbnQuZ2F0ZXdheS52Mi5TZnRwVHJhbnNmZXISDwoHY29udGVudBgHIAEoCRIOCgZvZmZzZXQYCCABKAQSEgoKYnl0ZXNfcmVhZBgJIAEoBBISCgpzaXplX2J5dGVzGAogASgEEhEKCXRydW5jYXRlZBgLIAEoCCJPCglTZnRwRXZlbnQSDAoEa2luZBgBIAEoCRI0Cgh0cmFuc2ZlchgCIAEoCzIiLmxpdmVhZ2VudC5nYXRld2F5LnYyLlNmdHBUcmFuc2ZlciLBAQoRVGVybWluYWxTc2hQcm9tcHQSCgoCaWQYASABKAkSDAoEa2luZBgCIAEoCRIPCgdob3N0X2lkGAMgASgJEhEKCWhvc3RfbmFtZRgEIAEoCRIMCgRob3N0GAUgASgJEgwKBHBvcnQYBiABKA0SDwoHbWVzc2FnZRgHIAEoCRIaChJmaW5nZXJwcmludF9zaGEyNTYYCCABKAkSEAoIa2V5X3R5cGUYCSABKAkSEwoLYW5zd2VyX2VjaG8YCiABKAgiQQoTVGVybWluYWxTaGVsbE9wdGlvbhIKCgJpZBgBIAEoCRINCgVsYWJlbBgCIAEoCRIPCgdjb21tYW5kGAMgASgJIoABCg5UZXJtaW5hbFNzaFRhYhIKCgJpZBgBIAEoCRISCgpzZXNzaW9uX2lkGAIgASgJEhgKEHByb2plY3RfcGF0aF9rZXkYAyABKAkSDAoEa2luZBgEIAEoCRISCgpjcmVhdGVkX2F0GAUgASgEEhIKCnVwZGF0ZWRfYXQYBiABKAQifwoXVGVybWluYWxTc2hUYWJzU25hcHNob3QSGAoQcHJvamVjdF9wYXRoX2tleRgBIAEoCRIyCgR0YWJzGAIgAygLMiQubGl2ZWFnZW50LmdhdGV3YXkudjIuVGVybWluYWxTc2hUYWISEAoIcmV2aXNpb24YBCABKARKBAgDEAQi/QEKF1Rlcm1pbmFsU3NoTG9jYWxGb3J3YXJkEgoKAmlkGAEgASgJEhIKCnNlc3Npb25faWQYAiABKAkSGAoQcHJvamVjdF9wYXRoX2tleRgDIAEoCRISCgpsb2NhbF9ob3N0GAQgASgJEhIKCmxvY2FsX3BvcnQYBSABKA0SDwoHYWRkcmVzcxgGIAEoCRITCgtyZW1vdGVfaG9zdBgHIAEoCRITCgtyZW1vdGVfcG9ydBgIIAEoDRIOCgZzdGF0dXMYCSABKAkSEgoKY3JlYXRlZF9hdBgKIAEoBBISCgp1cGRhdGVkX2F0GAsgASgEEg0KBWVycm9yGAwgASgJInUKIFRlcm1pbmFsU3NoTG9jYWxGb3J3YXJkc1NuYXBzaG90Ej8KCGZvcndhcmRzGAEgAygLMi0ubGl2ZWFnZW50LmdhdGV3YXkudjIuVGVybWluYWxTc2hMb2NhbEZvcndhcmQSEAoIcmV2aXNpb24YAiABKAQifwodVGVybWluYWxTc2hMb2NhbEZvcndhcmRBY3Rpb24SPgoHZm9yd2FyZBgBIAEoCzItLmxpdmVhZ2VudC5nYXRld2F5LnYyLlRlcm1pbmFsU3NoTG9jYWxGb3J3YXJkEhAKCHJldmlzaW9uGAIgASgEEgwKBGtpbmQYAyABKAkipwUKEFRlcm1pbmFsUmVzcG9uc2USDgoGYWN0aW9uGAEgASgJEjcKCHNlc3Npb25zGAIgAygLMiUubGl2ZWFnZW50LmdhdGV3YXkudjIuVGVybWluYWxTZXNzaW9uEjYKB3Nlc3Npb24YAyABKAsyJS5saXZlYWdlbnQuZ2F0ZXdheS52Mi5UZXJtaW5hbFNlc3Npb24SDgoGb3V0cHV0GAQgASgMEhEKCXRydW5jYXRlZBgFIAEoCBJACg1zaGVsbF9vcHRpb25zGAYgAygLMikubGl2ZWFnZW50LmdhdGV3YXkudjIuVGVybWluYWxTaGVsbE9wdGlvbhIVCg1kZWZhdWx0X3NoZWxsGAcgASgJEhsKE291dHB1dF9zdGFydF9vZmZzZXQYCCABKAQSGQoRb3V0cHV0X2VuZF9vZmZzZXQYCSABKAQSOwoKc3NoX3Byb21wdBgKIAEoCzInLmxpdmVhZ2VudC5nYXRld2F5LnYyLlRlcm1pbmFsU3NoUHJvbXB0EhIKCmxhdGVuY3lfbXMYCyABKA0SPwoIc3NoX3RhYnMYDCABKAsyLS5saXZlYWdlbnQuZ2F0ZXdheS52Mi5UZXJtaW5hbFNzaFRhYnNTbmFwc2hvdBJSChJzc2hfbG9jYWxfZm9yd2FyZHMYDSABKAsyNi5saXZlYWdlbnQuZ2F0ZXdheS52Mi5UZXJtaW5hbFNzaExvY2FsRm9yd2FyZHNTbmFwc2hvdBJOChFzc2hfbG9jYWxfZm9yd2FyZBgOIAEoCzIzLmxpdmVhZ2VudC5nYXRld2F5LnYyLlRlcm1pbmFsU3NoTG9jYWxGb3J3YXJkQWN0aW9uEigKIHNzaF9sb2NhbF9mb3J3YXJkX3BvcnRfYXZhaWxhYmxlGA8gASgIItoCCg1UZXJtaW5hbEV2ZW50EgwKBGtpbmQYASABKAkSEgoKc2Vzc2lvbl9pZBgCIAEoCRIYChBwcm9qZWN0X3BhdGhfa2V5GAMgASgJEjYKB3Nlc3Npb24YBCABKAsyJS5saXZlYWdlbnQuZ2F0ZXdheS52Mi5UZXJtaW5hbFNlc3Npb24SDAoEZGF0YRgFIAEoDBIbChNvdXRwdXRfc3RhcnRfb2Zmc2V0GAYgASgEEhkKEW91dHB1dF9lbmRfb2Zmc2V0GAcgASgEEj8KCHNzaF90YWJzGAggASgLMi0ubGl2ZWFnZW50LmdhdGV3YXkudjIuVGVybWluYWxTc2hUYWJzU25hcHNob3QSTgoRc3NoX2xvY2FsX2ZvcndhcmQYCSABKAsyMy5saXZlYWdlbnQuZ2F0ZXdheS52Mi5UZXJtaW5hbFNzaExvY2FsRm9yd2FyZEFjdGlvbiKyAgoTVGVybWluYWxTdHJlYW1GcmFtZRIMCgRraW5kGAEgASgJEhEKCXN0cmVhbV9pZBgCIAEoCRISCgpzZXNzaW9uX2lkGAMgASgJEhgKEHByb2plY3RfcGF0aF9rZXkYBCABKAkSCwoDc2VxGAUgASgEEhQKDHN0YXJ0X29mZnNldBgGIAEoBBISCgplbmRfb2Zmc2V0GAcgASgEEgwKBGNvbHMYCCABKA0SDAoEcm93cxgJIAEoDRIRCgltYXhfYnl0ZXMYCiABKA0SEQoJdHJ1bmNhdGVkGAsgASgIEg0KBWVycm9yGAwgASgJEjYKB3Nlc3Npb24YDSABKAsyJS5saXZlYWdlbnQuZ2F0ZXdheS52Mi5UZXJtaW5hbFNlc3Npb24SDAoEZGF0YRgOIAEoDCJACgpHaXRSZXF1ZXN0Eg4KBmFjdGlvbhgBIAEoCRIPCgd3b3JrZGlyGAIgASgJEhEKCWFyZ3NfanNvbhgDIAEoCSIyCgtHaXRSZXNwb25zZRIOCgZhY3Rpb24YASABKAkSEwoLcmVzdWx0X2pzb24YAiABKAki9AIKC0NoYXRSZXF1ZXN0EhcKD2NvbnZlcnNhdGlvbl9pZBgBIAEoCRIPCgdtZXNzYWdlGAIgASgJEj8KDnNlbGVjdGVkX21vZGVsGAMgASgLMicubGl2ZWFnZW50LmdhdGV3YXkudjIuQ2hhdFNlbGVjdGVkTW9kZWwSFgoOZXhlY3V0aW9uX21vZGUYBCABKAkSDwoHd29ya2RpchgFIAEoCRI+Cg51cGxvYWRlZF9maWxlcxgHIAMoCzImLmxpdmVhZ2VudC5nYXRld2F5LnYyLkNoYXRVcGxvYWRlZEZpbGUSGQoRY2xpZW50X3JlcXVlc3RfaWQYCCABKAkSQwoQcnVudGltZV9jb250cm9scxgJIAEoCzIpLmxpdmVhZ2VudC5nYXRld2F5LnYyLkNoYXRSdW50aW1lQ29udHJvbHMSFAoMcXVldWVfcG9saWN5GAogASgJSgQIBhAHUhVzZWxlY3RlZF9zeXN0ZW1fdG9vbHMiigEKDkNoYXRNZXNzYWdlUmVmEhUKDXNlZ21lbnRfaW5kZXgYASABKAUSFQoNbWVzc2FnZV9pbmRleBgCIAEoBRISCgpzZWdtZW50X2lkGAMgASgJEhIKCm1lc3NhZ2VfaWQYBCABKAkSDAoEcm9sZRgFIAEoCRIUCgxjb250ZW50X2hhc2gYBiABKAkiPAoRQ2FuY2VsQ2hhdFJlcXVlc3QSFwoPY29udmVyc2F0aW9uX2lkGAEgASgJEg4KBnJ1bl9pZBgCIAEoCSLPAQoSQ2hhdENvbW1hbmRSZXF1ZXN0EgwKBHR5cGUYASABKAkSMgoHcmVxdWVzdBgCIAEoCzIhLmxpdmVhZ2VudC5nYXRld2F5LnYyLkNoYXRSZXF1ZXN0Ej4KEGJhc2VfbWVzc2FnZV9yZWYYAyABKAsyJC5saXZlYWdlbnQuZ2F0ZXdheS52Mi5DaGF0TWVzc2FnZVJlZhI3CgZjYW5jZWwYBCABKAsyJy5saXZlYWdlbnQuZ2F0ZXdheS52Mi5DYW5jZWxDaGF0UmVxdWVzdCK4AQoQQ2hhdFF1ZXVlUmVxdWVzdBIOCgZhY3Rpb24YASABKAkSFwoPY29udmVyc2F0aW9uX2lkGAIgASgJEg8KB2l0ZW1faWQYAyABKAkSEQoJZGlyZWN0aW9uGAQgASgJEhAKCHJldmlzaW9uGAUgASgEEhIKCmRyYWZ0X2pzb24YBiABKAkSGwoTdXBsb2FkZWRfZmlsZXNfanNvbhgHIAEoCRIUCgxyZXF1ZXN0X2pzb24YCCABKAkihgEKEUNoYXRRdWV1ZVJlc3BvbnNlEhAKCGFjY2VwdGVkGAEgASgIEg8KB21lc3NhZ2UYAiABKAkSFQoNc25hcHNob3RfanNvbhgDIAEoCRIRCglpdGVtX2pzb24YBCABKAkSEgoKZXJyb3JfY29kZRgFIAEoCRIQCghyZXZpc2lvbhgGIAEoBCJSCg5DaGF0UXVldWVFdmVudBIXCg9jb252ZXJzYXRpb25faWQYASABKAkSFQoNc25hcHNob3RfanNvbhgCIAEoCRIQCghyZXZpc2lvbhgDIAEoBCKVAgoJQ2hhdEV2ZW50EjsKBHR5cGUYASABKA4yLS5saXZlYWdlbnQuZ2F0ZXdheS52Mi5DaGF0RXZlbnQuQ2hhdEV2ZW50VHlwZRIXCg9jb252ZXJzYXRpb25faWQYAiABKAkSDAoEZGF0YRgDIAEoCSKjAQoNQ2hhdEV2ZW50VHlwZRIJCgVUT0tFThAAEgwKCFRISU5LSU5HEAESDQoJVE9PTF9DQUxMEAISDwoLVE9PTF9SRVNVTFQQAxIICgRET05FEAQSCQoFRVJST1IQBRIPCgtUT09MX1NUQVRVUxAGEhEKDUhPU1RFRF9TRUFSQ0gQBxIQCgxVU0VSX01FU1NBR0UQCBIOCgpUUkFKRUNUT1JZEAkivAEKEENoYXRDb250cm9sRXZlbnQSEgoKcmVxdWVzdF9pZBgBIAEoCRIZChFjbGllbnRfcmVxdWVzdF9pZBgCIAEoCRIXCg9jb252ZXJzYXRpb25faWQYAyABKAkSEQoJcnVuX2Vwb2NoGAQgASgDEgwKBHR5cGUYBSABKAkSDQoFc3RhdGUYBiABKAkSEgoKZXJyb3JfY29kZRgHIAEoCRIPCgdtZXNzYWdlGAggASgJEgsKA3NlcRgJIAEoAyL8AQoTQ2hhdFJ1bnRpbWVTbmFwc2hvdBIXCg9jb252ZXJzYXRpb25faWQYASABKAkSDgoGcnVuX2lkGAIgASgJEhkKEWNsaWVudF9yZXF1ZXN0X2lkGAMgASgJEhEKCXdvcmtlcl9pZBgEIAEoCRINCgVzdGF0ZRgFIAEoCRILCgNjd2QYBiABKAkSEgoKdXBkYXRlZF9hdBgHIAEoAxIQCghyZXZpc2lvbhgIIAEoAxIUCgxlbnRyaWVzX2pzb24YCSABKAkSEwoLdG9vbF9zdGF0dXMYCiABKAkSIQoZdG9vbF9zdGF0dXNfaXNfY29tcGFjdGlvbhgLIAEoCCLqAQoSUnVudGltZVN0YXR1c0V2ZW50EhEKCXdvcmtlcl9pZBgBIAEoCRINCgVzdGF0ZRgCIAEoCRIPCgd2aXNpYmxlGAMgASgIEhgKEGFjdGl2ZV9ydW5fY291bnQYBCABKA0SEQoJdGltZXN0YW1wGAUgASgDEjgKC2FjdGl2ZV9ydW5zGAYgAygLMiMubGl2ZWFnZW50LmdhdGV3YXkudjIuQ2hhdFJ1blJlcG9ydBI6Cg1maW5pc2hlZF9ydW5zGAcgAygLMiMubGl2ZWFnZW50LmdhdGV3YXkudjIuQ2hhdFJ1blJlcG9ydCKAAQoNQ2hhdFJ1blJlcG9ydBIOCgZydW5faWQYASABKAkSFwoPY29udmVyc2F0aW9uX2lkGAIgASgJEg0KBXN0YXRlGAMgASgJEhIKCmVycm9yX2NvZGUYBCABKAkSDwoHbWVzc2FnZRgFIAEoCRISCgp1cGRhdGVkX2F0GAYgASgDIkcKEUNyb25NYW5hZ2VSZXF1ZXN0Eg4KBmFjdGlvbhgBIAEoCRIPCgd0YXNrX2lkGAIgASgJEhEKCXRhc2tfanNvbhgDIAEoCSI5ChJDcm9uTWFuYWdlUmVzcG9uc2USDgoGYWN0aW9uGAEgASgJEhMKC3Jlc3VsdF9qc29uGAIgASgJIlUKEkhpc3RvcnlMaXN0UmVxdWVzdBIMCgRwYWdlGAEgASgFEhEKCXBhZ2Vfc2l6ZRgCIAEoBRILCgNjd2QYAyABKAkSEQoJY3dkX2VtcHR5GAQgASgIImwKE0hpc3RvcnlMaXN0UmVzcG9uc2USQAoNY29udmVyc2F0aW9ucxgBIAMoCzIpLmxpdmVhZ2VudC5nYXRld2F5LnYyLkNvbnZlcnNhdGlvblN1bW1hcnkSEwoLdG90YWxfY291bnQYAiABKAUiigIKE0NvbnZlcnNhdGlvblN1bW1hcnkSCgoCaWQYASABKAkSDQoFdGl0bGUYAiABKAkSEgoKY3JlYXRlZF9hdBgDIAEoAxISCgp1cGRhdGVkX2F0GAQgASgDEhUKDW1lc3NhZ2VfY291bnQYBSABKAUSEwoLcHJvdmlkZXJfaWQYBiABKAkSDQoFbW9kZWwYByABKAkSEgoKc2Vzc2lvbl9pZBgIIAEoCRILCgNjd2QYCSABKAkSEQoJaXNfcGlubmVkGAogASgIEhEKCXBpbm5lZF9hdBgLIAEoAxIRCglpc19zaGFyZWQYDCABKAgSGwoTc2VsZWN0ZWRfbW9kZWxfanNvbhgNIAEoCSJCChFIaXN0b3J5R2V0UmVxdWVzdBIXCg9jb252ZXJzYXRpb25faWQYASABKAkSFAoMbWF4X21lc3NhZ2VzGAIgASgFItQBChJIaXN0b3J5R2V0UmVzcG9uc2USFwoPY29udmVyc2F0aW9uX2lkGAEgASgJEhUKDW1lc3NhZ2VzX2pzb24YAiABKAkSGwoTdG90YWxfbWVzc2FnZV9jb3VudBgDIAEoBRIeChZyZXR1cm5lZF9tZXNzYWdlX2NvdW50GAQgASgFEhAKCGhhc19tb3JlGAUgASgIEj8KDGNvbnZlcnNhdGlvbhgGIAEoCzIpLmxpdmVhZ2VudC5nYXRld2F5LnYyLkNvbnZlcnNhdGlvblN1bW1hcnki0QEKFlRyYWplY3RvcnlGZXRjaFJlcXVlc3QSFwoPY29udmVyc2F0aW9uX2lkGAEgASgJEhMKC3NlY3Rpb25faWRzGAIgAygJEhQKDG1heF9zZWdtZW50cxgDIAEoBRIhChRiZWZvcmVfc2VnbWVudF9pbmRleBgEIAEoBUgAiAEBEh0KFWluY2x1ZGVfc3ViYWdlbnRfcnVucxgFIAEoCBIYChBzdWJhZ2VudF9ydW5faWRzGAYgAygJQhcKFV9iZWZvcmVfc2VnbWVudF9pbmRleCJcChhUcmFqZWN0b3J5U2VjdGlvblBheWxvYWQSEgoKc2VjdGlvbl9pZBgBIAEoCRIMCgRzbG90GAIgASgJEg8KB2NvbnRlbnQYAyABKAkSDQoFYnl0ZXMYBCABKAMirAIKF1RyYWplY3RvcnlGZXRjaFJlc3BvbnNlEhcKD2NvbnZlcnNhdGlvbl9pZBgBIAEoCRITCgtldmVudHNfanNvbhgCIAEoCRIRCgl0cnVuY2F0ZWQYAyABKAgSQAoIc2VjdGlvbnMYBCADKAsyLi5saXZlYWdlbnQuZ2F0ZXdheS52Mi5UcmFqZWN0b3J5U2VjdGlvblBheWxvYWQSHAoUb2xkZXN0X3NlZ21lbnRfaW5kZXgYBSABKAUSHgoWcmV0dXJuZWRfc2VnbWVudF9jb3VudBgGIAEoBRIbChN0b3RhbF9zZWdtZW50X2NvdW50GAcgASgFEhcKD2hhc19tb3JlX2JlZm9yZRgIIAEoCBIaChJzdWJhZ2VudF9ydW5zX2pzb24YCSABKAkihQEKFEhpc3RvcnlQcmVmaXhSZXF1ZXN0EhcKD2NvbnZlcnNhdGlvbl9pZBgBIAEoCRIUCgxtYXhfbWVzc2FnZXMYAiABKAUSPgoQYmFzZV9tZXNzYWdlX3JlZhgDIAEoCzIkLmxpdmVhZ2VudC5nYXRld2F5LnYyLkNoYXRNZXNzYWdlUmVmItcBChVIaXN0b3J5UHJlZml4UmVzcG9uc2USFwoPY29udmVyc2F0aW9uX2lkGAEgASgJEhUKDW1lc3NhZ2VzX2pzb24YAiABKAkSGwoTdG90YWxfbWVzc2FnZV9jb3VudBgDIAEoBRIeChZyZXR1cm5lZF9tZXNzYWdlX2NvdW50GAQgASgFEhAKCGhhc19tb3JlGAUgASgIEj8KDGNvbnZlcnNhdGlvbhgGIAEoCzIpLmxpdmVhZ2VudC5nYXRld2F5LnYyLkNvbnZlcnNhdGlvblN1bW1hcnkiPgoUSGlzdG9yeVJlbmFtZVJlcXVlc3QSFwoPY29udmVyc2F0aW9uX2lkGAEgASgJEg0KBXRpdGxlGAIgASgJIlgKFUhpc3RvcnlSZW5hbWVSZXNwb25zZRI/Cgxjb252ZXJzYXRpb24YASABKAsyKS5saXZlYWdlbnQuZ2F0ZXdheS52Mi5Db252ZXJzYXRpb25TdW1tYXJ5Im8KFEhpc3RvcnlCcmFuY2hSZXF1ZXN0EhcKD2NvbnZlcnNhdGlvbl9pZBgBIAEoCRI+ChBiYXNlX21lc3NhZ2VfcmVmGAIgASgLMiQubGl2ZWFnZW50LmdhdGV3YXkudjIuQ2hhdE1lc3NhZ2VSZWYiWAoVSGlzdG9yeUJyYW5jaFJlc3BvbnNlEj8KDGNvbnZlcnNhdGlvbhgBIAEoCzIpLmxpdmVhZ2VudC5nYXRld2F5LnYyLkNvbnZlcnNhdGlvblN1bW1hcnkiPwoRSGlzdG9yeVBpblJlcXVlc3QSFwoPY29udmVyc2F0aW9uX2lkGAEgASgJEhEKCWlzX3Bpbm5lZBgCIAEoCCJVChJIaXN0b3J5UGluUmVzcG9uc2USPwoMY29udmVyc2F0aW9uGAEgASgLMikubGl2ZWFnZW50LmdhdGV3YXkudjIuQ29udmVyc2F0aW9uU3VtbWFyeSKSAQoSSGlzdG9yeVNoYXJlU3RhdHVzEhcKD2NvbnZlcnNhdGlvbl9pZBgBIAEoCRIPCgdlbmFibGVkGAIgASgIEg0KBXRva2VuGAMgASgJEhIKCmNyZWF0ZWRfYXQYBCABKAMSEgoKdXBkYXRlZF9hdBgFIAEoAxIbChNyZWRhY3RfdG9vbF9jb250ZW50GAYgASgIIjEKFkhpc3RvcnlTaGFyZUdldFJlcXVlc3QSFwoPY29udmVyc2F0aW9uX2lkGAEgASgJIlIKF0hpc3RvcnlTaGFyZUdldFJlc3BvbnNlEjcKBXNoYXJlGAEgASgLMigubGl2ZWFnZW50LmdhdGV3YXkudjIuSGlzdG9yeVNoYXJlU3RhdHVzInwKFkhpc3RvcnlTaGFyZVNldFJlcXVlc3QSFwoPY29udmVyc2F0aW9uX2lkGAEgASgJEg8KB2VuYWJsZWQYAiABKAgSIAoTcmVkYWN0X3Rvb2xfY29udGVudBgDIAEoCEgAiAEBQhYKFF9yZWRhY3RfdG9vbF9jb250ZW50IlIKF0hpc3RvcnlTaGFyZVNldFJlc3BvbnNlEjcKBXNoYXJlGAEgASgLMigubGl2ZWFnZW50LmdhdGV3YXkudjIuSGlzdG9yeVNoYXJlU3RhdHVzIisKGkhpc3RvcnlTaGFyZVJlc29sdmVSZXF1ZXN0Eg0KBXRva2VuGAEgASgJIsgBChtIaXN0b3J5U2hhcmVSZXNvbHZlUmVzcG9uc2USFwoPY29udmVyc2F0aW9uX2lkGAEgASgJEhUKDW1lc3NhZ2VzX2pzb24YAiABKAkSGwoTdG90YWxfbWVzc2FnZV9jb3VudBgDIAEoBRI/Cgxjb252ZXJzYXRpb24YBCABKAsyKS5saXZlYWdlbnQuZ2F0ZXdheS52Mi5Db252ZXJzYXRpb25TdW1tYXJ5EhsKE3JlZGFjdF90b29sX2NvbnRlbnQYBSABKAgiGAoWSGlzdG9yeVdvcmtkaXJzUmVxdWVzdCJVChVIaXN0b3J5V29ya2RpclN1bW1hcnkSDAoEcGF0aBgBIAEoCRIaChJjb252ZXJzYXRpb25fY291bnQYAiABKAUSEgoKdXBkYXRlZF9hdBgDIAEoAyJYChdIaXN0b3J5V29ya2RpcnNSZXNwb25zZRI9Cgh3b3JrZGlycxgBIAMoCzIrLmxpdmVhZ2VudC5nYXRld2F5LnYyLkhpc3RvcnlXb3JrZGlyU3VtbWFyeSIvChRIaXN0b3J5RGVsZXRlUmVxdWVzdBIXCg9jb252ZXJzYXRpb25faWQYASABKAkiFwoVSGlzdG9yeURlbGV0ZVJlc3BvbnNlInoKEEhpc3RvcnlTeW5jRXZlbnQSDAoEa2luZBgBIAEoCRI/Cgxjb252ZXJzYXRpb24YAiABKAsyKS5saXZlYWdlbnQuZ2F0ZXdheS52Mi5Db252ZXJzYXRpb25TdW1tYXJ5EhcKD2NvbnZlcnNhdGlvbl9pZBgDIAEoCSIVChNQcm92aWRlckxpc3RSZXF1ZXN0Ii4KFFByb3ZpZGVyTGlzdFJlc3BvbnNlEhYKDnByb3ZpZGVyc19qc29uGAEgASgJIhQKElNldHRpbmdzR2V0UmVxdWVzdCIsChNTZXR0aW5nc0dldFJlc3BvbnNlEhUKDXNldHRpbmdzX2pzb24YASABKAkiLgoVU2V0dGluZ3NVcGRhdGVSZXF1ZXN0EhUKDXNldHRpbmdzX2pzb24YASABKAkiOwoWU2V0dGluZ3NVcGRhdGVSZXNwb25zZRIQCghhY2NlcHRlZBgBIAEoCBIPCgdtZXNzYWdlGAIgASgJIj4KIFNldHRpbmdzUmVzZXRTc2hLbm93bkhvc3RSZXF1ZXN0EgwKBGhvc3QYASABKAkSDAoEcG9ydBgCIAEoDSI0CiFTZXR0aW5nc1Jlc2V0U3NoS25vd25Ib3N0UmVzcG9uc2USDwoHZGVsZXRlZBgBIAEoDSIqChFTZXR0aW5nc1N5bmNFdmVudBIVCg1zZXR0aW5nc19qc29uGAEgASgJIhcKFVNraWxsRmlsZXNMaXN0UmVxdWVzdCJMChZTa2lsbEZpbGVzTGlzdFJlc3BvbnNlEhAKCHJvb3RfZGlyGAEgASgJEg0KBXBhdGhzGAIgAygJEhEKCXRydW5jYXRlZBgDIAEoCCIoChhTa2lsbE1ldGFkYXRhUmVhZFJlcXVlc3QSDAoEcGF0aBgBIAEoCSI+ChlTa2lsbE1ldGFkYXRhUmVhZFJlc3BvbnNlEgwKBG5hbWUYASABKAkSEwoLZGVzY3JpcHRpb24YAiABKAkiRAoUU2tpbGxUZXh0UmVhZFJlcXVlc3QSDAoEcGF0aBgBIAEoCRIOCgZvZmZzZXQYAiABKA0SDgoGbGVuZ3RoGAMgASgNIjsKFVNraWxsVGV4dFJlYWRSZXNwb25zZRIPCgdjb250ZW50GAEgASgJEhEKCXRydW5jYXRlZBgCIAEoCCIqChJTa2lsbE1hbmFnZVJlcXVlc3QSFAoMcGF5bG9hZF9qc29uGAEgASgJIioKE1NraWxsTWFuYWdlUmVzcG9uc2USEwoLcmVzdWx0X2pzb24YASABKAkidwoWRmlsZU1lbnRpb25MaXN0UmVxdWVzdBIPCgd3b3JrZGlyGAEgASgJEhMKC21heF9yZXN1bHRzGAIgASgNEg0KBXF1ZXJ5GAMgASgJEhgKC3Nob3dfaGlkZGVuGAQgASgISACIAQFCDgoMX3Nob3dfaGlkZGVuIj4KEEZpbGVNZW50aW9uRW50cnkSDAoEcGF0aBgBIAEoCRIMCgRraW5kGAIgASgJEg4KBmhpZGRlbhgDIAEoCCJlChdGaWxlTWVudGlvbkxpc3RSZXNwb25zZRI3CgdlbnRyaWVzGAEgAygLMiYubGl2ZWFnZW50LmdhdGV3YXkudjIuRmlsZU1lbnRpb25FbnRyeRIRCgl0cnVuY2F0ZWQYAiABKAgiPwoGRnNSb290EgoKAmlkGAEgASgJEgwKBHBhdGgYAiABKAkSDAoEa2luZBgDIAEoCRINCgVsYWJlbBgEIAEoCSIQCg5Gc1Jvb3RzUmVxdWVzdCI+Cg9Gc1Jvb3RzUmVzcG9uc2USKwoFcm9vdHMYASADKAsyHC5saXZlYWdlbnQuZ2F0ZXdheS52Mi5Gc1Jvb3QiNgoRRnNMaXN0RGlyc1JlcXVlc3QSDAoEcGF0aBgBIAEoCRITCgttYXhfcmVzdWx0cxgCIAEoDSIoCgpGc0RpckVudHJ5EgwKBHBhdGgYASABKAkSDAoEbmFtZRgCIAEoCSJoChJGc0xpc3REaXJzUmVzcG9uc2USDAoEcGF0aBgBIAEoCRIxCgdlbnRyaWVzGAIgAygLMiAubGl2ZWFnZW50LmdhdGV3YXkudjIuRnNEaXJFbnRyeRIRCgl0cnVuY2F0ZWQYAyABKAgiPAocRnNDcmVhdGVQcm9qZWN0Rm9sZGVyUmVxdWVzdBIOCgZwYXJlbnQYASABKAkSDAoEbmFtZRgCIAEoCSItCh1Gc0NyZWF0ZVByb2plY3RGb2xkZXJSZXNwb25zZRIMCgRwYXRoGAEgASgJIowBCg1Gc0xpc3RSZXF1ZXN0Eg8KB3dvcmtkaXIYASABKAkSDAoEcGF0aBgCIAEoCRINCgVkZXB0aBgDIAEoDRIOCgZvZmZzZXQYBCABKA0SEwoLbWF4X3Jlc3VsdHMYBSABKA0SGAoLc2hvd19oaWRkZW4YBiABKAhIAIgBAUIOCgxfc2hvd19oaWRkZW4iOQoLRnNMaXN0RW50cnkSDAoEcGF0aBgBIAEoCRIMCgRraW5kGAIgASgJEg4KBmhpZGRlbhgDIAEoCCK5AQoORnNMaXN0UmVzcG9uc2USDAoEcGF0aBgBIAEoCRIQCghoYXNfcGF0aBgCIAEoCBINCgVkZXB0aBgDIAEoDRIOCgZvZmZzZXQYBCABKA0SEwoLbWF4X3Jlc3VsdHMYBSABKA0SDQoFdG90YWwYBiABKA0SEAoIaGFzX21vcmUYByABKAgSMgoHZW50cmllcxgIIAMoCzIhLmxpdmVhZ2VudC5nYXRld2F5LnYyLkZzTGlzdEVudHJ5IjoKGUZzUmVhZEVkaXRhYmxlVGV4dFJlcXVlc3QSDwoHd29ya2RpchgBIAEoCRIMCgRwYXRoGAIgASgJIowBChpGc1JlYWRFZGl0YWJsZVRleHRSZXNwb25zZRIMCgRwYXRoGAEgASgJEg8KB2NvbnRlbnQYAiABKAkSEAoIbXRpbWVfbXMYAyABKAQSFAoMY29udGVudF9oYXNoGAQgASgJEhIKCnNpemVfYnl0ZXMYBSABKAQSEwoLdG90YWxfbGluZXMYBiABKAQiPAobRnNSZWFkV29ya3NwYWNlSW1hZ2VSZXF1ZXN0Eg8KB3dvcmtkaXIYASABKAkSDAoEcGF0aBgCIAEoCSKJAQocRnNSZWFkV29ya3NwYWNlSW1hZ2VSZXNwb25zZRIMCgRwYXRoGAEgASgJEhEKCW1pbWVfdHlwZRgCIAEoCRIMCgRkYXRhGAMgASgJEhIKCnNpemVfYnl0ZXMYBCABKAQSEAoIbXRpbWVfbXMYBSABKAQSFAoMY29udGVudF9oYXNoGAYgASgJItsBChNDaGF0RmlsZU9wZW5SZXF1ZXN0EhcKD2NvbnZlcnNhdGlvbl9pZBgBIAEoCRIPCgd3b3JrZGlyGAIgASgJEgwKBHBhdGgYAyABKAkSDgoGc291cmNlGAQgASgJEhEKBGxpbmUYBSABKA1IAIgBARIVCghlbmRfbGluZRgGIAEoDUgBiAEBEhMKBmNvbHVtbhgHIAEoDUgCiAEBEhwKFG9wZW5faW5fZmlsZV9tYW5hZ2VyGAggASgIQgcKBV9saW5lQgsKCV9lbmRfbGluZUIJCgdfY29sdW1uIs4BChRDaGF0RmlsZU9wZW5SZXNwb25zZRIOCgZhY3Rpb24YASABKAkSDAoEa2luZBgCIAEoCRIPCgd3b3JrZGlyGAMgASgJEgwKBHBhdGgYBCABKAkSEQoEbGluZRgFIAEoDUgAiAEBEhUKCGVuZF9saW5lGAYgASgNSAGIAQESEwoGY29sdW1uGAcgASgNSAKIAQESGQoRb3V0c2lkZV93b3Jrc3BhY2UYCCABKAhCBwoFX2xpbmVCCwoJX2VuZF9saW5lQgkKB19jb2x1bW4izgEKEkZzV3JpdGVUZXh0UmVxdWVzdBIPCgd3b3JrZGlyGAEgASgJEgwKBHBhdGgYAiABKAkSDwoHY29udGVudBgDIAEoCRIMCgRtb2RlGAQgASgJEhkKEWV4cGVjdGVkX210aW1lX21zGAUgASgEEh0KFWV4cGVjdGVkX2NvbnRlbnRfaGFzaBgGIAEoCRIdChVoYXNfZXhwZWN0ZWRfbXRpbWVfbXMYByABKAgSIQoZaGFzX2V4cGVjdGVkX2NvbnRlbnRfaGFzaBgIIAEoCCKdAQoTRnNXcml0ZVRleHRSZXNwb25zZRIMCgRwYXRoGAEgASgJEgwKBG1vZGUYAiABKAkSFgoOZXhpc3RlZF9iZWZvcmUYAyABKAgSFQoNYnl0ZXNfd3JpdHRlbhgEIAEoBBIQCghtdGltZV9tcxgFIAEoBBIUCgxjb250ZW50X2hhc2gYBiABKAkSEwoLdG90YWxfbGluZXMYByABKAQiMwoSRnNDcmVhdGVEaXJSZXF1ZXN0Eg8KB3dvcmtkaXIYASABKAkSDAoEcGF0aBgCIAEoCSIxChNGc0NyZWF0ZURpclJlc3BvbnNlEgwKBHBhdGgYASABKAkSDAoEa2luZBgCIAEoCSJGCg9Gc1JlbmFtZVJlcXVlc3QSDwoHd29ya2RpchgBIAEoCRIRCglmcm9tX3BhdGgYAiABKAkSDwoHdG9fcGF0aBgDIAEoCSJBChBGc1JlbmFtZVJlc3BvbnNlEhEKCWZyb21fcGF0aBgBIAEoCRIMCgRwYXRoGAIgASgJEgwKBGtpbmQYAyABKAkiMAoPRnNEZWxldGVSZXF1ZXN0Eg8KB3dvcmtkaXIYASABKAkSDAoEcGF0aBgCIAEoCSIuChBGc0RlbGV0ZVJlc3BvbnNlEgwKBHBhdGgYASABKAkSDAoEa2luZBgCIAEoCSIgCgtQaW5nUmVxdWVzdBIRCgl0aW1lc3RhbXAYASABKAMiIQoMUG9uZ1Jlc3BvbnNlEhEKCXRpbWVzdGFtcBgBIAEoAyIuCg1FcnJvclJlc3BvbnNlEgwKBGNvZGUYASABKAUSDwoHbWVzc2FnZRgCIAEoCSK+AQoVUHJvdmlkZXJNb2RlbHNSZXF1ZXN0EhUKDXByb3ZpZGVyX3R5cGUYASABKAkSEAoIYmFzZV91cmwYAiABKAkSDwoHYXBpX2tleRgDIAEoCRIYChB1c2Vfc3lzdGVtX3Byb3h5GAQgASgIEhIKCm1vZGVsc191cmwYBSABKAkSEwoLcHJvdmlkZXJfaWQYBiABKAkSGAoLaXNfZnVsbF91cmwYByABKAhIAIgBAUIOCgxfaXNfZnVsbF91cmwiLQoWUHJvdmlkZXJNb2RlbHNSZXNwb25zZRITCgttb2RlbHNfanNvbhgBIAEoCSJRChRQcm92aWRlclVzYWdlUmVxdWVzdBITCgtwcm92aWRlcl9pZBgBIAEoCRIPCgdyZWZyZXNoGAIgASgIEhMKC2NvbmZpZ19qc29uGAMgASgJIiwKFVByb3ZpZGVyVXNhZ2VSZXNwb25zZRITCgtyZXN1bHRfanNvbhgBIAEoCSKIAQoQQ2hhdEluZ3Jlc3NCYXRjaBIOCgZydW5faWQYASABKAkSFwoPY29udmVyc2F0aW9uX2lkGAIgASgJEhEKCWZpcnN0X3NlcRgDIAEoBBI4CgdyZWNvcmRzGAQgAygLMicubGl2ZWFnZW50LmdhdGV3YXkudjIuQ2hhdEluZ3Jlc3NSZWNvcmQimgIKEUNoYXRJbmdyZXNzUmVjb3JkEjcKBWRlbHRhGAEgASgLMiYubGl2ZWFnZW50LmdhdGV3YXkudjIuQ2hhdEluZ3Jlc3NEZWx0YUgAEkEKCmNoZWNrcG9pbnQYAiABKAsyKy5saXZlYWdlbnQuZ2F0ZXdheS52Mi5DaGF0SW5ncmVzc0NoZWNrcG9pbnRIABI9Cgh0ZXJtaW5hbBgDIAEoCzIpLmxpdmVhZ2VudC5nYXRld2F5LnYyLkNoYXRJbmdyZXNzVGVybWluYWxIABI/CgloZWFydGJlYXQYBCABKAsyKi5saXZlYWdlbnQuZ2F0ZXdheS52Mi5DaGF0SW5ncmVzc0hlYXJ0YmVhdEgAQgkKB3BheWxvYWQiOQoQQ2hhdEluZ3Jlc3NEZWx0YRISCgpldmVudF9qc29uGAEgASgJEhEKCXdvcmtlcl9pZBgCIAEoCSIqChRDaGF0SW5ncmVzc0hlYXJ0YmVhdBISCgp1cGRhdGVkX2F0GAEgASgDIsQBChVDaGF0SW5ncmVzc0NoZWNrcG9pbnQSGgoSY292ZXJzX3Rocm91Z2hfc2VxGAEgASgEEhAKCHJldmlzaW9uGAIgASgEEh0KFWNvbXByZXNzZWRfcHJvamVjdGlvbhgDIAEoDBIaChJ1bmNvbXByZXNzZWRfYnl0ZXMYBCABKAQSDgoGc2hhMjU2GAUgASgJEhgKEGNvbnRlbnRfY29tcGxldGUYBiABKAgSGAoQaGlzdG9yeV9yZXF1aXJlZBgHIAEoCCL8AQoTQ2hhdEluZ3Jlc3NUZXJtaW5hbBIaChJjb3ZlcnNfdGhyb3VnaF9zZXEYASABKAQSEAoIcmV2aXNpb24YAiABKAQSHQoVY29tcHJlc3NlZF9wcm9qZWN0aW9uGAMgASgMEhoKEnVuY29tcHJlc3NlZF9ieXRlcxgEIAEoBBIOCgZzaGEyNTYYBSABKAkSGAoQY29udGVudF9jb21wbGV0ZRgGIAEoCBIYChBoaXN0b3J5X3JlcXVpcmVkGAcgASgIEg0KBXN0YXRlGAggASgJEhIKCmVycm9yX2NvZGUYCSABKAkSFQoNZXJyb3JfbWVzc2FnZRgKIAEoCSJNChFDaGF0SW5ncmVzc1Jlc3VtZRI4CgRydW5zGAEgAygLMioubGl2ZWFnZW50LmdhdGV3YXkudjIuQ2hhdEluZ3Jlc3NSdW5SZXN1bWUi1QEKFENoYXRJbmdyZXNzUnVuUmVzdW1lEg4KBnJ1bl9pZBgBIAEoCRIXCg9jb252ZXJzYXRpb25faWQYAiABKAkSFwoPcmVwbGF5X2Zyb21fc2VxGAMgASgEEhoKEnJlcGxheV90aHJvdWdoX3NlcRgEIAEoBBIQCghuZXh0X3NlcRgFIAEoBBIdChVsYXRlc3RfY2hlY2twb2ludF9zZXEYBiABKAQSFAoMdGVybWluYWxfc2VxGAcgASgEEhgKEHRlcm1pbmFsX3BlbmRpbmcYCCABKAgizgEKE0NoYXRJbmdyZXNzRnJhZ21lbnQSDgoGcnVuX2lkGAEgASgJEhcKD2NvbnZlcnNhdGlvbl9pZBgCIAEoCRISCgpzb3VyY2Vfc2VxGAMgASgEEhYKDmZyYWdtZW50X2luZGV4GAQgASgNEhYKDmZyYWdtZW50X2NvdW50GAUgASgNEhwKFGVuY29kZWRfcmVjb3JkX2NodW5rGAYgASgMEhwKFGVuY29kZWRfcmVjb3JkX2J5dGVzGAcgASgEEg4KBnNoYTI1NhgIIAEoCSLcAgoOQ2hhdEluZ3Jlc3NBY2sSDgoGcnVuX2lkGAEgASgJEhcKD2NvbnZlcnNhdGlvbl9pZBgCIAEoCRIZChFjb21taXR0ZWRfdGhyb3VnaBgDIAEoBBIVCg1leHBlY3RlZF9uZXh0GAQgASgEEjsKBmFjdGlvbhgFIAEoDjIrLmxpdmVhZ2VudC5nYXRld2F5LnYyLkNoYXRJbmdyZXNzQWNrLkFjdGlvbhIaChJ0ZXJtaW5hbF9jb21taXR0ZWQYBiABKAgSEgoKZXJyb3JfY29kZRgHIAEoCRIVCg1lcnJvcl9tZXNzYWdlGAggASgJImsKBkFjdGlvbhIWChJBQ1RJT05fVU5TUEVDSUZJRUQQABIMCghDT05USU5VRRABEhgKFFJFUExBWV9GUk9NX0VYUEVDVEVEEAISEwoPU0VORF9DSEVDS1BPSU5UEAMSDAoIUkVKRUNURUQQBCI8ChRIaXN0b3J5U2V0Q3dkUmVxdWVzdBIXCg9jb252ZXJzYXRpb25faWQYASABKAkSCwoDY3dkGAIgASgJIlgKFUhpc3RvcnlTZXRDd2RSZXNwb25zZRI/Cgxjb252ZXJzYXRpb24YASABKAsyKS5saXZlYWdlbnQuZ2F0ZXdheS52Mi5Db252ZXJzYXRpb25TdW1tYXJ5ImYKF1dvcmtzcGFjZVJvb3RHcmFudERyYWZ0Eg8KAmlkGAEgASgJSACIAQESDQoFYWxpYXMYAiABKAkSFAoMZGlzcGxheV9wYXRoGAMgASgJEg4KBmFjY2VzcxgEIAEoCUIFCgNfaWQi0gEKEldvcmtzcGFjZVJvb3RHcmFudBIKCgJpZBgBIAEoCRISCgpwcm9qZWN0X2lkGAIgASgJEhgKEHByb2plY3RfcGF0aF9rZXkYAyABKAkSDQoFYWxpYXMYBCABKAkSFAoMZGlzcGxheV9wYXRoGAUgASgJEhYKDmNhbm9uaWNhbF9wYXRoGAYgASgJEg4KBmFjY2VzcxgHIAEoCRINCgVzdGF0ZRgIIAEoCRISCgpjcmVhdGVkX2F0GAkgASgDEhIKCnVwZGF0ZWRfYXQYCiABKAMilQEKGldvcmtzcGFjZVJvb3RHcmFudHNSZXF1ZXN0Eg4KBmFjdGlvbhgBIAEoCRISCgpwcm9qZWN0X2lkGAIgASgJEhQKDHByb2plY3RfcGF0aBgDIAEoCRI9CgZncmFudHMYBCADKAsyLS5saXZlYWdlbnQuZ2F0ZXdheS52Mi5Xb3Jrc3BhY2VSb290R3JhbnREcmFmdCJXChtXb3Jrc3BhY2VSb290R3JhbnRzUmVzcG9uc2USOAoGZ3JhbnRzGAEgAygLMigubGl2ZWFnZW50LmdhdGV3YXkudjIuV29ya3NwYWNlUm9vdEdyYW50IjwKF0NoZWNrcG9pbnRFeHBlY3RlZEVudHJ5EgsKA2tleRgBIAEoCRIUCgxjdXJyZW50X2hhc2gYAiABKAkiqQEKEUNoZWNrcG9pbnRSZXF1ZXN0Eg4KBmFjdGlvbhgBIAEoCRIXCg9jb252ZXJzYXRpb25faWQYAiABKAkSEAoIdHVybl9zZXEYAyABKAQSGAoQYXV0aG9yaXplZF9yb290cxgEIAMoCRI/CghleHBlY3RlZBgFIAMoCzItLmxpdmVhZ2VudC5nYXRld2F5LnYyLkNoZWNrcG9pbnRFeHBlY3RlZEVudHJ5IjkKEkNoZWNrcG9pbnRSZXNwb25zZRIOCgZhY3Rpb24YASABKAkSEwoLcmVzdWx0X2pzb24YAiABKAkq5QEKGEltcG9ydERpcmVjdG9yeU9wZXJhdGlvbhIqCiZJTVBPUlRfRElSRUNUT1JZX09QRVJBVElPTl9VTlNQRUNJRklFRBAAEiQKIElNUE9SVF9ESVJFQ1RPUllfT1BFUkFUSU9OX1NUQVJUEAESKgomSU1QT1JUX0RJUkVDVE9SWV9PUEVSQVRJT05fV1JJVEVfQ0hVTksQAhIlCiFJTVBPUlRfRElSRUNUT1JZX09QRVJBVElPTl9DT01NSVQQAxIkCiBJTVBPUlRfRElSRUNUT1JZX09QRVJBVElPTl9BQk9SVBAEKsYECg9UdW5uZWxGcmFtZUtpbmQSIQodVFVOTkVMX0ZSQU1FX0tJTkRfVU5TUEVDSUZJRUQQABIoCiRUVU5ORUxfRlJBTUVfS0lORF9IVFRQX1JFUVVFU1RfU1RBUlQQARInCiNUVU5ORUxfRlJBTUVfS0lORF9IVFRQX1JFUVVFU1RfQk9EWRACEiYKIlRVTk5FTF9GUkFNRV9LSU5EX0hUVFBfUkVRVUVTVF9FTkQQAxIpCiVUVU5ORUxfRlJBTUVfS0lORF9IVFRQX1JFU1BPTlNFX1NUQVJUEAQSKAokVFVOTkVMX0ZSQU1FX0tJTkRfSFRUUF9SRVNQT05TRV9CT0RZEAUSJwojVFVOTkVMX0ZSQU1FX0tJTkRfSFRUUF9SRVNQT05TRV9FTkQQBhIdChlUVU5ORUxfRlJBTUVfS0lORF9XU19ESUFMEAcSIAocVFVOTkVMX0ZSQU1FX0tJTkRfV1NfRElBTF9PSxAIEiMKH1RVTk5FTF9GUkFNRV9LSU5EX1dTX0RJQUxfRVJST1IQCRIeChpUVU5ORUxfRlJBTUVfS0lORF9XU19GUkFNRRAKEh4KGlRVTk5FTF9GUkFNRV9LSU5EX1dTX0NMT1NFEAsSGwoXVFVOTkVMX0ZSQU1FX0tJTkRfRVJST1IQDBIcChhUVU5ORUxfRlJBTUVfS0lORF9DQU5DRUwQDRIaChZUVU5ORUxfRlJBTUVfS0lORF9QSU5HEA4SGgoWVFVOTkVMX0ZSQU1FX0tJTkRfUE9ORxAPKoEBChNUdW5uZWxXc01lc3NhZ2VUeXBlEiYKIlRVTk5FTF9XU19NRVNTQUdFX1RZUEVfVU5TUEVDSUZJRUQQABIfChtUVU5ORUxfV1NfTUVTU0FHRV9UWVBFX1RFWFQQARIhCh1UVU5ORUxfV1NfTUVTU0FHRV9UWVBFX0JJTkFSWRACQkBaPmdpdGh1Yi5jb20vbGl2ZWFnZW50L2FnZW50LWdhdGV3YXkvaW50ZXJuYWwvcHJvdG8vdjI7Z2F0ZXdheXYyYgZwcm90bzM"); + fileDesc("ChZwcm90by92Mi9nYXRld2F5LnByb3RvEhRsaXZlYWdlbnQuZ2F0ZXdheS52MiKjHgoPR2F0ZXdheUVudmVsb3BlEhIKCnJlcXVlc3RfaWQYASABKAkSEQoJdGltZXN0YW1wGAIgASgDEkAKDGNoYXRfY29tbWFuZBgKIAEoCzIoLmxpdmVhZ2VudC5nYXRld2F5LnYyLkNoYXRDb21tYW5kUmVxdWVzdEgAEj4KC2Nyb25fbWFuYWdlGBQgASgLMicubGl2ZWFnZW50LmdhdGV3YXkudjIuQ3Jvbk1hbmFnZVJlcXVlc3RIABJACgxoaXN0b3J5X2xpc3QYHiABKAsyKC5saXZlYWdlbnQuZ2F0ZXdheS52Mi5IaXN0b3J5TGlzdFJlcXVlc3RIABI+CgtoaXN0b3J5X2dldBgfIAEoCzInLmxpdmVhZ2VudC5nYXRld2F5LnYyLkhpc3RvcnlHZXRSZXF1ZXN0SAASRAoOaGlzdG9yeV9yZW5hbWUYICABKAsyKi5saXZlYWdlbnQuZ2F0ZXdheS52Mi5IaXN0b3J5UmVuYW1lUmVxdWVzdEgAEkQKDmhpc3RvcnlfZGVsZXRlGCEgASgLMioubGl2ZWFnZW50LmdhdGV3YXkudjIuSGlzdG9yeURlbGV0ZVJlcXVlc3RIABJECg5oaXN0b3J5X3ByZWZpeBgiIAEoCzIqLmxpdmVhZ2VudC5nYXRld2F5LnYyLkhpc3RvcnlQcmVmaXhSZXF1ZXN0SAASPgoLaGlzdG9yeV9waW4YIyABKAsyJy5saXZlYWdlbnQuZ2F0ZXdheS52Mi5IaXN0b3J5UGluUmVxdWVzdEgAEkkKEWhpc3Rvcnlfc2hhcmVfZ2V0GCQgASgLMiwubGl2ZWFnZW50LmdhdGV3YXkudjIuSGlzdG9yeVNoYXJlR2V0UmVxdWVzdEgAEkkKEWhpc3Rvcnlfc2hhcmVfc2V0GCUgASgLMiwubGl2ZWFnZW50LmdhdGV3YXkudjIuSGlzdG9yeVNoYXJlU2V0UmVxdWVzdEgAElEKFWhpc3Rvcnlfc2hhcmVfcmVzb2x2ZRgmIAEoCzIwLmxpdmVhZ2VudC5nYXRld2F5LnYyLkhpc3RvcnlTaGFyZVJlc29sdmVSZXF1ZXN0SAASSAoQaGlzdG9yeV93b3JrZGlycxgnIAEoCzIsLmxpdmVhZ2VudC5nYXRld2F5LnYyLkhpc3RvcnlXb3JrZGlyc1JlcXVlc3RIABJCCg1wcm92aWRlcl9saXN0GCggASgLMikubGl2ZWFnZW50LmdhdGV3YXkudjIuUHJvdmlkZXJMaXN0UmVxdWVzdEgAEkAKDHNldHRpbmdzX2dldBgpIAEoCzIoLmxpdmVhZ2VudC5nYXRld2F5LnYyLlNldHRpbmdzR2V0UmVxdWVzdEgAEkYKD3NldHRpbmdzX3VwZGF0ZRgqIAEoCzIrLmxpdmVhZ2VudC5nYXRld2F5LnYyLlNldHRpbmdzVXBkYXRlUmVxdWVzdEgAEkcKEHNraWxsX2ZpbGVzX2xpc3QYKyABKAsyKy5saXZlYWdlbnQuZ2F0ZXdheS52Mi5Ta2lsbEZpbGVzTGlzdFJlcXVlc3RIABJNChNza2lsbF9tZXRhZGF0YV9yZWFkGCwgASgLMi4ubGl2ZWFnZW50LmdhdGV3YXkudjIuU2tpbGxNZXRhZGF0YVJlYWRSZXF1ZXN0SAASRQoPc2tpbGxfdGV4dF9yZWFkGC0gASgLMioubGl2ZWFnZW50LmdhdGV3YXkudjIuU2tpbGxUZXh0UmVhZFJlcXVlc3RIABJJChFmaWxlX21lbnRpb25fbGlzdBguIAEoCzIsLmxpdmVhZ2VudC5nYXRld2F5LnYyLkZpbGVNZW50aW9uTGlzdFJlcXVlc3RIABJRChV1cGxvYWRfcmVhZGFibGVfZmlsZXMYLyABKAsyMC5saXZlYWdlbnQuZ2F0ZXdheS52Mi5VcGxvYWRSZWFkYWJsZUZpbGVzUmVxdWVzdEgAEjgKCGZzX3Jvb3RzGDAgASgLMiQubGl2ZWFnZW50LmdhdGV3YXkudjIuRnNSb290c1JlcXVlc3RIABI/Cgxmc19saXN0X2RpcnMYMSABKAsyJy5saXZlYWdlbnQuZ2F0ZXdheS52Mi5Gc0xpc3REaXJzUmVxdWVzdEgAEjEKBHBpbmcYMiABKAsyIS5saXZlYWdlbnQuZ2F0ZXdheS52Mi5QaW5nUmVxdWVzdEgAElMKFnVwbG9hZGVkX2ltYWdlX3ByZXZpZXcYMyABKAsyMS5saXZlYWdlbnQuZ2F0ZXdheS52Mi5VcGxvYWRlZEltYWdlUHJldmlld1JlcXVlc3RIABJCCg1tZW1vcnlfbWFuYWdlGDQgASgLMikubGl2ZWFnZW50LmdhdGV3YXkudjIuTWVtb3J5TWFuYWdlUmVxdWVzdEgAEkAKDHNraWxsX21hbmFnZRg1IAEoCzIoLmxpdmVhZ2VudC5nYXRld2F5LnYyLlNraWxsTWFuYWdlUmVxdWVzdEgAElYKGGZzX2NyZWF0ZV9wcm9qZWN0X2ZvbGRlchg2IAEoCzIyLmxpdmVhZ2VudC5nYXRld2F5LnYyLkZzQ3JlYXRlUHJvamVjdEZvbGRlclJlcXVlc3RIABJBChB0ZXJtaW5hbF9yZXF1ZXN0GDcgASgLMiUubGl2ZWFnZW50LmdhdGV3YXkudjIuVGVybWluYWxSZXF1ZXN0SAASNgoHZnNfbGlzdBg4IAEoCzIjLmxpdmVhZ2VudC5nYXRld2F5LnYyLkZzTGlzdFJlcXVlc3RIABJBCg1mc193cml0ZV90ZXh0GDkgASgLMigubGl2ZWFnZW50LmdhdGV3YXkudjIuRnNXcml0ZVRleHRSZXF1ZXN0SAASQQoNZnNfY3JlYXRlX2Rpchg6IAEoCzIoLmxpdmVhZ2VudC5nYXRld2F5LnYyLkZzQ3JlYXRlRGlyUmVxdWVzdEgAEjoKCWZzX3JlbmFtZRg7IAEoCzIlLmxpdmVhZ2VudC5nYXRld2F5LnYyLkZzUmVuYW1lUmVxdWVzdEgAEjoKCWZzX2RlbGV0ZRg8IAEoCzIlLmxpdmVhZ2VudC5nYXRld2F5LnYyLkZzRGVsZXRlUmVxdWVzdEgAEjcKC2dpdF9yZXF1ZXN0GD0gASgLMiAubGl2ZWFnZW50LmdhdGV3YXkudjIuR2l0UmVxdWVzdEgAElAKFWZzX3JlYWRfZWRpdGFibGVfdGV4dBg+IAEoCzIvLmxpdmVhZ2VudC5nYXRld2F5LnYyLkZzUmVhZEVkaXRhYmxlVGV4dFJlcXVlc3RIABJUChdmc19yZWFkX3dvcmtzcGFjZV9pbWFnZRg/IAEoCzIxLmxpdmVhZ2VudC5nYXRld2F5LnYyLkZzUmVhZFdvcmtzcGFjZUltYWdlUmVxdWVzdEgAEjkKDHNmdHBfcmVxdWVzdBhAIAEoCzIhLmxpdmVhZ2VudC5nYXRld2F5LnYyLlNmdHBSZXF1ZXN0SAASRgoPcHJvdmlkZXJfbW9kZWxzGEEgASgLMisubGl2ZWFnZW50LmdhdGV3YXkudjIuUHJvdmlkZXJNb2RlbHNSZXF1ZXN0SAASXwodc2V0dGluZ3NfcmVzZXRfc3NoX2tub3duX2hvc3QYSCABKAsyNi5saXZlYWdlbnQuZ2F0ZXdheS52Mi5TZXR0aW5nc1Jlc2V0U3NoS25vd25Ib3N0UmVxdWVzdEgAEjwKCmNoYXRfcXVldWUYSSABKAsyJi5saXZlYWdlbnQuZ2F0ZXdheS52Mi5DaGF0UXVldWVSZXF1ZXN0SAASQAoQY2hhdF9pbmdyZXNzX2FjaxhLIAEoCzIkLmxpdmVhZ2VudC5nYXRld2F5LnYyLkNoYXRJbmdyZXNzQWNrSAASQQoMdHVubmVsX3N0YXRlGFAgASgLMikubGl2ZWFnZW50LmdhdGV3YXkudjIuVHVubmVsU3RhdGVTbmFwc2hvdEgAEj8KD3R1bm5lbF9tdXRhdGlvbhhRIAEoCzIkLmxpdmVhZ2VudC5nYXRld2F5LnYyLlR1bm5lbE11dGF0aW9uSAASOQoMdHVubmVsX2ZyYW1lGFIgASgLMiEubGl2ZWFnZW50LmdhdGV3YXkudjIuVHVubmVsRnJhbWVIABJGCg93b3Jrc3BhY2Vfd2F0Y2gYWiABKAsyKy5saXZlYWdlbnQuZ2F0ZXdheS52Mi5Xb3Jrc3BhY2VXYXRjaFJlcXVlc3RIABJOChdtYW5hZ2VkX3Byb2Nlc3NfcmVxdWVzdBhbIAEoCzIrLmxpdmVhZ2VudC5nYXRld2F5LnYyLk1hbmFnZWRQcm9jZXNzUmVxdWVzdEgAEkQKDmhpc3RvcnlfYnJhbmNoGFwgASgLMioubGl2ZWFnZW50LmdhdGV3YXkudjIuSGlzdG9yeUJyYW5jaFJlcXVlc3RIABJECg5wcm92aWRlcl91c2FnZRhdIAEoCzIqLmxpdmVhZ2VudC5nYXRld2F5LnYyLlByb3ZpZGVyVXNhZ2VSZXF1ZXN0SAASQwoOY2hhdF9maWxlX29wZW4YXiABKAsyKS5saXZlYWdlbnQuZ2F0ZXdheS52Mi5DaGF0RmlsZU9wZW5SZXF1ZXN0SAASRQoPaGlzdG9yeV9zZXRfY3dkGF8gASgLMioubGl2ZWFnZW50LmdhdGV3YXkudjIuSGlzdG9yeVNldEN3ZFJlcXVlc3RIABJRChV3b3Jrc3BhY2Vfcm9vdF9ncmFudHMYYCABKAsyMC5saXZlYWdlbnQuZ2F0ZXdheS52Mi5Xb3Jrc3BhY2VSb290R3JhbnRzUmVxdWVzdEgAEkgKEGltcG9ydF9kaXJlY3RvcnkYYSABKAsyLC5saXZlYWdlbnQuZ2F0ZXdheS52Mi5JbXBvcnREaXJlY3RvcnlSZXF1ZXN0SAASPQoKY2hlY2twb2ludBhiIAEoCzInLmxpdmVhZ2VudC5nYXRld2F5LnYyLkNoZWNrcG9pbnRSZXF1ZXN0SAASSAoQdHJhamVjdG9yeV9mZXRjaBhjIAEoCzIsLmxpdmVhZ2VudC5nYXRld2F5LnYyLlRyYWplY3RvcnlGZXRjaFJlcXVlc3RIAEIJCgdwYXlsb2FkSgQIQxBESgQIRBBFSgQIRRBGSgQIShBLIqUnCg1BZ2VudEVudmVsb3BlEhIKCnJlcXVlc3RfaWQYASABKAkSEQoJdGltZXN0YW1wGAIgASgDEjUKCmNoYXRfZXZlbnQYCiABKAsyHy5saXZlYWdlbnQuZ2F0ZXdheS52Mi5DaGF0RXZlbnRIABJEChBjcm9uX21hbmFnZV9yZXNwGBQgASgLMigubGl2ZWFnZW50LmdhdGV3YXkudjIuQ3Jvbk1hbmFnZVJlc3BvbnNlSAASRgoRaGlzdG9yeV9saXN0X3Jlc3AYHiABKAsyKS5saXZlYWdlbnQuZ2F0ZXdheS52Mi5IaXN0b3J5TGlzdFJlc3BvbnNlSAASRAoQaGlzdG9yeV9nZXRfcmVzcBgfIAEoCzIoLmxpdmVhZ2VudC5nYXRld2F5LnYyLkhpc3RvcnlHZXRSZXNwb25zZUgAEkoKE2hpc3RvcnlfcmVuYW1lX3Jlc3AYICABKAsyKy5saXZlYWdlbnQuZ2F0ZXdheS52Mi5IaXN0b3J5UmVuYW1lUmVzcG9uc2VIABJKChNoaXN0b3J5X2RlbGV0ZV9yZXNwGCEgASgLMisubGl2ZWFnZW50LmdhdGV3YXkudjIuSGlzdG9yeURlbGV0ZVJlc3BvbnNlSAASPgoMaGlzdG9yeV9zeW5jGCIgASgLMiYubGl2ZWFnZW50LmdhdGV3YXkudjIuSGlzdG9yeVN5bmNFdmVudEgAEkoKE2hpc3RvcnlfcHJlZml4X3Jlc3AYIyABKAsyKy5saXZlYWdlbnQuZ2F0ZXdheS52Mi5IaXN0b3J5UHJlZml4UmVzcG9uc2VIABJEChBoaXN0b3J5X3Bpbl9yZXNwGCQgASgLMigubGl2ZWFnZW50LmdhdGV3YXkudjIuSGlzdG9yeVBpblJlc3BvbnNlSAASTwoWaGlzdG9yeV9zaGFyZV9nZXRfcmVzcBglIAEoCzItLmxpdmVhZ2VudC5nYXRld2F5LnYyLkhpc3RvcnlTaGFyZUdldFJlc3BvbnNlSAASTwoWaGlzdG9yeV9zaGFyZV9zZXRfcmVzcBgmIAEoCzItLmxpdmVhZ2VudC5nYXRld2F5LnYyLkhpc3RvcnlTaGFyZVNldFJlc3BvbnNlSAASVwoaaGlzdG9yeV9zaGFyZV9yZXNvbHZlX3Jlc3AYJyABKAsyMS5saXZlYWdlbnQuZ2F0ZXdheS52Mi5IaXN0b3J5U2hhcmVSZXNvbHZlUmVzcG9uc2VIABJOChVoaXN0b3J5X3dvcmtkaXJzX3Jlc3AYOCABKAsyLS5saXZlYWdlbnQuZ2F0ZXdheS52Mi5IaXN0b3J5V29ya2RpcnNSZXNwb25zZUgAEkgKEnByb3ZpZGVyX2xpc3RfcmVzcBgoIAEoCzIqLmxpdmVhZ2VudC5nYXRld2F5LnYyLlByb3ZpZGVyTGlzdFJlc3BvbnNlSAASRgoRc2V0dGluZ3NfZ2V0X3Jlc3AYKSABKAsyKS5saXZlYWdlbnQuZ2F0ZXdheS52Mi5TZXR0aW5nc0dldFJlc3BvbnNlSAASTAoUc2V0dGluZ3NfdXBkYXRlX3Jlc3AYKiABKAsyLC5saXZlYWdlbnQuZ2F0ZXdheS52Mi5TZXR0aW5nc1VwZGF0ZVJlc3BvbnNlSAASQAoNc2V0dGluZ3Nfc3luYxgrIAEoCzInLmxpdmVhZ2VudC5nYXRld2F5LnYyLlNldHRpbmdzU3luY0V2ZW50SAASTQoVc2tpbGxfZmlsZXNfbGlzdF9yZXNwGCwgASgLMiwubGl2ZWFnZW50LmdhdGV3YXkudjIuU2tpbGxGaWxlc0xpc3RSZXNwb25zZUgAElMKGHNraWxsX21ldGFkYXRhX3JlYWRfcmVzcBgtIAEoCzIvLmxpdmVhZ2VudC5nYXRld2F5LnYyLlNraWxsTWV0YWRhdGFSZWFkUmVzcG9uc2VIABJLChRza2lsbF90ZXh0X3JlYWRfcmVzcBguIAEoCzIrLmxpdmVhZ2VudC5nYXRld2F5LnYyLlNraWxsVGV4dFJlYWRSZXNwb25zZUgAEk8KFmZpbGVfbWVudGlvbl9saXN0X3Jlc3AYLyABKAsyLS5saXZlYWdlbnQuZ2F0ZXdheS52Mi5GaWxlTWVudGlvbkxpc3RSZXNwb25zZUgAElcKGnVwbG9hZF9yZWFkYWJsZV9maWxlc19yZXNwGDAgASgLMjEubGl2ZWFnZW50LmdhdGV3YXkudjIuVXBsb2FkUmVhZGFibGVGaWxlc1Jlc3BvbnNlSAASPgoNZnNfcm9vdHNfcmVzcBgxIAEoCzIlLmxpdmVhZ2VudC5nYXRld2F5LnYyLkZzUm9vdHNSZXNwb25zZUgAEjIKBHBvbmcYMiABKAsyIi5saXZlYWdlbnQuZ2F0ZXdheS52Mi5Qb25nUmVzcG9uc2VIABJFChFmc19saXN0X2RpcnNfcmVzcBgzIAEoCzIoLmxpdmVhZ2VudC5nYXRld2F5LnYyLkZzTGlzdERpcnNSZXNwb25zZUgAElkKG3VwbG9hZGVkX2ltYWdlX3ByZXZpZXdfcmVzcBg0IAEoCzIyLmxpdmVhZ2VudC5nYXRld2F5LnYyLlVwbG9hZGVkSW1hZ2VQcmV2aWV3UmVzcG9uc2VIABJIChJtZW1vcnlfbWFuYWdlX3Jlc3AYNSABKAsyKi5saXZlYWdlbnQuZ2F0ZXdheS52Mi5NZW1vcnlNYW5hZ2VSZXNwb25zZUgAEkYKEXNraWxsX21hbmFnZV9yZXNwGDYgASgLMikubGl2ZWFnZW50LmdhdGV3YXkudjIuU2tpbGxNYW5hZ2VSZXNwb25zZUgAElwKHWZzX2NyZWF0ZV9wcm9qZWN0X2ZvbGRlcl9yZXNwGDcgASgLMjMubGl2ZWFnZW50LmdhdGV3YXkudjIuRnNDcmVhdGVQcm9qZWN0Rm9sZGVyUmVzcG9uc2VIABJDChF0ZXJtaW5hbF9yZXNwb25zZRg5IAEoCzImLmxpdmVhZ2VudC5nYXRld2F5LnYyLlRlcm1pbmFsUmVzcG9uc2VIABI9Cg50ZXJtaW5hbF9ldmVudBg6IAEoCzIjLmxpdmVhZ2VudC5nYXRld2F5LnYyLlRlcm1pbmFsRXZlbnRIABI8Cgxmc19saXN0X3Jlc3AYOyABKAsyJC5saXZlYWdlbnQuZ2F0ZXdheS52Mi5Gc0xpc3RSZXNwb25zZUgAEkcKEmZzX3dyaXRlX3RleHRfcmVzcBg8IAEoCzIpLmxpdmVhZ2VudC5nYXRld2F5LnYyLkZzV3JpdGVUZXh0UmVzcG9uc2VIABJHChJmc19jcmVhdGVfZGlyX3Jlc3AYPSABKAsyKS5saXZlYWdlbnQuZ2F0ZXdheS52Mi5Gc0NyZWF0ZURpclJlc3BvbnNlSAASQAoOZnNfcmVuYW1lX3Jlc3AYPiABKAsyJi5saXZlYWdlbnQuZ2F0ZXdheS52Mi5Gc1JlbmFtZVJlc3BvbnNlSAASQAoOZnNfZGVsZXRlX3Jlc3AYPyABKAsyJi5saXZlYWdlbnQuZ2F0ZXdheS52Mi5Gc0RlbGV0ZVJlc3BvbnNlSAASOQoMZ2l0X3Jlc3BvbnNlGEAgASgLMiEubGl2ZWFnZW50LmdhdGV3YXkudjIuR2l0UmVzcG9uc2VIABJWChpmc19yZWFkX2VkaXRhYmxlX3RleHRfcmVzcBhBIAEoCzIwLmxpdmVhZ2VudC5nYXRld2F5LnYyLkZzUmVhZEVkaXRhYmxlVGV4dFJlc3BvbnNlSAASWgocZnNfcmVhZF93b3Jrc3BhY2VfaW1hZ2VfcmVzcBhCIAEoCzIyLmxpdmVhZ2VudC5nYXRld2F5LnYyLkZzUmVhZFdvcmtzcGFjZUltYWdlUmVzcG9uc2VIABI7Cg1zZnRwX3Jlc3BvbnNlGEkgASgLMiIubGl2ZWFnZW50LmdhdGV3YXkudjIuU2Z0cFJlc3BvbnNlSAASNQoKc2Z0cF9ldmVudBhKIAEoCzIfLmxpdmVhZ2VudC5nYXRld2F5LnYyLlNmdHBFdmVudEgAEkIKD2NoYXRfcXVldWVfcmVzcBhLIAEoCzInLmxpdmVhZ2VudC5nYXRld2F5LnYyLkNoYXRRdWV1ZVJlc3BvbnNlSAASQAoQY2hhdF9xdWV1ZV9ldmVudBhMIAEoCzIkLmxpdmVhZ2VudC5nYXRld2F5LnYyLkNoYXRRdWV1ZUV2ZW50SAASPgoMY2hhdF9jb250cm9sGEYgASgLMiYubGl2ZWFnZW50LmdhdGV3YXkudjIuQ2hhdENvbnRyb2xFdmVudEgAEkIKDnJ1bnRpbWVfc3RhdHVzGEcgASgLMigubGl2ZWFnZW50LmdhdGV3YXkudjIuUnVudGltZVN0YXR1c0V2ZW50SAASZQoic2V0dGluZ3NfcmVzZXRfc3NoX2tub3duX2hvc3RfcmVzcBhIIAEoCzI3LmxpdmVhZ2VudC5nYXRld2F5LnYyLlNldHRpbmdzUmVzZXRTc2hLbm93bkhvc3RSZXNwb25zZUgAEkoKFWNoYXRfcnVudGltZV9zbmFwc2hvdBhNIAEoCzIpLmxpdmVhZ2VudC5nYXRld2F5LnYyLkNoYXRSdW50aW1lU25hcHNob3RIABJMChRwcm92aWRlcl9tb2RlbHNfcmVzcBhPIAEoCzIsLmxpdmVhZ2VudC5nYXRld2F5LnYyLlByb3ZpZGVyTW9kZWxzUmVzcG9uc2VIABJCCg50dW5uZWxfZGVzaXJlZBhQIAEoCzIoLmxpdmVhZ2VudC5nYXRld2F5LnYyLlR1bm5lbERlc2lyZWRTdGF0ZUgAEkwKFnR1bm5lbF9tdXRhdGlvbl9yZXN1bHQYUSABKAsyKi5saXZlYWdlbnQuZ2F0ZXdheS52Mi5UdW5uZWxNdXRhdGlvblJlc3VsdEgAEjkKDHR1bm5lbF9mcmFtZRhSIAEoCzIhLmxpdmVhZ2VudC5nYXRld2F5LnYyLlR1bm5lbEZyYW1lSAASRgoTdHVubmVsX3Byb2JlX3JlcG9ydBhTIAEoCzInLmxpdmVhZ2VudC5nYXRld2F5LnYyLlR1bm5lbFByb2JlUmVwb3J0SAASSgoSd29ya3NwYWNlX2FjdGl2aXR5GFogASgLMiwubGl2ZWFnZW50LmdhdGV3YXkudjIuV29ya3NwYWNlQWN0aXZpdHlFdmVudEgAElAKGG1hbmFnZWRfcHJvY2Vzc19yZXNwb25zZRhbIAEoCzIsLmxpdmVhZ2VudC5nYXRld2F5LnYyLk1hbmFnZWRQcm9jZXNzUmVzcG9uc2VIABJQChhtYW5hZ2VkX3Byb2Nlc3Nfc25hcHNob3QYXCABKAsyLC5saXZlYWdlbnQuZ2F0ZXdheS52Mi5NYW5hZ2VkUHJvY2Vzc1NuYXBzaG90SAASSgoTaGlzdG9yeV9icmFuY2hfcmVzcBhdIAEoCzIrLmxpdmVhZ2VudC5nYXRld2F5LnYyLkhpc3RvcnlCcmFuY2hSZXNwb25zZUgAEkoKE3Byb3ZpZGVyX3VzYWdlX3Jlc3AYXiABKAsyKy5saXZlYWdlbnQuZ2F0ZXdheS52Mi5Qcm92aWRlclVzYWdlUmVzcG9uc2VIABJEChJjaGF0X2luZ3Jlc3NfYmF0Y2gYXyABKAsyJi5saXZlYWdlbnQuZ2F0ZXdheS52Mi5DaGF0SW5ncmVzc0JhdGNoSAASRgoTY2hhdF9pbmdyZXNzX3Jlc3VtZRhgIAEoCzInLmxpdmVhZ2VudC5nYXRld2F5LnYyLkNoYXRJbmdyZXNzUmVzdW1lSAASSgoVY2hhdF9pbmdyZXNzX2ZyYWdtZW50GGEgASgLMikubGl2ZWFnZW50LmdhdGV3YXkudjIuQ2hhdEluZ3Jlc3NGcmFnbWVudEgAEkkKE2NoYXRfZmlsZV9vcGVuX3Jlc3AYYiABKAsyKi5saXZlYWdlbnQuZ2F0ZXdheS52Mi5DaGF0RmlsZU9wZW5SZXNwb25zZUgAEjQKBWVycm9yGGMgASgLMiMubGl2ZWFnZW50LmdhdGV3YXkudjIuRXJyb3JSZXNwb25zZUgAEksKFGhpc3Rvcnlfc2V0X2N3ZF9yZXNwGGQgASgLMisubGl2ZWFnZW50LmdhdGV3YXkudjIuSGlzdG9yeVNldEN3ZFJlc3BvbnNlSAASVwoad29ya3NwYWNlX3Jvb3RfZ3JhbnRzX3Jlc3AYZSABKAsyMS5saXZlYWdlbnQuZ2F0ZXdheS52Mi5Xb3Jrc3BhY2VSb290R3JhbnRzUmVzcG9uc2VIABJOChVpbXBvcnRfZGlyZWN0b3J5X3Jlc3AYZiABKAsyLS5saXZlYWdlbnQuZ2F0ZXdheS52Mi5JbXBvcnREaXJlY3RvcnlSZXNwb25zZUgAEkMKD2NoZWNrcG9pbnRfcmVzcBhnIAEoCzIoLmxpdmVhZ2VudC5nYXRld2F5LnYyLkNoZWNrcG9pbnRSZXNwb25zZUgAEk4KFXRyYWplY3RvcnlfZmV0Y2hfcmVzcBhoIAEoCzItLmxpdmVhZ2VudC5nYXRld2F5LnYyLlRyYWplY3RvcnlGZXRjaFJlc3BvbnNlSABCCQoHcGF5bG9hZEoECEMQREoECEQQRUoECEUQRkoECE4QTyJVChFDaGF0U2VsZWN0ZWRNb2RlbBIaChJjdXN0b21fcHJvdmlkZXJfaWQYASABKAkSDQoFbW9kZWwYAiABKAkSFQoNcHJvdmlkZXJfdHlwZRgDIAEoCSJlChNDaGF0UnVudGltZUNvbnRyb2xzEhgKEHRoaW5raW5nX2VuYWJsZWQYASABKAgSIQoZbmF0aXZlX3dlYl9zZWFyY2hfZW5hYmxlZBgCIAEoCBIRCglyZWFzb25pbmcYAyABKAkidQoQQ2hhdFVwbG9hZGVkRmlsZRIVCg1yZWxhdGl2ZV9wYXRoGAEgASgJEhUKDWFic29sdXRlX3BhdGgYAiABKAkSEQoJZmlsZV9uYW1lGAMgASgJEgwKBGtpbmQYBCABKAkSEgoKc2l6ZV9ieXRlcxgFIAEoAyJLChJVcGxvYWRSZWFkYWJsZUZpbGUSEQoJZmlsZV9uYW1lGAEgASgJEhEKCW1pbWVfdHlwZRgCIAEoCRIPCgdjb250ZW50GAMgASgMImYKGlVwbG9hZFJlYWRhYmxlRmlsZXNSZXF1ZXN0Eg8KB3dvcmtkaXIYASABKAkSNwoFZmlsZXMYAiADKAsyKC5saXZlYWdlbnQuZ2F0ZXdheS52Mi5VcGxvYWRSZWFkYWJsZUZpbGUiZQobVXBsb2FkUmVhZGFibGVGaWxlc1Jlc3BvbnNlEjUKBWZpbGVzGAEgAygLMiYubGl2ZWFnZW50LmdhdGV3YXkudjIuQ2hhdFVwbG9hZGVkRmlsZRIPCgdza2lwcGVkGAIgAygJIj0KE0ltcG9ydERpcmVjdG9yeUZpbGUSFQoNcmVsYXRpdmVfcGF0aBgBIAEoCRIPCgdjb250ZW50GAIgASgMIsMCChZJbXBvcnREaXJlY3RvcnlSZXF1ZXN0EgwKBG5hbWUYASABKAkSDgoGdGFyZ2V0GAIgASgJEjwKBWZpbGVzGAMgAygLMikubGl2ZWFnZW50LmdhdGV3YXkudjIuSW1wb3J0RGlyZWN0b3J5RmlsZUICGAESEwoLdHJhbnNmZXJfaWQYBCABKAkSQQoJb3BlcmF0aW9uGAUgASgOMi4ubGl2ZWFnZW50LmdhdGV3YXkudjIuSW1wb3J0RGlyZWN0b3J5T3BlcmF0aW9uEhMKC3RvdGFsX2ZpbGVzGAYgASgNEhMKC3RvdGFsX2J5dGVzGAcgASgEEhUKDXJlbGF0aXZlX3BhdGgYCCABKAkSDgoGb2Zmc2V0GAkgASgEEg0KBWNodW5rGAogASgMEhUKDWZpbGVfY29tcGxldGUYCyABKAgifgoXSW1wb3J0RGlyZWN0b3J5UmVzcG9uc2USEQoJcm9vdF9wYXRoGAEgASgJEhIKCmZpbGVfY291bnQYAiABKAUSDwoHc2tpcHBlZBgDIAMoCRITCgt0cmFuc2Zlcl9pZBgEIAEoCRIWCg5yZWNlaXZlZF9ieXRlcxgFIAEoBCJFChtVcGxvYWRlZEltYWdlUHJldmlld1JlcXVlc3QSDwoHd29ya2RpchgBIAEoCRIVCg1hYnNvbHV0ZV9wYXRoGAIgASgJIj8KHFVwbG9hZGVkSW1hZ2VQcmV2aWV3UmVzcG9uc2USEQoJbWltZV90eXBlGAEgASgJEgwKBGRhdGEYAiABKAkiewoKVHVubmVsU3BlYxIKCgJpZBgBIAEoCRIRCglzbHVnX2hpbnQYAiABKAkSDAoEbmFtZRgDIAEoCRISCgp0YXJnZXRfdXJsGAQgASgJEhIKCmV4cGlyZXNfYXQYBSABKAMSGAoQcHJvamVjdF9wYXRoX2tleRgGIAEoCSJZChJUdW5uZWxEZXNpcmVkU3RhdGUSMQoHdHVubmVscxgBIAMoCzIgLmxpdmVhZ2VudC5nYXRld2F5LnYyLlR1bm5lbFNwZWMSEAoIcmV2aXNpb24YAiABKAQiZgoMVHVubmVsSGVhbHRoEg4KBnN0YXR1cxgBIAEoCRITCgtodHRwX3N0YXR1cxgCIAEoDRINCgVlcnJvchgDIAEoCRISCgpjaGVja2VkX2F0GAQgASgDEg4KBnJ0dF9tcxgFIAEoDSLwAQoMVHVubmVsU3RhdHVzEgoKAmlkGAEgASgJEgwKBHNsdWcYAiABKAkSDAoEbmFtZRgDIAEoCRISCgp0YXJnZXRfdXJsGAQgASgJEhMKC3B1YmxpY19wYXRoGAUgASgJEhIKCmNyZWF0ZWRfYXQYBiABKAMSEgoKZXhwaXJlc19hdBgHIAEoAxIaChJhY3RpdmVfY29ubmVjdGlvbnMYCCABKA0SGAoQcHJvamVjdF9wYXRoX2tleRgJIAEoCRIxCgVsb2NhbBgKIAEoCzIiLmxpdmVhZ2VudC5nYXRld2F5LnYyLlR1bm5lbEhlYWx0aCKlAQoTVHVubmVsU3RhdGVTbmFwc2hvdBIzCgd0dW5uZWxzGAEgAygLMiIubGl2ZWFnZW50LmdhdGV3YXkudjIuVHVubmVsU3RhdHVzEhAKCHJldmlzaW9uGAIgASgEEhQKDGFnZW50X29ubGluZRgDIAEoCBIxCgVyZWxheRgEIAEoCzIiLmxpdmVhZ2VudC5nYXRld2F5LnYyLlR1bm5lbEhlYWx0aCKZAQoOVHVubmVsTXV0YXRpb24SDgoGYWN0aW9uGAEgASgJEhEKCXR1bm5lbF9pZBgCIAEoCRISCgp0YXJnZXRfdXJsGAMgASgJEgwKBG5hbWUYBCABKAkSGAoLdHRsX3NlY29uZHMYBSABKA1IAIgBARIYChBwcm9qZWN0X3BhdGhfa2V5GAYgASgJQg4KDF90dGxfc2Vjb25kcyJUChRUdW5uZWxNdXRhdGlvblJlc3VsdBIRCgl0dW5uZWxfaWQYASABKAkSEgoKZXJyb3JfY29kZRgCIAEoCRIVCg1lcnJvcl9tZXNzYWdlGAMgASgJIlkKEVR1bm5lbFByb2JlUmVzdWx0EhEKCXR1bm5lbF9pZBgBIAEoCRIxCgVsb2NhbBgCIAEoCzIiLmxpdmVhZ2VudC5nYXRld2F5LnYyLlR1bm5lbEhlYWx0aCJNChFUdW5uZWxQcm9iZVJlcG9ydBI4CgdyZXN1bHRzGAEgAygLMicubGl2ZWFnZW50LmdhdGV3YXkudjIuVHVubmVsUHJvYmVSZXN1bHQiKwoMVHVubmVsSGVhZGVyEgwKBG5hbWUYASABKAkSDQoFdmFsdWUYAiABKAki9QIKC1R1bm5lbEZyYW1lEhEKCXN0cmVhbV9pZBgBIAEoCRIzCgRraW5kGAIgASgOMiUubGl2ZWFnZW50LmdhdGV3YXkudjIuVHVubmVsRnJhbWVLaW5kEhIKCnRhcmdldF91cmwYAyABKAkSDgoGbWV0aG9kGAQgASgJEgwKBHBhdGgYBSABKAkSMwoHaGVhZGVycxgGIAMoCzIiLmxpdmVhZ2VudC5nYXRld2F5LnYyLlR1bm5lbEhlYWRlchIOCgZzdGF0dXMYByABKA0SDAoEYm9keRgIIAEoDBINCgVlcnJvchgJIAEoCRJCCg93c19tZXNzYWdlX3R5cGUYCiABKA4yKS5saXZlYWdlbnQuZ2F0ZXdheS52Mi5UdW5uZWxXc01lc3NhZ2VUeXBlEhYKDndzX3N1YnByb3RvY29sGAsgASgJEhUKDXdzX2Nsb3NlX2NvZGUYDCABKA0SFwoPd3NfY2xvc2VfcmVhc29uGA0gASgJIikKFVdvcmtzcGFjZVdhdGNoUmVxdWVzdBIQCgh3b3JrZGlycxgBIAMoCSJ+ChZXb3Jrc3BhY2VBY3Rpdml0eUV2ZW50Eg8KB3dvcmtkaXIYASABKAkSEAoIcmV2aXNpb24YAiABKAQSCgoCZnMYAyABKAgSCwoDZ2l0GAQgASgIEhUKDWNoYW5nZWRfcGF0aHMYBSADKAkSEQoJdHJ1bmNhdGVkGAYgASgIIpYCChRNYW5hZ2VkUHJvY2Vzc1JlY29yZBIKCgJpZBgBIAEoCRINCgVsYWJlbBgCIAEoCRIPCgdjb21tYW5kGAMgASgJEgsKA2N3ZBgEIAEoCRINCgVzaGVsbBgFIAEoCRILCgNwaWQYBiABKA0SEAoIbG9nX3BhdGgYByABKAkSEgoKc3RhcnRlZF9hdBgIIAEoAxIYCgtmaW5pc2hlZF9hdBgJIAEoA0gAiAEBEhYKCWV4aXRfY29kZRgKIAEoBUgBiAEBEg8KB3J1bm5pbmcYCyABKAgSEAoIaXNvbGF0ZWQYDCABKAgSEAoIcmVzdG9yZWQYDSABKAhCDgoMX2ZpbmlzaGVkX2F0QgwKCl9leGl0X2NvZGUiaQoWTWFuYWdlZFByb2Nlc3NTbmFwc2hvdBI9Cglwcm9jZXNzZXMYASADKAsyKi5saXZlYWdlbnQuZ2F0ZXdheS52Mi5NYW5hZ2VkUHJvY2Vzc1JlY29yZBIQCghyZXZpc2lvbhgCIAEoBCJOChVNYW5hZ2VkUHJvY2Vzc1JlcXVlc3QSDgoGYWN0aW9uGAEgASgJEhIKCnByb2Nlc3NfaWQYAiABKAkSEQoJbWF4X2J5dGVzGAMgASgNIrcBChZNYW5hZ2VkUHJvY2Vzc1Jlc3BvbnNlEg4KBmFjdGlvbhgBIAEoCRI+CghzbmFwc2hvdBgCIAEoCzIsLmxpdmVhZ2VudC5nYXRld2F5LnYyLk1hbmFnZWRQcm9jZXNzU25hcHNob3QSEwoLbG9nX2NvbnRlbnQYAyABKAkSEAoIbG9nX3BhdGgYBCABKAkSFQoNbG9nX3RydW5jYXRlZBgFIAEoCBIPCgdzdG9wcGVkGAYgASgIIjkKE01lbW9yeU1hbmFnZVJlcXVlc3QSDwoHY29tbWFuZBgBIAEoCRIRCglhcmdzX2pzb24YAiABKAkiKwoUTWVtb3J5TWFuYWdlUmVzcG9uc2USEwoLcmVzdWx0X2pzb24YASABKAkimAMKD1Rlcm1pbmFsUmVxdWVzdBIOCgZhY3Rpb24YASABKAkSEgoKc2Vzc2lvbl9pZBgCIAEoCRIYChBwcm9qZWN0X3BhdGhfa2V5GAMgASgJEgsKA2N3ZBgEIAEoCRINCgVzaGVsbBgFIAEoCRINCgV0aXRsZRgGIAEoCRIMCgRkYXRhGAcgASgJEgwKBGNvbHMYCCABKA0SDAoEcm93cxgJIAEoDRIRCgltYXhfYnl0ZXMYCiABKA0SEwoLc3NoX2hvc3RfaWQYCyABKAkSEQoJcHJvbXB0X2lkGAwgASgJEhUKDXByb21wdF9hbnN3ZXIYDSABKAkSFgoOdHJ1c3RfaG9zdF9rZXkYDiABKAgSFAoMc2Z0cF9lbmFibGVkGA8gASgIEg4KBnRhYl9pZBgQIAEoCRIQCgh0YWJfa2luZBgRIAEoCRITCgtyZW1vdGVfaG9zdBgSIAEoCRITCgtyZW1vdGVfcG9ydBgTIAEoDRISCgpsb2NhbF9wb3J0GBQgASgNEhIKCmZvcndhcmRfaWQYFSABKAkisgIKD1Rlcm1pbmFsU2Vzc2lvbhIKCgJpZBgBIAEoCRIYChBwcm9qZWN0X3BhdGhfa2V5GAIgASgJEgsKA2N3ZBgDIAEoCRINCgVzaGVsbBgEIAEoCRINCgV0aXRsZRgFIAEoCRILCgNwaWQYBiABKA0SDAoEY29scxgHIAEoDRIMCgRyb3dzGAggASgNEhIKCmNyZWF0ZWRfYXQYCSABKAQSEgoKdXBkYXRlZF9hdBgKIAEoBBITCgtmaW5pc2hlZF9hdBgLIAEoBBIRCglleGl0X2NvZGUYDCABKAUSDwoHcnVubmluZxgNIAEoCBIMCgRraW5kGA4gASgJEjYKA3NzaBgPIAEoCzIpLmxpdmVhZ2VudC5nYXRld2F5LnYyLlRlcm1pbmFsU3NoTWV0YWRhdGEi2wEKE1Rlcm1pbmFsU3NoTWV0YWRhdGESDwoHaG9zdF9pZBgBIAEoCRIRCglob3N0X25hbWUYAiABKAkSEAoIdXNlcm5hbWUYAyABKAkSDAoEaG9zdBgEIAEoCRIMCgRwb3J0GAUgASgNEhEKCWF1dGhfdHlwZRgGIAEoCRIOCgZzdGF0dXMYByABKAkSGQoRcmVjb25uZWN0X2F0dGVtcHQYCCABKA0SHgoWcmVjb25uZWN0X21heF9hdHRlbXB0cxgJIAEoDRIUCgxzZnRwX2VuYWJsZWQYCiABKAgikQMKC1NmdHBSZXF1ZXN0Eg4KBmFjdGlvbhgBIAEoCRISCgpzZXNzaW9uX2lkGAIgASgJEhgKEHByb2plY3RfcGF0aF9rZXkYAyABKAkSDwoHd29ya2RpchgEIAEoCRISCgpsb2NhbF9wYXRoGAUgASgJEhMKC3JlbW90ZV9wYXRoGAYgASgJEhEKCWZyb21fcGF0aBgHIAEoCRIPCgd0b19wYXRoGAggASgJEhEKCWRpcmVjdGlvbhgJIAEoCRITCgt0YXJnZXRfcGF0aBgKIAEoCRIRCglyZWN1cnNpdmUYCyABKAgSEQoJb3ZlcndyaXRlGAwgASgIEg8KB2NvbnRlbnQYDSABKAkSDgoGb2Zmc2V0GA4gASgEEhEKCW1heF9ieXRlcxgPIAEoBBITCgtzdHJpY3RfdXRmOBgQIAEoCBIWCg5leHBlY3RlZF9tdGltZRgRIAEoBBIbChNleHBlY3RlZF9zaXplX2J5dGVzGBIgASgEEhoKEmNyZWF0ZV9wYXJlbnRfZGlycxgTIAEoCCJYCglTZnRwRW50cnkSDAoEcGF0aBgBIAEoCRIMCgRuYW1lGAIgASgJEgwKBGtpbmQYAyABKAkSEgoKc2l6ZV9ieXRlcxgEIAEoBBINCgVtdGltZRgFIAEoBCLyAQoMU2Z0cFRyYW5zZmVyEgoKAmlkGAEgASgJEhIKCnNlc3Npb25faWQYAiABKAkSEQoJZGlyZWN0aW9uGAMgASgJEg4KBnN0YXR1cxgEIAEoCRITCgtzb3VyY2VfcGF0aBgFIAEoCRITCgt0YXJnZXRfcGF0aBgGIAEoCRIUCgxjdXJyZW50X3BhdGgYByABKAkSEgoKYnl0ZXNfZG9uZRgIIAEoBBITCgtieXRlc190b3RhbBgJIAEoBBISCgpmaWxlc19kb25lGAogASgNEhMKC2ZpbGVzX3RvdGFsGAsgASgNEg0KBWVycm9yGAwgASgJIrACCgxTZnRwUmVzcG9uc2USDgoGYWN0aW9uGAEgASgJEgwKBHBhdGgYAiABKAkSMAoHZW50cmllcxgDIAMoCzIfLmxpdmVhZ2VudC5nYXRld2F5LnYyLlNmdHBFbnRyeRIuCgVlbnRyeRgEIAEoCzIfLmxpdmVhZ2VudC5nYXRld2F5LnYyLlNmdHBFbnRyeRIOCgZleGlzdHMYBSABKAgSNAoIdHJhbnNmZXIYBiABKAsyIi5saXZlYWdlbnQuZ2F0ZXdheS52Mi5TZnRwVHJhbnNmZXISDwoHY29udGVudBgHIAEoCRIOCgZvZmZzZXQYCCABKAQSEgoKYnl0ZXNfcmVhZBgJIAEoBBISCgpzaXplX2J5dGVzGAogASgEEhEKCXRydW5jYXRlZBgLIAEoCCJPCglTZnRwRXZlbnQSDAoEa2luZBgBIAEoCRI0Cgh0cmFuc2ZlchgCIAEoCzIiLmxpdmVhZ2VudC5nYXRld2F5LnYyLlNmdHBUcmFuc2ZlciLBAQoRVGVybWluYWxTc2hQcm9tcHQSCgoCaWQYASABKAkSDAoEa2luZBgCIAEoCRIPCgdob3N0X2lkGAMgASgJEhEKCWhvc3RfbmFtZRgEIAEoCRIMCgRob3N0GAUgASgJEgwKBHBvcnQYBiABKA0SDwoHbWVzc2FnZRgHIAEoCRIaChJmaW5nZXJwcmludF9zaGEyNTYYCCABKAkSEAoIa2V5X3R5cGUYCSABKAkSEwoLYW5zd2VyX2VjaG8YCiABKAgiQQoTVGVybWluYWxTaGVsbE9wdGlvbhIKCgJpZBgBIAEoCRINCgVsYWJlbBgCIAEoCRIPCgdjb21tYW5kGAMgASgJIoABCg5UZXJtaW5hbFNzaFRhYhIKCgJpZBgBIAEoCRISCgpzZXNzaW9uX2lkGAIgASgJEhgKEHByb2plY3RfcGF0aF9rZXkYAyABKAkSDAoEa2luZBgEIAEoCRISCgpjcmVhdGVkX2F0GAUgASgEEhIKCnVwZGF0ZWRfYXQYBiABKAQifwoXVGVybWluYWxTc2hUYWJzU25hcHNob3QSGAoQcHJvamVjdF9wYXRoX2tleRgBIAEoCRIyCgR0YWJzGAIgAygLMiQubGl2ZWFnZW50LmdhdGV3YXkudjIuVGVybWluYWxTc2hUYWISEAoIcmV2aXNpb24YBCABKARKBAgDEAQi/QEKF1Rlcm1pbmFsU3NoTG9jYWxGb3J3YXJkEgoKAmlkGAEgASgJEhIKCnNlc3Npb25faWQYAiABKAkSGAoQcHJvamVjdF9wYXRoX2tleRgDIAEoCRISCgpsb2NhbF9ob3N0GAQgASgJEhIKCmxvY2FsX3BvcnQYBSABKA0SDwoHYWRkcmVzcxgGIAEoCRITCgtyZW1vdGVfaG9zdBgHIAEoCRITCgtyZW1vdGVfcG9ydBgIIAEoDRIOCgZzdGF0dXMYCSABKAkSEgoKY3JlYXRlZF9hdBgKIAEoBBISCgp1cGRhdGVkX2F0GAsgASgEEg0KBWVycm9yGAwgASgJInUKIFRlcm1pbmFsU3NoTG9jYWxGb3J3YXJkc1NuYXBzaG90Ej8KCGZvcndhcmRzGAEgAygLMi0ubGl2ZWFnZW50LmdhdGV3YXkudjIuVGVybWluYWxTc2hMb2NhbEZvcndhcmQSEAoIcmV2aXNpb24YAiABKAQifwodVGVybWluYWxTc2hMb2NhbEZvcndhcmRBY3Rpb24SPgoHZm9yd2FyZBgBIAEoCzItLmxpdmVhZ2VudC5nYXRld2F5LnYyLlRlcm1pbmFsU3NoTG9jYWxGb3J3YXJkEhAKCHJldmlzaW9uGAIgASgEEgwKBGtpbmQYAyABKAkipwUKEFRlcm1pbmFsUmVzcG9uc2USDgoGYWN0aW9uGAEgASgJEjcKCHNlc3Npb25zGAIgAygLMiUubGl2ZWFnZW50LmdhdGV3YXkudjIuVGVybWluYWxTZXNzaW9uEjYKB3Nlc3Npb24YAyABKAsyJS5saXZlYWdlbnQuZ2F0ZXdheS52Mi5UZXJtaW5hbFNlc3Npb24SDgoGb3V0cHV0GAQgASgMEhEKCXRydW5jYXRlZBgFIAEoCBJACg1zaGVsbF9vcHRpb25zGAYgAygLMikubGl2ZWFnZW50LmdhdGV3YXkudjIuVGVybWluYWxTaGVsbE9wdGlvbhIVCg1kZWZhdWx0X3NoZWxsGAcgASgJEhsKE291dHB1dF9zdGFydF9vZmZzZXQYCCABKAQSGQoRb3V0cHV0X2VuZF9vZmZzZXQYCSABKAQSOwoKc3NoX3Byb21wdBgKIAEoCzInLmxpdmVhZ2VudC5nYXRld2F5LnYyLlRlcm1pbmFsU3NoUHJvbXB0EhIKCmxhdGVuY3lfbXMYCyABKA0SPwoIc3NoX3RhYnMYDCABKAsyLS5saXZlYWdlbnQuZ2F0ZXdheS52Mi5UZXJtaW5hbFNzaFRhYnNTbmFwc2hvdBJSChJzc2hfbG9jYWxfZm9yd2FyZHMYDSABKAsyNi5saXZlYWdlbnQuZ2F0ZXdheS52Mi5UZXJtaW5hbFNzaExvY2FsRm9yd2FyZHNTbmFwc2hvdBJOChFzc2hfbG9jYWxfZm9yd2FyZBgOIAEoCzIzLmxpdmVhZ2VudC5nYXRld2F5LnYyLlRlcm1pbmFsU3NoTG9jYWxGb3J3YXJkQWN0aW9uEigKIHNzaF9sb2NhbF9mb3J3YXJkX3BvcnRfYXZhaWxhYmxlGA8gASgIItoCCg1UZXJtaW5hbEV2ZW50EgwKBGtpbmQYASABKAkSEgoKc2Vzc2lvbl9pZBgCIAEoCRIYChBwcm9qZWN0X3BhdGhfa2V5GAMgASgJEjYKB3Nlc3Npb24YBCABKAsyJS5saXZlYWdlbnQuZ2F0ZXdheS52Mi5UZXJtaW5hbFNlc3Npb24SDAoEZGF0YRgFIAEoDBIbChNvdXRwdXRfc3RhcnRfb2Zmc2V0GAYgASgEEhkKEW91dHB1dF9lbmRfb2Zmc2V0GAcgASgEEj8KCHNzaF90YWJzGAggASgLMi0ubGl2ZWFnZW50LmdhdGV3YXkudjIuVGVybWluYWxTc2hUYWJzU25hcHNob3QSTgoRc3NoX2xvY2FsX2ZvcndhcmQYCSABKAsyMy5saXZlYWdlbnQuZ2F0ZXdheS52Mi5UZXJtaW5hbFNzaExvY2FsRm9yd2FyZEFjdGlvbiKyAgoTVGVybWluYWxTdHJlYW1GcmFtZRIMCgRraW5kGAEgASgJEhEKCXN0cmVhbV9pZBgCIAEoCRISCgpzZXNzaW9uX2lkGAMgASgJEhgKEHByb2plY3RfcGF0aF9rZXkYBCABKAkSCwoDc2VxGAUgASgEEhQKDHN0YXJ0X29mZnNldBgGIAEoBBISCgplbmRfb2Zmc2V0GAcgASgEEgwKBGNvbHMYCCABKA0SDAoEcm93cxgJIAEoDRIRCgltYXhfYnl0ZXMYCiABKA0SEQoJdHJ1bmNhdGVkGAsgASgIEg0KBWVycm9yGAwgASgJEjYKB3Nlc3Npb24YDSABKAsyJS5saXZlYWdlbnQuZ2F0ZXdheS52Mi5UZXJtaW5hbFNlc3Npb24SDAoEZGF0YRgOIAEoDCJACgpHaXRSZXF1ZXN0Eg4KBmFjdGlvbhgBIAEoCRIPCgd3b3JrZGlyGAIgASgJEhEKCWFyZ3NfanNvbhgDIAEoCSIyCgtHaXRSZXNwb25zZRIOCgZhY3Rpb24YASABKAkSEwoLcmVzdWx0X2pzb24YAiABKAkikQMKC0NoYXRSZXF1ZXN0EhcKD2NvbnZlcnNhdGlvbl9pZBgBIAEoCRIPCgdtZXNzYWdlGAIgASgJEj8KDnNlbGVjdGVkX21vZGVsGAMgASgLMicubGl2ZWFnZW50LmdhdGV3YXkudjIuQ2hhdFNlbGVjdGVkTW9kZWwSFgoOZXhlY3V0aW9uX21vZGUYBCABKAkSDwoHd29ya2RpchgFIAEoCRI+Cg51cGxvYWRlZF9maWxlcxgHIAMoCzImLmxpdmVhZ2VudC5nYXRld2F5LnYyLkNoYXRVcGxvYWRlZEZpbGUSGQoRY2xpZW50X3JlcXVlc3RfaWQYCCABKAkSQwoQcnVudGltZV9jb250cm9scxgJIAEoCzIpLmxpdmVhZ2VudC5nYXRld2F5LnYyLkNoYXRSdW50aW1lQ29udHJvbHMSFAoMcXVldWVfcG9saWN5GAogASgJEhsKE2NvbW1hbmRfc2FmZXR5X21vZGUYCyABKAlKBAgGEAdSFXNlbGVjdGVkX3N5c3RlbV90b29scyKKAQoOQ2hhdE1lc3NhZ2VSZWYSFQoNc2VnbWVudF9pbmRleBgBIAEoBRIVCg1tZXNzYWdlX2luZGV4GAIgASgFEhIKCnNlZ21lbnRfaWQYAyABKAkSEgoKbWVzc2FnZV9pZBgEIAEoCRIMCgRyb2xlGAUgASgJEhQKDGNvbnRlbnRfaGFzaBgGIAEoCSI8ChFDYW5jZWxDaGF0UmVxdWVzdBIXCg9jb252ZXJzYXRpb25faWQYASABKAkSDgoGcnVuX2lkGAIgASgJIs8BChJDaGF0Q29tbWFuZFJlcXVlc3QSDAoEdHlwZRgBIAEoCRIyCgdyZXF1ZXN0GAIgASgLMiEubGl2ZWFnZW50LmdhdGV3YXkudjIuQ2hhdFJlcXVlc3QSPgoQYmFzZV9tZXNzYWdlX3JlZhgDIAEoCzIkLmxpdmVhZ2VudC5nYXRld2F5LnYyLkNoYXRNZXNzYWdlUmVmEjcKBmNhbmNlbBgEIAEoCzInLmxpdmVhZ2VudC5nYXRld2F5LnYyLkNhbmNlbENoYXRSZXF1ZXN0IrgBChBDaGF0UXVldWVSZXF1ZXN0Eg4KBmFjdGlvbhgBIAEoCRIXCg9jb252ZXJzYXRpb25faWQYAiABKAkSDwoHaXRlbV9pZBgDIAEoCRIRCglkaXJlY3Rpb24YBCABKAkSEAoIcmV2aXNpb24YBSABKAQSEgoKZHJhZnRfanNvbhgGIAEoCRIbChN1cGxvYWRlZF9maWxlc19qc29uGAcgASgJEhQKDHJlcXVlc3RfanNvbhgIIAEoCSKGAQoRQ2hhdFF1ZXVlUmVzcG9uc2USEAoIYWNjZXB0ZWQYASABKAgSDwoHbWVzc2FnZRgCIAEoCRIVCg1zbmFwc2hvdF9qc29uGAMgASgJEhEKCWl0ZW1fanNvbhgEIAEoCRISCgplcnJvcl9jb2RlGAUgASgJEhAKCHJldmlzaW9uGAYgASgEIlIKDkNoYXRRdWV1ZUV2ZW50EhcKD2NvbnZlcnNhdGlvbl9pZBgBIAEoCRIVCg1zbmFwc2hvdF9qc29uGAIgASgJEhAKCHJldmlzaW9uGAMgASgEIpUCCglDaGF0RXZlbnQSOwoEdHlwZRgBIAEoDjItLmxpdmVhZ2VudC5nYXRld2F5LnYyLkNoYXRFdmVudC5DaGF0RXZlbnRUeXBlEhcKD2NvbnZlcnNhdGlvbl9pZBgCIAEoCRIMCgRkYXRhGAMgASgJIqMBCg1DaGF0RXZlbnRUeXBlEgkKBVRPS0VOEAASDAoIVEhJTktJTkcQARINCglUT09MX0NBTEwQAhIPCgtUT09MX1JFU1VMVBADEggKBERPTkUQBBIJCgVFUlJPUhAFEg8KC1RPT0xfU1RBVFVTEAYSEQoNSE9TVEVEX1NFQVJDSBAHEhAKDFVTRVJfTUVTU0FHRRAIEg4KClRSQUpFQ1RPUlkQCSK8AQoQQ2hhdENvbnRyb2xFdmVudBISCgpyZXF1ZXN0X2lkGAEgASgJEhkKEWNsaWVudF9yZXF1ZXN0X2lkGAIgASgJEhcKD2NvbnZlcnNhdGlvbl9pZBgDIAEoCRIRCglydW5fZXBvY2gYBCABKAMSDAoEdHlwZRgFIAEoCRINCgVzdGF0ZRgGIAEoCRISCgplcnJvcl9jb2RlGAcgASgJEg8KB21lc3NhZ2UYCCABKAkSCwoDc2VxGAkgASgDIvwBChNDaGF0UnVudGltZVNuYXBzaG90EhcKD2NvbnZlcnNhdGlvbl9pZBgBIAEoCRIOCgZydW5faWQYAiABKAkSGQoRY2xpZW50X3JlcXVlc3RfaWQYAyABKAkSEQoJd29ya2VyX2lkGAQgASgJEg0KBXN0YXRlGAUgASgJEgsKA2N3ZBgGIAEoCRISCgp1cGRhdGVkX2F0GAcgASgDEhAKCHJldmlzaW9uGAggASgDEhQKDGVudHJpZXNfanNvbhgJIAEoCRITCgt0b29sX3N0YXR1cxgKIAEoCRIhChl0b29sX3N0YXR1c19pc19jb21wYWN0aW9uGAsgASgIIuoBChJSdW50aW1lU3RhdHVzRXZlbnQSEQoJd29ya2VyX2lkGAEgASgJEg0KBXN0YXRlGAIgASgJEg8KB3Zpc2libGUYAyABKAgSGAoQYWN0aXZlX3J1bl9jb3VudBgEIAEoDRIRCgl0aW1lc3RhbXAYBSABKAMSOAoLYWN0aXZlX3J1bnMYBiADKAsyIy5saXZlYWdlbnQuZ2F0ZXdheS52Mi5DaGF0UnVuUmVwb3J0EjoKDWZpbmlzaGVkX3J1bnMYByADKAsyIy5saXZlYWdlbnQuZ2F0ZXdheS52Mi5DaGF0UnVuUmVwb3J0IoABCg1DaGF0UnVuUmVwb3J0Eg4KBnJ1bl9pZBgBIAEoCRIXCg9jb252ZXJzYXRpb25faWQYAiABKAkSDQoFc3RhdGUYAyABKAkSEgoKZXJyb3JfY29kZRgEIAEoCRIPCgdtZXNzYWdlGAUgASgJEhIKCnVwZGF0ZWRfYXQYBiABKAMiRwoRQ3Jvbk1hbmFnZVJlcXVlc3QSDgoGYWN0aW9uGAEgASgJEg8KB3Rhc2tfaWQYAiABKAkSEQoJdGFza19qc29uGAMgASgJIjkKEkNyb25NYW5hZ2VSZXNwb25zZRIOCgZhY3Rpb24YASABKAkSEwoLcmVzdWx0X2pzb24YAiABKAkiVQoSSGlzdG9yeUxpc3RSZXF1ZXN0EgwKBHBhZ2UYASABKAUSEQoJcGFnZV9zaXplGAIgASgFEgsKA2N3ZBgDIAEoCRIRCgljd2RfZW1wdHkYBCABKAgibAoTSGlzdG9yeUxpc3RSZXNwb25zZRJACg1jb252ZXJzYXRpb25zGAEgAygLMikubGl2ZWFnZW50LmdhdGV3YXkudjIuQ29udmVyc2F0aW9uU3VtbWFyeRITCgt0b3RhbF9jb3VudBgCIAEoBSKKAgoTQ29udmVyc2F0aW9uU3VtbWFyeRIKCgJpZBgBIAEoCRINCgV0aXRsZRgCIAEoCRISCgpjcmVhdGVkX2F0GAMgASgDEhIKCnVwZGF0ZWRfYXQYBCABKAMSFQoNbWVzc2FnZV9jb3VudBgFIAEoBRITCgtwcm92aWRlcl9pZBgGIAEoCRINCgVtb2RlbBgHIAEoCRISCgpzZXNzaW9uX2lkGAggASgJEgsKA2N3ZBgJIAEoCRIRCglpc19waW5uZWQYCiABKAgSEQoJcGlubmVkX2F0GAsgASgDEhEKCWlzX3NoYXJlZBgMIAEoCBIbChNzZWxlY3RlZF9tb2RlbF9qc29uGA0gASgJIkIKEUhpc3RvcnlHZXRSZXF1ZXN0EhcKD2NvbnZlcnNhdGlvbl9pZBgBIAEoCRIUCgxtYXhfbWVzc2FnZXMYAiABKAUi1AEKEkhpc3RvcnlHZXRSZXNwb25zZRIXCg9jb252ZXJzYXRpb25faWQYASABKAkSFQoNbWVzc2FnZXNfanNvbhgCIAEoCRIbChN0b3RhbF9tZXNzYWdlX2NvdW50GAMgASgFEh4KFnJldHVybmVkX21lc3NhZ2VfY291bnQYBCABKAUSEAoIaGFzX21vcmUYBSABKAgSPwoMY29udmVyc2F0aW9uGAYgASgLMikubGl2ZWFnZW50LmdhdGV3YXkudjIuQ29udmVyc2F0aW9uU3VtbWFyeSLRAQoWVHJhamVjdG9yeUZldGNoUmVxdWVzdBIXCg9jb252ZXJzYXRpb25faWQYASABKAkSEwoLc2VjdGlvbl9pZHMYAiADKAkSFAoMbWF4X3NlZ21lbnRzGAMgASgFEiEKFGJlZm9yZV9zZWdtZW50X2luZGV4GAQgASgFSACIAQESHQoVaW5jbHVkZV9zdWJhZ2VudF9ydW5zGAUgASgIEhgKEHN1YmFnZW50X3J1bl9pZHMYBiADKAlCFwoVX2JlZm9yZV9zZWdtZW50X2luZGV4IlwKGFRyYWplY3RvcnlTZWN0aW9uUGF5bG9hZBISCgpzZWN0aW9uX2lkGAEgASgJEgwKBHNsb3QYAiABKAkSDwoHY29udGVudBgDIAEoCRINCgVieXRlcxgEIAEoAyKsAgoXVHJhamVjdG9yeUZldGNoUmVzcG9uc2USFwoPY29udmVyc2F0aW9uX2lkGAEgASgJEhMKC2V2ZW50c19qc29uGAIgASgJEhEKCXRydW5jYXRlZBgDIAEoCBJACghzZWN0aW9ucxgEIAMoCzIuLmxpdmVhZ2VudC5nYXRld2F5LnYyLlRyYWplY3RvcnlTZWN0aW9uUGF5bG9hZBIcChRvbGRlc3Rfc2VnbWVudF9pbmRleBgFIAEoBRIeChZyZXR1cm5lZF9zZWdtZW50X2NvdW50GAYgASgFEhsKE3RvdGFsX3NlZ21lbnRfY291bnQYByABKAUSFwoPaGFzX21vcmVfYmVmb3JlGAggASgIEhoKEnN1YmFnZW50X3J1bnNfanNvbhgJIAEoCSKFAQoUSGlzdG9yeVByZWZpeFJlcXVlc3QSFwoPY29udmVyc2F0aW9uX2lkGAEgASgJEhQKDG1heF9tZXNzYWdlcxgCIAEoBRI+ChBiYXNlX21lc3NhZ2VfcmVmGAMgASgLMiQubGl2ZWFnZW50LmdhdGV3YXkudjIuQ2hhdE1lc3NhZ2VSZWYi1wEKFUhpc3RvcnlQcmVmaXhSZXNwb25zZRIXCg9jb252ZXJzYXRpb25faWQYASABKAkSFQoNbWVzc2FnZXNfanNvbhgCIAEoCRIbChN0b3RhbF9tZXNzYWdlX2NvdW50GAMgASgFEh4KFnJldHVybmVkX21lc3NhZ2VfY291bnQYBCABKAUSEAoIaGFzX21vcmUYBSABKAgSPwoMY29udmVyc2F0aW9uGAYgASgLMikubGl2ZWFnZW50LmdhdGV3YXkudjIuQ29udmVyc2F0aW9uU3VtbWFyeSI+ChRIaXN0b3J5UmVuYW1lUmVxdWVzdBIXCg9jb252ZXJzYXRpb25faWQYASABKAkSDQoFdGl0bGUYAiABKAkiWAoVSGlzdG9yeVJlbmFtZVJlc3BvbnNlEj8KDGNvbnZlcnNhdGlvbhgBIAEoCzIpLmxpdmVhZ2VudC5nYXRld2F5LnYyLkNvbnZlcnNhdGlvblN1bW1hcnkibwoUSGlzdG9yeUJyYW5jaFJlcXVlc3QSFwoPY29udmVyc2F0aW9uX2lkGAEgASgJEj4KEGJhc2VfbWVzc2FnZV9yZWYYAiABKAsyJC5saXZlYWdlbnQuZ2F0ZXdheS52Mi5DaGF0TWVzc2FnZVJlZiJYChVIaXN0b3J5QnJhbmNoUmVzcG9uc2USPwoMY29udmVyc2F0aW9uGAEgASgLMikubGl2ZWFnZW50LmdhdGV3YXkudjIuQ29udmVyc2F0aW9uU3VtbWFyeSI/ChFIaXN0b3J5UGluUmVxdWVzdBIXCg9jb252ZXJzYXRpb25faWQYASABKAkSEQoJaXNfcGlubmVkGAIgASgIIlUKEkhpc3RvcnlQaW5SZXNwb25zZRI/Cgxjb252ZXJzYXRpb24YASABKAsyKS5saXZlYWdlbnQuZ2F0ZXdheS52Mi5Db252ZXJzYXRpb25TdW1tYXJ5IpIBChJIaXN0b3J5U2hhcmVTdGF0dXMSFwoPY29udmVyc2F0aW9uX2lkGAEgASgJEg8KB2VuYWJsZWQYAiABKAgSDQoFdG9rZW4YAyABKAkSEgoKY3JlYXRlZF9hdBgEIAEoAxISCgp1cGRhdGVkX2F0GAUgASgDEhsKE3JlZGFjdF90b29sX2NvbnRlbnQYBiABKAgiMQoWSGlzdG9yeVNoYXJlR2V0UmVxdWVzdBIXCg9jb252ZXJzYXRpb25faWQYASABKAkiUgoXSGlzdG9yeVNoYXJlR2V0UmVzcG9uc2USNwoFc2hhcmUYASABKAsyKC5saXZlYWdlbnQuZ2F0ZXdheS52Mi5IaXN0b3J5U2hhcmVTdGF0dXMifAoWSGlzdG9yeVNoYXJlU2V0UmVxdWVzdBIXCg9jb252ZXJzYXRpb25faWQYASABKAkSDwoHZW5hYmxlZBgCIAEoCBIgChNyZWRhY3RfdG9vbF9jb250ZW50GAMgASgISACIAQFCFgoUX3JlZGFjdF90b29sX2NvbnRlbnQiUgoXSGlzdG9yeVNoYXJlU2V0UmVzcG9uc2USNwoFc2hhcmUYASABKAsyKC5saXZlYWdlbnQuZ2F0ZXdheS52Mi5IaXN0b3J5U2hhcmVTdGF0dXMiKwoaSGlzdG9yeVNoYXJlUmVzb2x2ZVJlcXVlc3QSDQoFdG9rZW4YASABKAkiyAEKG0hpc3RvcnlTaGFyZVJlc29sdmVSZXNwb25zZRIXCg9jb252ZXJzYXRpb25faWQYASABKAkSFQoNbWVzc2FnZXNfanNvbhgCIAEoCRIbChN0b3RhbF9tZXNzYWdlX2NvdW50GAMgASgFEj8KDGNvbnZlcnNhdGlvbhgEIAEoCzIpLmxpdmVhZ2VudC5nYXRld2F5LnYyLkNvbnZlcnNhdGlvblN1bW1hcnkSGwoTcmVkYWN0X3Rvb2xfY29udGVudBgFIAEoCCIYChZIaXN0b3J5V29ya2RpcnNSZXF1ZXN0IlUKFUhpc3RvcnlXb3JrZGlyU3VtbWFyeRIMCgRwYXRoGAEgASgJEhoKEmNvbnZlcnNhdGlvbl9jb3VudBgCIAEoBRISCgp1cGRhdGVkX2F0GAMgASgDIlgKF0hpc3RvcnlXb3JrZGlyc1Jlc3BvbnNlEj0KCHdvcmtkaXJzGAEgAygLMisubGl2ZWFnZW50LmdhdGV3YXkudjIuSGlzdG9yeVdvcmtkaXJTdW1tYXJ5Ii8KFEhpc3RvcnlEZWxldGVSZXF1ZXN0EhcKD2NvbnZlcnNhdGlvbl9pZBgBIAEoCSIXChVIaXN0b3J5RGVsZXRlUmVzcG9uc2UiegoQSGlzdG9yeVN5bmNFdmVudBIMCgRraW5kGAEgASgJEj8KDGNvbnZlcnNhdGlvbhgCIAEoCzIpLmxpdmVhZ2VudC5nYXRld2F5LnYyLkNvbnZlcnNhdGlvblN1bW1hcnkSFwoPY29udmVyc2F0aW9uX2lkGAMgASgJIhUKE1Byb3ZpZGVyTGlzdFJlcXVlc3QiLgoUUHJvdmlkZXJMaXN0UmVzcG9uc2USFgoOcHJvdmlkZXJzX2pzb24YASABKAkiFAoSU2V0dGluZ3NHZXRSZXF1ZXN0IiwKE1NldHRpbmdzR2V0UmVzcG9uc2USFQoNc2V0dGluZ3NfanNvbhgBIAEoCSIuChVTZXR0aW5nc1VwZGF0ZVJlcXVlc3QSFQoNc2V0dGluZ3NfanNvbhgBIAEoCSI7ChZTZXR0aW5nc1VwZGF0ZVJlc3BvbnNlEhAKCGFjY2VwdGVkGAEgASgIEg8KB21lc3NhZ2UYAiABKAkiPgogU2V0dGluZ3NSZXNldFNzaEtub3duSG9zdFJlcXVlc3QSDAoEaG9zdBgBIAEoCRIMCgRwb3J0GAIgASgNIjQKIVNldHRpbmdzUmVzZXRTc2hLbm93bkhvc3RSZXNwb25zZRIPCgdkZWxldGVkGAEgASgNIioKEVNldHRpbmdzU3luY0V2ZW50EhUKDXNldHRpbmdzX2pzb24YASABKAkiFwoVU2tpbGxGaWxlc0xpc3RSZXF1ZXN0IkwKFlNraWxsRmlsZXNMaXN0UmVzcG9uc2USEAoIcm9vdF9kaXIYASABKAkSDQoFcGF0aHMYAiADKAkSEQoJdHJ1bmNhdGVkGAMgASgIIigKGFNraWxsTWV0YWRhdGFSZWFkUmVxdWVzdBIMCgRwYXRoGAEgASgJIj4KGVNraWxsTWV0YWRhdGFSZWFkUmVzcG9uc2USDAoEbmFtZRgBIAEoCRITCgtkZXNjcmlwdGlvbhgCIAEoCSJEChRTa2lsbFRleHRSZWFkUmVxdWVzdBIMCgRwYXRoGAEgASgJEg4KBm9mZnNldBgCIAEoDRIOCgZsZW5ndGgYAyABKA0iOwoVU2tpbGxUZXh0UmVhZFJlc3BvbnNlEg8KB2NvbnRlbnQYASABKAkSEQoJdHJ1bmNhdGVkGAIgASgIIioKElNraWxsTWFuYWdlUmVxdWVzdBIUCgxwYXlsb2FkX2pzb24YASABKAkiKgoTU2tpbGxNYW5hZ2VSZXNwb25zZRITCgtyZXN1bHRfanNvbhgBIAEoCSJ3ChZGaWxlTWVudGlvbkxpc3RSZXF1ZXN0Eg8KB3dvcmtkaXIYASABKAkSEwoLbWF4X3Jlc3VsdHMYAiABKA0SDQoFcXVlcnkYAyABKAkSGAoLc2hvd19oaWRkZW4YBCABKAhIAIgBAUIOCgxfc2hvd19oaWRkZW4iPgoQRmlsZU1lbnRpb25FbnRyeRIMCgRwYXRoGAEgASgJEgwKBGtpbmQYAiABKAkSDgoGaGlkZGVuGAMgASgIImUKF0ZpbGVNZW50aW9uTGlzdFJlc3BvbnNlEjcKB2VudHJpZXMYASADKAsyJi5saXZlYWdlbnQuZ2F0ZXdheS52Mi5GaWxlTWVudGlvbkVudHJ5EhEKCXRydW5jYXRlZBgCIAEoCCI/CgZGc1Jvb3QSCgoCaWQYASABKAkSDAoEcGF0aBgCIAEoCRIMCgRraW5kGAMgASgJEg0KBWxhYmVsGAQgASgJIhAKDkZzUm9vdHNSZXF1ZXN0Ij4KD0ZzUm9vdHNSZXNwb25zZRIrCgVyb290cxgBIAMoCzIcLmxpdmVhZ2VudC5nYXRld2F5LnYyLkZzUm9vdCI2ChFGc0xpc3REaXJzUmVxdWVzdBIMCgRwYXRoGAEgASgJEhMKC21heF9yZXN1bHRzGAIgASgNIigKCkZzRGlyRW50cnkSDAoEcGF0aBgBIAEoCRIMCgRuYW1lGAIgASgJImgKEkZzTGlzdERpcnNSZXNwb25zZRIMCgRwYXRoGAEgASgJEjEKB2VudHJpZXMYAiADKAsyIC5saXZlYWdlbnQuZ2F0ZXdheS52Mi5Gc0RpckVudHJ5EhEKCXRydW5jYXRlZBgDIAEoCCI8ChxGc0NyZWF0ZVByb2plY3RGb2xkZXJSZXF1ZXN0Eg4KBnBhcmVudBgBIAEoCRIMCgRuYW1lGAIgASgJIi0KHUZzQ3JlYXRlUHJvamVjdEZvbGRlclJlc3BvbnNlEgwKBHBhdGgYASABKAkijAEKDUZzTGlzdFJlcXVlc3QSDwoHd29ya2RpchgBIAEoCRIMCgRwYXRoGAIgASgJEg0KBWRlcHRoGAMgASgNEg4KBm9mZnNldBgEIAEoDRITCgttYXhfcmVzdWx0cxgFIAEoDRIYCgtzaG93X2hpZGRlbhgGIAEoCEgAiAEBQg4KDF9zaG93X2hpZGRlbiI5CgtGc0xpc3RFbnRyeRIMCgRwYXRoGAEgASgJEgwKBGtpbmQYAiABKAkSDgoGaGlkZGVuGAMgASgIIrkBCg5Gc0xpc3RSZXNwb25zZRIMCgRwYXRoGAEgASgJEhAKCGhhc19wYXRoGAIgASgIEg0KBWRlcHRoGAMgASgNEg4KBm9mZnNldBgEIAEoDRITCgttYXhfcmVzdWx0cxgFIAEoDRINCgV0b3RhbBgGIAEoDRIQCghoYXNfbW9yZRgHIAEoCBIyCgdlbnRyaWVzGAggAygLMiEubGl2ZWFnZW50LmdhdGV3YXkudjIuRnNMaXN0RW50cnkiOgoZRnNSZWFkRWRpdGFibGVUZXh0UmVxdWVzdBIPCgd3b3JrZGlyGAEgASgJEgwKBHBhdGgYAiABKAkijAEKGkZzUmVhZEVkaXRhYmxlVGV4dFJlc3BvbnNlEgwKBHBhdGgYASABKAkSDwoHY29udGVudBgCIAEoCRIQCghtdGltZV9tcxgDIAEoBBIUCgxjb250ZW50X2hhc2gYBCABKAkSEgoKc2l6ZV9ieXRlcxgFIAEoBBITCgt0b3RhbF9saW5lcxgGIAEoBCI8ChtGc1JlYWRXb3Jrc3BhY2VJbWFnZVJlcXVlc3QSDwoHd29ya2RpchgBIAEoCRIMCgRwYXRoGAIgASgJIokBChxGc1JlYWRXb3Jrc3BhY2VJbWFnZVJlc3BvbnNlEgwKBHBhdGgYASABKAkSEQoJbWltZV90eXBlGAIgASgJEgwKBGRhdGEYAyABKAkSEgoKc2l6ZV9ieXRlcxgEIAEoBBIQCghtdGltZV9tcxgFIAEoBBIUCgxjb250ZW50X2hhc2gYBiABKAki2wEKE0NoYXRGaWxlT3BlblJlcXVlc3QSFwoPY29udmVyc2F0aW9uX2lkGAEgASgJEg8KB3dvcmtkaXIYAiABKAkSDAoEcGF0aBgDIAEoCRIOCgZzb3VyY2UYBCABKAkSEQoEbGluZRgFIAEoDUgAiAEBEhUKCGVuZF9saW5lGAYgASgNSAGIAQESEwoGY29sdW1uGAcgASgNSAKIAQESHAoUb3Blbl9pbl9maWxlX21hbmFnZXIYCCABKAhCBwoFX2xpbmVCCwoJX2VuZF9saW5lQgkKB19jb2x1bW4izgEKFENoYXRGaWxlT3BlblJlc3BvbnNlEg4KBmFjdGlvbhgBIAEoCRIMCgRraW5kGAIgASgJEg8KB3dvcmtkaXIYAyABKAkSDAoEcGF0aBgEIAEoCRIRCgRsaW5lGAUgASgNSACIAQESFQoIZW5kX2xpbmUYBiABKA1IAYgBARITCgZjb2x1bW4YByABKA1IAogBARIZChFvdXRzaWRlX3dvcmtzcGFjZRgIIAEoCEIHCgVfbGluZUILCglfZW5kX2xpbmVCCQoHX2NvbHVtbiLOAQoSRnNXcml0ZVRleHRSZXF1ZXN0Eg8KB3dvcmtkaXIYASABKAkSDAoEcGF0aBgCIAEoCRIPCgdjb250ZW50GAMgASgJEgwKBG1vZGUYBCABKAkSGQoRZXhwZWN0ZWRfbXRpbWVfbXMYBSABKAQSHQoVZXhwZWN0ZWRfY29udGVudF9oYXNoGAYgASgJEh0KFWhhc19leHBlY3RlZF9tdGltZV9tcxgHIAEoCBIhChloYXNfZXhwZWN0ZWRfY29udGVudF9oYXNoGAggASgIIp0BChNGc1dyaXRlVGV4dFJlc3BvbnNlEgwKBHBhdGgYASABKAkSDAoEbW9kZRgCIAEoCRIWCg5leGlzdGVkX2JlZm9yZRgDIAEoCBIVCg1ieXRlc193cml0dGVuGAQgASgEEhAKCG10aW1lX21zGAUgASgEEhQKDGNvbnRlbnRfaGFzaBgGIAEoCRITCgt0b3RhbF9saW5lcxgHIAEoBCIzChJGc0NyZWF0ZURpclJlcXVlc3QSDwoHd29ya2RpchgBIAEoCRIMCgRwYXRoGAIgASgJIjEKE0ZzQ3JlYXRlRGlyUmVzcG9uc2USDAoEcGF0aBgBIAEoCRIMCgRraW5kGAIgASgJIkYKD0ZzUmVuYW1lUmVxdWVzdBIPCgd3b3JrZGlyGAEgASgJEhEKCWZyb21fcGF0aBgCIAEoCRIPCgd0b19wYXRoGAMgASgJIkEKEEZzUmVuYW1lUmVzcG9uc2USEQoJZnJvbV9wYXRoGAEgASgJEgwKBHBhdGgYAiABKAkSDAoEa2luZBgDIAEoCSIwCg9Gc0RlbGV0ZVJlcXVlc3QSDwoHd29ya2RpchgBIAEoCRIMCgRwYXRoGAIgASgJIi4KEEZzRGVsZXRlUmVzcG9uc2USDAoEcGF0aBgBIAEoCRIMCgRraW5kGAIgASgJIiAKC1BpbmdSZXF1ZXN0EhEKCXRpbWVzdGFtcBgBIAEoAyIhCgxQb25nUmVzcG9uc2USEQoJdGltZXN0YW1wGAEgASgDIi4KDUVycm9yUmVzcG9uc2USDAoEY29kZRgBIAEoBRIPCgdtZXNzYWdlGAIgASgJIr4BChVQcm92aWRlck1vZGVsc1JlcXVlc3QSFQoNcHJvdmlkZXJfdHlwZRgBIAEoCRIQCghiYXNlX3VybBgCIAEoCRIPCgdhcGlfa2V5GAMgASgJEhgKEHVzZV9zeXN0ZW1fcHJveHkYBCABKAgSEgoKbW9kZWxzX3VybBgFIAEoCRITCgtwcm92aWRlcl9pZBgGIAEoCRIYCgtpc19mdWxsX3VybBgHIAEoCEgAiAEBQg4KDF9pc19mdWxsX3VybCItChZQcm92aWRlck1vZGVsc1Jlc3BvbnNlEhMKC21vZGVsc19qc29uGAEgASgJIlEKFFByb3ZpZGVyVXNhZ2VSZXF1ZXN0EhMKC3Byb3ZpZGVyX2lkGAEgASgJEg8KB3JlZnJlc2gYAiABKAgSEwoLY29uZmlnX2pzb24YAyABKAkiLAoVUHJvdmlkZXJVc2FnZVJlc3BvbnNlEhMKC3Jlc3VsdF9qc29uGAEgASgJIogBChBDaGF0SW5ncmVzc0JhdGNoEg4KBnJ1bl9pZBgBIAEoCRIXCg9jb252ZXJzYXRpb25faWQYAiABKAkSEQoJZmlyc3Rfc2VxGAMgASgEEjgKB3JlY29yZHMYBCADKAsyJy5saXZlYWdlbnQuZ2F0ZXdheS52Mi5DaGF0SW5ncmVzc1JlY29yZCKaAgoRQ2hhdEluZ3Jlc3NSZWNvcmQSNwoFZGVsdGEYASABKAsyJi5saXZlYWdlbnQuZ2F0ZXdheS52Mi5DaGF0SW5ncmVzc0RlbHRhSAASQQoKY2hlY2twb2ludBgCIAEoCzIrLmxpdmVhZ2VudC5nYXRld2F5LnYyLkNoYXRJbmdyZXNzQ2hlY2twb2ludEgAEj0KCHRlcm1pbmFsGAMgASgLMikubGl2ZWFnZW50LmdhdGV3YXkudjIuQ2hhdEluZ3Jlc3NUZXJtaW5hbEgAEj8KCWhlYXJ0YmVhdBgEIAEoCzIqLmxpdmVhZ2VudC5nYXRld2F5LnYyLkNoYXRJbmdyZXNzSGVhcnRiZWF0SABCCQoHcGF5bG9hZCI5ChBDaGF0SW5ncmVzc0RlbHRhEhIKCmV2ZW50X2pzb24YASABKAkSEQoJd29ya2VyX2lkGAIgASgJIioKFENoYXRJbmdyZXNzSGVhcnRiZWF0EhIKCnVwZGF0ZWRfYXQYASABKAMixAEKFUNoYXRJbmdyZXNzQ2hlY2twb2ludBIaChJjb3ZlcnNfdGhyb3VnaF9zZXEYASABKAQSEAoIcmV2aXNpb24YAiABKAQSHQoVY29tcHJlc3NlZF9wcm9qZWN0aW9uGAMgASgMEhoKEnVuY29tcHJlc3NlZF9ieXRlcxgEIAEoBBIOCgZzaGEyNTYYBSABKAkSGAoQY29udGVudF9jb21wbGV0ZRgGIAEoCBIYChBoaXN0b3J5X3JlcXVpcmVkGAcgASgIIvwBChNDaGF0SW5ncmVzc1Rlcm1pbmFsEhoKEmNvdmVyc190aHJvdWdoX3NlcRgBIAEoBBIQCghyZXZpc2lvbhgCIAEoBBIdChVjb21wcmVzc2VkX3Byb2plY3Rpb24YAyABKAwSGgoSdW5jb21wcmVzc2VkX2J5dGVzGAQgASgEEg4KBnNoYTI1NhgFIAEoCRIYChBjb250ZW50X2NvbXBsZXRlGAYgASgIEhgKEGhpc3RvcnlfcmVxdWlyZWQYByABKAgSDQoFc3RhdGUYCCABKAkSEgoKZXJyb3JfY29kZRgJIAEoCRIVCg1lcnJvcl9tZXNzYWdlGAogASgJIk0KEUNoYXRJbmdyZXNzUmVzdW1lEjgKBHJ1bnMYASADKAsyKi5saXZlYWdlbnQuZ2F0ZXdheS52Mi5DaGF0SW5ncmVzc1J1blJlc3VtZSLVAQoUQ2hhdEluZ3Jlc3NSdW5SZXN1bWUSDgoGcnVuX2lkGAEgASgJEhcKD2NvbnZlcnNhdGlvbl9pZBgCIAEoCRIXCg9yZXBsYXlfZnJvbV9zZXEYAyABKAQSGgoScmVwbGF5X3Rocm91Z2hfc2VxGAQgASgEEhAKCG5leHRfc2VxGAUgASgEEh0KFWxhdGVzdF9jaGVja3BvaW50X3NlcRgGIAEoBBIUCgx0ZXJtaW5hbF9zZXEYByABKAQSGAoQdGVybWluYWxfcGVuZGluZxgIIAEoCCLOAQoTQ2hhdEluZ3Jlc3NGcmFnbWVudBIOCgZydW5faWQYASABKAkSFwoPY29udmVyc2F0aW9uX2lkGAIgASgJEhIKCnNvdXJjZV9zZXEYAyABKAQSFgoOZnJhZ21lbnRfaW5kZXgYBCABKA0SFgoOZnJhZ21lbnRfY291bnQYBSABKA0SHAoUZW5jb2RlZF9yZWNvcmRfY2h1bmsYBiABKAwSHAoUZW5jb2RlZF9yZWNvcmRfYnl0ZXMYByABKAQSDgoGc2hhMjU2GAggASgJItwCCg5DaGF0SW5ncmVzc0FjaxIOCgZydW5faWQYASABKAkSFwoPY29udmVyc2F0aW9uX2lkGAIgASgJEhkKEWNvbW1pdHRlZF90aHJvdWdoGAMgASgEEhUKDWV4cGVjdGVkX25leHQYBCABKAQSOwoGYWN0aW9uGAUgASgOMisubGl2ZWFnZW50LmdhdGV3YXkudjIuQ2hhdEluZ3Jlc3NBY2suQWN0aW9uEhoKEnRlcm1pbmFsX2NvbW1pdHRlZBgGIAEoCBISCgplcnJvcl9jb2RlGAcgASgJEhUKDWVycm9yX21lc3NhZ2UYCCABKAkiawoGQWN0aW9uEhYKEkFDVElPTl9VTlNQRUNJRklFRBAAEgwKCENPTlRJTlVFEAESGAoUUkVQTEFZX0ZST01fRVhQRUNURUQQAhITCg9TRU5EX0NIRUNLUE9JTlQQAxIMCghSRUpFQ1RFRBAEIjwKFEhpc3RvcnlTZXRDd2RSZXF1ZXN0EhcKD2NvbnZlcnNhdGlvbl9pZBgBIAEoCRILCgNjd2QYAiABKAkiWAoVSGlzdG9yeVNldEN3ZFJlc3BvbnNlEj8KDGNvbnZlcnNhdGlvbhgBIAEoCzIpLmxpdmVhZ2VudC5nYXRld2F5LnYyLkNvbnZlcnNhdGlvblN1bW1hcnkiZgoXV29ya3NwYWNlUm9vdEdyYW50RHJhZnQSDwoCaWQYASABKAlIAIgBARINCgVhbGlhcxgCIAEoCRIUCgxkaXNwbGF5X3BhdGgYAyABKAkSDgoGYWNjZXNzGAQgASgJQgUKA19pZCLSAQoSV29ya3NwYWNlUm9vdEdyYW50EgoKAmlkGAEgASgJEhIKCnByb2plY3RfaWQYAiABKAkSGAoQcHJvamVjdF9wYXRoX2tleRgDIAEoCRINCgVhbGlhcxgEIAEoCRIUCgxkaXNwbGF5X3BhdGgYBSABKAkSFgoOY2Fub25pY2FsX3BhdGgYBiABKAkSDgoGYWNjZXNzGAcgASgJEg0KBXN0YXRlGAggASgJEhIKCmNyZWF0ZWRfYXQYCSABKAMSEgoKdXBkYXRlZF9hdBgKIAEoAyKVAQoaV29ya3NwYWNlUm9vdEdyYW50c1JlcXVlc3QSDgoGYWN0aW9uGAEgASgJEhIKCnByb2plY3RfaWQYAiABKAkSFAoMcHJvamVjdF9wYXRoGAMgASgJEj0KBmdyYW50cxgEIAMoCzItLmxpdmVhZ2VudC5nYXRld2F5LnYyLldvcmtzcGFjZVJvb3RHcmFudERyYWZ0IlcKG1dvcmtzcGFjZVJvb3RHcmFudHNSZXNwb25zZRI4CgZncmFudHMYASADKAsyKC5saXZlYWdlbnQuZ2F0ZXdheS52Mi5Xb3Jrc3BhY2VSb290R3JhbnQiPAoXQ2hlY2twb2ludEV4cGVjdGVkRW50cnkSCwoDa2V5GAEgASgJEhQKDGN1cnJlbnRfaGFzaBgCIAEoCSKpAQoRQ2hlY2twb2ludFJlcXVlc3QSDgoGYWN0aW9uGAEgASgJEhcKD2NvbnZlcnNhdGlvbl9pZBgCIAEoCRIQCgh0dXJuX3NlcRgDIAEoBBIYChBhdXRob3JpemVkX3Jvb3RzGAQgAygJEj8KCGV4cGVjdGVkGAUgAygLMi0ubGl2ZWFnZW50LmdhdGV3YXkudjIuQ2hlY2twb2ludEV4cGVjdGVkRW50cnkiOQoSQ2hlY2twb2ludFJlc3BvbnNlEg4KBmFjdGlvbhgBIAEoCRITCgtyZXN1bHRfanNvbhgCIAEoCSrlAQoYSW1wb3J0RGlyZWN0b3J5T3BlcmF0aW9uEioKJklNUE9SVF9ESVJFQ1RPUllfT1BFUkFUSU9OX1VOU1BFQ0lGSUVEEAASJAogSU1QT1JUX0RJUkVDVE9SWV9PUEVSQVRJT05fU1RBUlQQARIqCiZJTVBPUlRfRElSRUNUT1JZX09QRVJBVElPTl9XUklURV9DSFVOSxACEiUKIUlNUE9SVF9ESVJFQ1RPUllfT1BFUkFUSU9OX0NPTU1JVBADEiQKIElNUE9SVF9ESVJFQ1RPUllfT1BFUkFUSU9OX0FCT1JUEAQqxgQKD1R1bm5lbEZyYW1lS2luZBIhCh1UVU5ORUxfRlJBTUVfS0lORF9VTlNQRUNJRklFRBAAEigKJFRVTk5FTF9GUkFNRV9LSU5EX0hUVFBfUkVRVUVTVF9TVEFSVBABEicKI1RVTk5FTF9GUkFNRV9LSU5EX0hUVFBfUkVRVUVTVF9CT0RZEAISJgoiVFVOTkVMX0ZSQU1FX0tJTkRfSFRUUF9SRVFVRVNUX0VORBADEikKJVRVTk5FTF9GUkFNRV9LSU5EX0hUVFBfUkVTUE9OU0VfU1RBUlQQBBIoCiRUVU5ORUxfRlJBTUVfS0lORF9IVFRQX1JFU1BPTlNFX0JPRFkQBRInCiNUVU5ORUxfRlJBTUVfS0lORF9IVFRQX1JFU1BPTlNFX0VORBAGEh0KGVRVTk5FTF9GUkFNRV9LSU5EX1dTX0RJQUwQBxIgChxUVU5ORUxfRlJBTUVfS0lORF9XU19ESUFMX09LEAgSIwofVFVOTkVMX0ZSQU1FX0tJTkRfV1NfRElBTF9FUlJPUhAJEh4KGlRVTk5FTF9GUkFNRV9LSU5EX1dTX0ZSQU1FEAoSHgoaVFVOTkVMX0ZSQU1FX0tJTkRfV1NfQ0xPU0UQCxIbChdUVU5ORUxfRlJBTUVfS0lORF9FUlJPUhAMEhwKGFRVTk5FTF9GUkFNRV9LSU5EX0NBTkNFTBANEhoKFlRVTk5FTF9GUkFNRV9LSU5EX1BJTkcQDhIaChZUVU5ORUxfRlJBTUVfS0lORF9QT05HEA8qgQEKE1R1bm5lbFdzTWVzc2FnZVR5cGUSJgoiVFVOTkVMX1dTX01FU1NBR0VfVFlQRV9VTlNQRUNJRklFRBAAEh8KG1RVTk5FTF9XU19NRVNTQUdFX1RZUEVfVEVYVBABEiEKHVRVTk5FTF9XU19NRVNTQUdFX1RZUEVfQklOQVJZEAJCQFo+Z2l0aHViLmNvbS9saXZlYWdlbnQvYWdlbnQtZ2F0ZXdheS9pbnRlcm5hbC9wcm90by92MjtnYXRld2F5djJiBnByb3RvMw"); /** * @generated from message liveagent.gateway.v2.GatewayEnvelope @@ -3073,6 +3073,14 @@ export type ChatRequest = Message<"liveagent.gateway.v2.ChatRequest"> & { * @generated from field: string queue_policy = 10; */ queuePolicy: string; + + /** + * 命令安全模式(ask/auto/sandbox/sandboxOffline)。远端 WebUI 直带,桌面端据此 + * 覆盖本地 settings.system.commandSafetyMode;空串表示未指定(回落本地设置)。 + * + * @generated from field: string command_safety_mode = 11; + */ + commandSafetyMode: string; }; /** diff --git a/crates/agent-gateway/web/src/styles/base-chat.css b/crates/agent-gateway/web/src/styles/base-chat.css index 387cb0eaf..e4377a647 100644 --- a/crates/agent-gateway/web/src/styles/base-chat.css +++ b/crates/agent-gateway/web/src/styles/base-chat.css @@ -29,7 +29,9 @@ html[data-liveagent-webui="gateway"] .composer-model-trigger, html[data-liveagent-webui="gateway"] .composer-model-label, html[data-liveagent-webui="gateway"] .model-selector-dropdown, html[data-liveagent-webui="gateway"] .model-selector-group-label, -html[data-liveagent-webui="gateway"] .model-selector-item { +html[data-liveagent-webui="gateway"] .model-selector-item, +html[data-liveagent-webui="gateway"] .composer-safety-dropdown, +html[data-liveagent-webui="gateway"] .composer-safety-item { font-family: var(--app-font-family); } diff --git a/crates/agent-gui/src-tauri/Cargo.toml b/crates/agent-gui/src-tauri/Cargo.toml index d284ab05c..9de980149 100644 --- a/crates/agent-gui/src-tauri/Cargo.toml +++ b/crates/agent-gui/src-tauri/Cargo.toml @@ -83,7 +83,19 @@ objc2 = { version = "0.6", default-features = false, features = ["std"] } block2 = "0.6" [target.'cfg(windows)'.dependencies] -windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Storage_FileSystem", "Win32_System_Threading"] } +windows-sys = { version = "0.61", features = [ + "Win32_Foundation", + "Win32_Storage_FileSystem", + "Win32_Security", + "Win32_Security_Authorization", + "Win32_Security_Isolation", + "Win32_System_Threading", + "Win32_System_JobObjects", + "Win32_System_Console", + "Win32_System_Environment", + "Win32_System_Registry", + "Win32_System_LibraryLoader", +] } [dev-dependencies] tauri = { version = "2.11.5", features = ["test"] } diff --git a/crates/agent-gui/src-tauri/src/commands/app/system.rs b/crates/agent-gui/src-tauri/src/commands/app/system.rs index 94baf0208..a32fadb72 100644 --- a/crates/agent-gui/src-tauri/src/commands/app/system.rs +++ b/crates/agent-gui/src-tauri/src/commands/app/system.rs @@ -2486,6 +2486,11 @@ pub(crate) fn system_create_project_folder_sync( }) } +#[tauri::command(rename_all = "snake_case")] +pub fn system_sandbox_capability() -> crate::runtime::sandbox::SandboxCapability { + crate::runtime::sandbox::capability() +} + #[tauri::command(rename_all = "snake_case")] pub async fn system_pick_folder(initial_workdir: Option) -> Result, String> { tauri::async_runtime::spawn_blocking(move || { diff --git a/crates/agent-gui/src-tauri/src/commands/automation/hook.rs b/crates/agent-gui/src-tauri/src/commands/automation/hook.rs index a806c15bf..0fa886df4 100644 --- a/crates/agent-gui/src-tauri/src/commands/automation/hook.rs +++ b/crates/agent-gui/src-tauri/src/commands/automation/hook.rs @@ -166,6 +166,8 @@ pub(crate) fn run_hook_script_sync( None, token.clone(), &context, + // Hook 脚本是用户显式配置的自动化,不属于模型驱动面,不套沙箱。 + None, ); if let (Some(scope), Some(token)) = (&scope_id, &token) { diff --git a/crates/agent-gui/src-tauri/src/commands/config/settings/mod.rs b/crates/agent-gui/src-tauri/src/commands/config/settings/mod.rs index cbadafa80..ae359114a 100644 --- a/crates/agent-gui/src-tauri/src/commands/config/settings/mod.rs +++ b/crates/agent-gui/src-tauri/src/commands/config/settings/mod.rs @@ -37,6 +37,9 @@ const SYSTEM_WORKDIR_KEY: &str = "workdir"; // 工具审批策略(按工具名/`group:`/`server:` 键 → allow/ask/deny)。此前未纳入 // 保存白名单,导致重启后设置丢失;补入本键持久化。 const SYSTEM_TOOL_POLICIES_KEY: &str = "toolPolicies"; +// 命令执行方式("ask"/"auto"/"sandbox"/"sandboxOffline"),与前端 +// SystemSettings.commandSafetyMode 对齐;sandbox* 由执行层映射为 OS 沙箱参数。 +const SYSTEM_COMMAND_SAFETY_MODE_KEY: &str = "commandSafetyMode"; const SYSTEM_WORKSPACE_PROJECTS_KEY: &str = "workspaceProjects"; const SYSTEM_WORKSPACE_PROJECT_GROUPS_KEY: &str = "workspaceProjectGroups"; const SYSTEM_ACTIVE_WORKSPACE_PROJECT_ID_KEY: &str = "activeWorkspaceProjectId"; diff --git a/crates/agent-gui/src-tauri/src/commands/config/settings/system.rs b/crates/agent-gui/src-tauri/src/commands/config/settings/system.rs index 5286d2f14..f61d59558 100644 --- a/crates/agent-gui/src-tauri/src/commands/config/settings/system.rs +++ b/crates/agent-gui/src-tauri/src/commands/config/settings/system.rs @@ -459,10 +459,73 @@ fn system_value_with_defaults(raw: Option, default_workdir: &str) -> Valu SYSTEM_SYSTEM_PROXY_KEY.to_string(), normalize_system_proxy_value(system.get(SYSTEM_SYSTEM_PROXY_KEY)), ); + system.insert( + SYSTEM_COMMAND_SAFETY_MODE_KEY.to_string(), + normalize_command_safety_mode_value(system.get(SYSTEM_COMMAND_SAFETY_MODE_KEY)), + ); Value::Object(system) } +/// 命令安全模式的合法取值,与前端 COMMAND_SAFETY_MODES 一致。 +const COMMAND_SAFETY_MODES: [&str; 4] = ["ask", "auto", "sandbox", "sandboxOffline"]; +/// 键缺失(全新配置/旧快照)时的默认值。 +const COMMAND_SAFETY_MODE_DEFAULT: &str = "auto"; +/// 存在但无法识别时收敛到的最严格值(逐次人工放行)。 +const COMMAND_SAFETY_MODE_FAIL_CLOSED: &str = "ask"; + +/// "ask" | "auto" | "sandbox" | "sandboxOffline"。 +/// +/// - 键缺失 / null / 空串:沿用默认 "auto"(全新配置与旧快照的正常形态)。 +/// - **存在但无法识别:收敛到最严格的 "ask"**(P2#6)。该设置全部意义在于约束,而 +/// `save_system` 会先删除全部 system key 再按白名单重插,故此处的默认值不只是读取 +/// 期兜底,而是会被破坏性地持久化。未来新增的模式值、回退到旧版本、手改配置的 +/// 笔误若静默降级成最宽松的非 ask 值,等于悄悄放宽用户的约束选择;与 sandbox.rs +/// 自述的 fail-closed 原则一致,一律向严格侧收敛并留下告警。 +/// +/// 与前端 normalizeCommandSafetyMode 保持同一套语义。 +fn normalize_command_safety_mode_value(raw: Option<&Value>) -> Value { + let Some(value) = raw.filter(|value| !value.is_null()) else { + return Value::String(COMMAND_SAFETY_MODE_DEFAULT.to_string()); + }; + let Some(text) = value.as_str().map(str::trim) else { + // 非字符串(类型损坏)同样是"存在但无法识别"。 + eprintln!( + "[settings] non-string commandSafetyMode {value}; failing closed to \ +\"{COMMAND_SAFETY_MODE_FAIL_CLOSED}\"" + ); + return Value::String(COMMAND_SAFETY_MODE_FAIL_CLOSED.to_string()); + }; + if text.is_empty() { + return Value::String(COMMAND_SAFETY_MODE_DEFAULT.to_string()); + } + if COMMAND_SAFETY_MODES.contains(&text) { + return Value::String(text.to_string()); + } + eprintln!( + "[settings] unrecognized commandSafetyMode {value}; failing closed to \ +\"{COMMAND_SAFETY_MODE_FAIL_CLOSED}\"" + ); + Value::String(COMMAND_SAFETY_MODE_FAIL_CLOSED.to_string()) +} + +/// 沙箱下限的唯一权威来源(P2#3):后端自行回查持久化的 commandSafetyMode,不采信 +/// 调用方(渲染进程 / 网关 / Cron 调度器)声明的布尔。与 `load_runtime_ssh_host` +/// 同一范式——服务端重新解析持久化配置,而不是信任请求参数。 +/// +/// 读取失败时返回 Err:调用方据此 fail-closed(报错),绝不静默降级成无沙箱执行。 +pub(crate) fn load_runtime_command_safety_mode() -> Result { + let conn = open_db()?; + let system = load_system(&conn)?; + let raw = system + .as_ref() + .and_then(|value| value.get(SYSTEM_COMMAND_SAFETY_MODE_KEY)); + match normalize_command_safety_mode_value(raw) { + Value::String(mode) => Ok(mode), + _ => Ok(COMMAND_SAFETY_MODE_FAIL_CLOSED.to_string()), + } +} + fn load_system_with_defaults(conn: &Connection, default_workdir: &str) -> Result { Ok(system_value_with_defaults( load_system(conn)?, @@ -500,6 +563,7 @@ fn save_system_with_default_workdir( SYSTEM_EXECUTION_MODE_KEY, SYSTEM_WORKDIR_KEY, SYSTEM_TOOL_POLICIES_KEY, + SYSTEM_COMMAND_SAFETY_MODE_KEY, SYSTEM_WORKSPACE_PROJECTS_KEY, SYSTEM_WORKSPACE_PROJECT_GROUPS_KEY, SYSTEM_ACTIVE_WORKSPACE_PROJECT_ID_KEY, diff --git a/crates/agent-gui/src-tauri/src/commands/config/settings/tests.rs b/crates/agent-gui/src-tauri/src/commands/config/settings/tests.rs index c0e34a1f8..bd28e7cc7 100644 --- a/crates/agent-gui/src-tauri/src/commands/config/settings/tests.rs +++ b/crates/agent-gui/src-tauri/src/commands/config/settings/tests.rs @@ -1181,12 +1181,13 @@ mod tests { }; let loaded = load_system(&conn).expect("load system"); - assert_eq!(row_count, 11); + assert_eq!(row_count, 12); assert_eq!( keys, vec![ SYSTEM_ACTIVE_WORKSPACE_PROJECT_ID_KEY.to_string(), SYSTEM_ARCHIVED_WORKSPACE_PROJECT_PATHS_KEY.to_string(), + SYSTEM_COMMAND_SAFETY_MODE_KEY.to_string(), SYSTEM_EXECUTION_MODE_KEY.to_string(), SYSTEM_HIDDEN_WORKSPACE_PROJECT_PATHS_KEY.to_string(), SYSTEM_MISSING_WORKSPACE_PROJECT_PATHS_KEY.to_string(), @@ -1207,6 +1208,7 @@ mod tests { "missingWorkspaceProjectPaths": [], "archivedWorkspaceProjectPaths": [], "workspaceResourceSettings": {}, + "commandSafetyMode": "auto", "systemProxy": default_system_proxy_json(), "workdir": default_workdir.clone(), "toolPolicies": { "Bash": "ask", "server:docs-mcp": "deny" }, @@ -1505,6 +1507,7 @@ mod tests { "missingWorkspaceProjectPaths": [], "archivedWorkspaceProjectPaths": [], "workspaceResourceSettings": {}, + "commandSafetyMode": "auto", "systemProxy": default_system_proxy_json(), "workdir": "/tmp/liveagent-default-project", "toolPolicies": null, @@ -1558,6 +1561,7 @@ mod tests { "missingWorkspaceProjectPaths": [], "archivedWorkspaceProjectPaths": [], "workspaceResourceSettings": {}, + "commandSafetyMode": "auto", "systemProxy": default_system_proxy_json(), "workdir": "/tmp/liveagent-default-project", "toolPolicies": null, @@ -1578,9 +1582,44 @@ mod tests { ); } + // P2#6:未识别的命令安全模式必须向严格侧(ask)收敛,不能静默降级成 auto —— + // save_system 会把归一结果破坏性地写回磁盘。 #[test] - fn load_system_with_defaults_returns_agent_mode_and_default_project() { - let conn = open_memory_db(); + fn command_safety_mode_unrecognized_value_fails_closed_to_ask() { + // 缺失 / null / 空串:正常缺省形态,沿用 auto。 + assert_eq!(normalize_command_safety_mode_value(None), json!("auto")); + assert_eq!( + normalize_command_safety_mode_value(Some(&Value::Null)), + json!("auto") + ); + assert_eq!( + normalize_command_safety_mode_value(Some(&json!(" "))), + json!("auto") + ); + // 合法值原样保留(含空白裁剪)。 + for mode in ["ask", "auto", "sandbox", "sandboxOffline"] { + assert_eq!( + normalize_command_safety_mode_value(Some(&json!(format!(" {mode} ")))), + json!(mode) + ); + } + // 未来新增的模式值 / 回退旧版本 / 手改笔误 / 类型错误:一律收敛到 ask。 + assert_eq!( + normalize_command_safety_mode_value(Some(&json!("sandboxStrictest"))), + json!("ask") + ); + assert_eq!( + normalize_command_safety_mode_value(Some(&json!("Auto"))), + json!("ask") + ); + assert_eq!( + normalize_command_safety_mode_value(Some(&json!(1))), + json!("ask") + ); + } + + #[test] + fn load_system_with_defaults_returns_agent_mode_and_default_project() { let conn = open_memory_db(); let loaded = load_system_with_defaults(&conn, "/tmp/liveagent-default-project") .expect("load system"); @@ -1593,6 +1632,7 @@ mod tests { "missingWorkspaceProjectPaths": [], "archivedWorkspaceProjectPaths": [], "workspaceResourceSettings": {}, + "commandSafetyMode": "auto", "systemProxy": default_system_proxy_json(), "workdir": "/tmp/liveagent-default-project", "workspaceProjects": [ diff --git a/crates/agent-gui/src-tauri/src/commands/runtime/process.rs b/crates/agent-gui/src-tauri/src/commands/runtime/process.rs index 9a9b0e524..decb3270a 100644 --- a/crates/agent-gui/src-tauri/src/commands/runtime/process.rs +++ b/crates/agent-gui/src-tauri/src/commands/runtime/process.rs @@ -5,9 +5,12 @@ use tauri::State; use crate::runtime::managed_process::{ ManagedProcessLogResponse, ManagedProcessRegistry, ManagedProcessSnapshot, ManagedProcessStartResponse, ManagedProcessStatusResponse, ManagedProcessStopResponse, + ManagedProcessWaitResponse, }; +use crate::runtime::sandbox::{resolve_effective_options, SandboxOptions}; #[tauri::command(rename_all = "snake_case")] +#[allow(clippy::too_many_arguments)] pub fn managed_process_start( registry: State<'_, Arc>, workdir: String, @@ -15,8 +18,21 @@ pub fn managed_process_start( cwd: Option, label: Option, isolated: Option, + sandbox: bool, + sandbox_allow_network: bool, ) -> Result { - registry.start(workdir, command, cwd, label, isolated.unwrap_or(false)) + // 请求侧声明只能加严;下限由后端回查持久化的 commandSafetyMode 得出(P1#3)。 + let sandbox_options = resolve_effective_options(sandbox.then_some(SandboxOptions { + allow_network: sandbox_allow_network, + }))?; + registry.start( + workdir, + command, + cwd, + label, + isolated.unwrap_or(false), + sandbox_options, + ) } #[tauri::command(rename_all = "snake_case")] @@ -44,6 +60,22 @@ pub fn managed_process_read_log( registry.read_log(process_id, max_bytes) } +#[tauri::command(rename_all = "snake_case")] +pub async fn managed_process_wait( + registry: State<'_, Arc>, + process_id: String, + cursor: Option, + yield_time_ms: Option, + max_bytes: Option, +) -> Result { + let registry = Arc::clone(registry.inner()); + tauri::async_runtime::spawn_blocking(move || { + registry.wait(process_id, cursor, yield_time_ms, max_bytes) + }) + .await + .map_err(|error| format!("managed_process_wait join failed: {error}"))? +} + #[tauri::command(rename_all = "snake_case")] pub fn managed_process_snapshot( registry: State<'_, Arc>, diff --git a/crates/agent-gui/src-tauri/src/commands/runtime/shell.rs b/crates/agent-gui/src-tauri/src/commands/runtime/shell.rs index 663f4496c..61ebe75ec 100644 --- a/crates/agent-gui/src-tauri/src/commands/runtime/shell.rs +++ b/crates/agent-gui/src-tauri/src/commands/runtime/shell.rs @@ -3,7 +3,10 @@ use std::sync::Arc; use serde::Serialize; use tauri::State; -use crate::runtime::shell_runner::{run_shell_script, ShellRunRegistry, ShellRunResponse}; +use crate::runtime::sandbox::{resolve_effective_options, SandboxOptions}; +use crate::runtime::shell_runner::{ + run_shell_script_with_envs, ShellRunRegistry, ShellRunResponse, +}; use crate::runtime::shell_session::{ShellSessionManager, ShellSessionResponse}; #[derive(Debug, Serialize)] @@ -11,7 +14,19 @@ pub struct ShellCancelResponse { cancelled: bool, } +/// 请求侧的沙箱声明只是"上限之下的加严",最终生效值由后端与持久化的 +/// commandSafetyMode 取更严格者(P1#3)。任何 host 都无法靠传 `sandbox=false` 绕过围栏。 +fn effective_sandbox_options( + sandbox: bool, + sandbox_allow_network: bool, +) -> Result, String> { + resolve_effective_options(sandbox.then_some(SandboxOptions { + allow_network: sandbox_allow_network, + })) +} + #[tauri::command(rename_all = "snake_case")] +#[allow(clippy::too_many_arguments)] pub async fn shell_run( registry: State<'_, Arc>, workdir: String, @@ -21,7 +36,10 @@ pub async fn shell_run( max_timeout_ms: Option, provider_id: Option, run_id: Option, + sandbox: bool, + sandbox_allow_network: bool, ) -> Result { + let sandbox_options = effective_sandbox_options(sandbox, sandbox_allow_network)?; let normalized_run_id = run_id .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()); @@ -29,7 +47,7 @@ pub async fn shell_run( let registered_token = cancel_token.clone(); let join_result = tauri::async_runtime::spawn_blocking(move || { - run_shell_script( + run_shell_script_with_envs( workdir, command, cwd, @@ -37,6 +55,8 @@ pub async fn shell_run( max_timeout_ms, provider_id, cancel_token, + &[], + sandbox_options, ) }) .await; @@ -71,8 +91,11 @@ pub async fn shell_session_start( yield_time_ms: Option, timeout_ms: Option, max_timeout_ms: Option, + sandbox: bool, + sandbox_allow_network: bool, ) -> Result { let manager = Arc::clone(manager.inner()); + let sandbox_options = effective_sandbox_options(sandbox, sandbox_allow_network)?; tauri::async_runtime::spawn_blocking(move || { manager.start( session_id, @@ -82,6 +105,7 @@ pub async fn shell_session_start( yield_time_ms, timeout_ms, max_timeout_ms, + sandbox_options, ) }) .await diff --git a/crates/agent-gui/src-tauri/src/lib.rs b/crates/agent-gui/src-tauri/src/lib.rs index d738ed671..757682d04 100644 --- a/crates/agent-gui/src-tauri/src/lib.rs +++ b/crates/agent-gui/src-tauri/src/lib.rs @@ -208,6 +208,7 @@ macro_rules! app_invoke_handler { commands::process::managed_process_status, commands::process::managed_process_stop, commands::process::managed_process_read_log, + commands::process::managed_process_wait, commands::process::managed_process_snapshot, commands::process::managed_process_clear, commands::terminal::terminal_shell_options, @@ -283,6 +284,7 @@ macro_rules! app_invoke_handler { commands::system::system_resolve_dropped_workspace_folders, commands::system::system_classify_dropped_paths, commands::system::system_pick_file, + commands::system::system_sandbox_capability, commands::system::system_save_preview_file, commands::system::system_create_project_folder, commands::system::system_import_pasted_texts, @@ -693,6 +695,11 @@ fn configure_windows_window_chrome(app: &tauri::App) -> tauri::Result<()> { #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { + // 最早期钩子:若本进程是 Windows 沙箱的自我再执行启动器(__sandbox_exec), + // 就在此建立受限令牌并运行真实命令,以其退出码退出——绝不继续初始化 Tauri。 + // 非 Windows 平台为空操作。 + runtime::windows_sandbox::run_sandbox_launcher_if_requested(); + let automation_store = Arc::new( services::automation::AutomationStore::open() .expect("failed to initialize LiveAgent automation store"), diff --git a/crates/agent-gui/src-tauri/src/runtime/managed_process.rs b/crates/agent-gui/src-tauri/src/runtime/managed_process.rs index 01b29cf0f..b53c86577 100644 --- a/crates/agent-gui/src-tauri/src/runtime/managed_process.rs +++ b/crates/agent-gui/src-tauri/src/runtime/managed_process.rs @@ -18,6 +18,7 @@ use crate::runtime::process::{ signal_process_tree_by_pid, terminate_child_process_tree, terminate_process_tree_by_pid, ProcessProbe, }; +use crate::runtime::sandbox::{SandboxOptions, SandboxSpec}; use crate::runtime::shell_runner::spawn_platform_shell_command; use crate::services::gateway::GatewayController; @@ -27,6 +28,9 @@ const MAX_LOG_BYTES: u64 = 512 * 1024; const STOP_GRACE_MS: u64 = 500; const SHUTDOWN_GRACE_MS: u64 = 1200; const MONITOR_INTERVAL_MS: u64 = 2000; +const DEFAULT_WAIT_MS: u64 = 30_000; +const MAX_WAIT_MS: u64 = 300_000; +const WAIT_POLL_MS: u64 = 50; /// Rate limit for pid-probing restored entries (no Child handle to poll). const RESTORED_PROBE_INTERVAL_MS: u128 = 2000; /// `ps -o etime` has second granularity; a restored pid whose probed start @@ -162,6 +166,17 @@ pub struct ManagedProcessLogResponse { pub bytes: u64, } +#[derive(Debug, Serialize)] +pub struct ManagedProcessWaitResponse { + pub process: ManagedProcessRecord, + pub log_path: String, + pub content: String, + pub truncated: bool, + pub bytes: u64, + pub cursor: u64, + pub timed_out: bool, +} + fn now_ms() -> u128 { SystemTime::now() .duration_since(UNIX_EPOCH) @@ -249,17 +264,34 @@ fn sanitize_rel_cwd(input: Option, workdir: &Path) -> Result Result<(Child, String), String> { +fn spawn_shell_command( + command: &str, + cwd: &Path, + log: File, + log_path: &Path, + sandbox_spec: Option<&SandboxSpec>, +) -> Result<(Child, String), String> { let stderr = log .try_clone() .map_err(|err| format!("Failed to clone process log: {err}"))?; - let spawned = spawn_platform_shell_command(command, cwd, &[], || { - Ok(( - Stdio::from(log.try_clone()?), - Stdio::from(stderr.try_clone()?), - )) - })?; + // POSIX(Git Bash) 不会把继承的 Win32 文件句柄接到 fd 1/2,所以把日志路径 + // 交给脚本用 POSIX 路径自己打开,并用 stdbuf 按行刷盘。PowerShell/cmd 仍走 + // 继承句柄。映像仍是平台 shell,Windows 沙箱对 Git Bash 的 SID 判定不变。 + let spawned = spawn_platform_shell_command( + command, + cwd, + &[], + sandbox_spec, + true, + Some(log_path), + || { + Ok(( + Stdio::from(log.try_clone()?), + Stdio::from(stderr.try_clone()?), + )) + }, + )?; Ok((spawned.child, spawned.profile.display_shell.to_string())) } @@ -267,6 +299,33 @@ fn entry_running(entry: &ManagedProcessEntry) -> bool { entry.finished_at.is_none() } +fn read_log_from_cursor( + log_path: &Path, + cursor: u64, + max_bytes: u64, +) -> Result<(String, u64, u64, bool), String> { + let mut file = + File::open(log_path).map_err(|err| format!("Failed to open process log: {err}"))?; + let len = file.metadata().map_err(|err| err.to_string())?.len(); + let start = cursor.min(len); + let remaining = len.saturating_sub(start); + let truncated = remaining > max_bytes; + let read_len = remaining.min(max_bytes); + file.seek(SeekFrom::Start(start)) + .map_err(|err| format!("Failed to seek process log: {err}"))?; + let mut bytes = vec![0_u8; read_len as usize]; + if read_len > 0 { + file.read_exact(&mut bytes) + .map_err(|err| format!("Failed to read process log: {err}"))?; + } + Ok(( + String::from_utf8_lossy(&bytes).to_string(), + start.saturating_add(read_len), + read_len, + truncated, + )) +} + enum RecordProbe { AliveMatching, Gone, @@ -517,6 +576,7 @@ impl ManagedProcessRegistry { cwd: Option, label: Option, isolated: bool, + sandbox_options: Option, ) -> Result { let command = command.trim().to_string(); if command.is_empty() { @@ -531,7 +591,16 @@ impl ManagedProcessRegistry { .append(true) .open(&log_path) .map_err(|err| format!("Failed to open process log: {err}"))?; - let (child, shell) = spawn_shell_command(&command, &cwd, log)?; + // 写围栏锚定工作区根;dev server 等常驻进程通常要监听端口,是否放网络 + // 由调用方经 SandboxOptions 决定。isolated 常驻进程须在 LiveAgent 退出后 + // 存活,透传给沙箱规格以省略 Linux 的 --die-with-parent 死亡耦合。 + let sandbox_spec = sandbox_options.map(|options| { + let mut spec = SandboxSpec::from_options(workdir.clone(), options); + spec.isolated = isolated; + spec + }); + let (child, shell) = + spawn_shell_command(&command, &cwd, log, &log_path, sandbox_spec.as_ref())?; let pid = child.id(); let entry = ManagedProcessEntry { id: id.clone(), @@ -695,6 +764,81 @@ impl ManagedProcessRegistry { }) } + /// Blocks until the process exits, the log grows past `cursor`, or + /// `yield_time_ms` elapses. `cursor` is the absolute byte offset already + /// consumed (same idea as ProcessWait); omit it to start from 0. + pub fn wait( + &self, + id: String, + cursor: Option, + yield_time_ms: Option, + max_bytes: Option, + ) -> Result { + let id = id.trim().to_string(); + if id.is_empty() { + return Err("process_id is required".to_string()); + } + let yield_for = Duration::from_millis( + yield_time_ms + .unwrap_or(DEFAULT_WAIT_MS) + .clamp(1, MAX_WAIT_MS), + ); + let limit = max_bytes + .unwrap_or(DEFAULT_LOG_BYTES) + .clamp(1, MAX_LOG_BYTES); + let cursor = cursor.unwrap_or(0); + let deadline = Instant::now() + yield_for; + + loop { + let _ = self.sync(); + let (running, log_path) = { + let processes = self.lock_processes()?; + let Some(entry) = processes.get(&id) else { + return Err(format!("Managed process not found: {id}")); + }; + (entry_running(entry), entry.log_path.clone()) + }; + let len = fs::metadata(&log_path).map(|meta| meta.len()).unwrap_or(0); + if !running || len > cursor { + let (content, new_cursor, bytes, truncated) = + read_log_from_cursor(&log_path, cursor, limit)?; + let process = self + .status(Some(id))? + .processes + .into_iter() + .next() + .ok_or_else(|| "Managed process disappeared during wait".to_string())?; + return Ok(ManagedProcessWaitResponse { + process, + log_path: log_path.display().to_string(), + content, + truncated, + bytes, + cursor: new_cursor, + timed_out: false, + }); + } + if Instant::now() >= deadline { + let process = self + .status(Some(id))? + .processes + .into_iter() + .next() + .ok_or_else(|| "Managed process disappeared during wait".to_string())?; + return Ok(ManagedProcessWaitResponse { + process, + log_path: log_path.display().to_string(), + content: String::new(), + truncated: false, + bytes: 0, + cursor, + timed_out: true, + }); + } + std::thread::sleep(Duration::from_millis(WAIT_POLL_MS)); + } + } + /// Reconciles journal rows left by the previous run: isolated rows whose /// pid still matches are restored into the panel; non-isolated leftovers /// are crash residue and get their trees reaped; everything else is @@ -935,11 +1079,11 @@ mod tests { let mut last_log = registry .read_log(process_id.to_string(), Some(1024)) .expect("log should be readable"); - for _ in 0..50 { + for _ in 0..150 { if last_log.content.contains(expected) { return last_log; } - std::thread::sleep(Duration::from_millis(20)); + std::thread::sleep(Duration::from_millis(50)); last_log = registry .read_log(process_id.to_string(), Some(1024)) .expect("log should be readable"); @@ -947,6 +1091,50 @@ mod tests { last_log } + #[test] + fn managed_process_echo_is_visible_while_running() { + let temp_dir = temp_workdir("echo-flush"); + let registry = ManagedProcessRegistry::default(); + let started = registry + .start( + temp_dir.display().to_string(), + "echo liveagent-managed-process-ready; sleep 30".to_string(), + None, + Some("flush test".to_string()), + false, + None, + ) + .expect("process should start"); + let process_id = started.process.id.clone(); + let log = wait_for_log_content(®istry, &process_id, "liveagent-managed-process-ready"); + let status = registry + .status(Some(process_id.clone())) + .expect("status should work"); + assert!( + log.content.contains("liveagent-managed-process-ready"), + "periodic/early echo must appear in the log before the process exits; \ + running={} exit={:?} shell={} pid={} content={:?}", + status.processes[0].running, + status.processes[0].exit_code, + started.process.shell, + started.process.pid, + log.content + ); + assert!( + status.processes[0].running, + "echo should be visible while the managed process is still running" + ); + let waited = registry + .wait(process_id.clone(), Some(0), Some(5_000), Some(4096)) + .expect("wait should work"); + assert!(waited.content.contains("liveagent-managed-process-ready")); + assert!(!waited.timed_out); + assert!(waited.cursor > 0); + + let _ = registry.stop(process_id); + let _ = fs::remove_dir_all(&temp_dir); + } + #[cfg(unix)] #[test] fn managed_process_runs_logs_and_stops() { @@ -960,6 +1148,7 @@ mod tests { None, Some("test process".to_string()), false, + None, ) .expect("process should start"); let process_id = started.process.id.clone(); @@ -1003,6 +1192,7 @@ mod tests { None, Some("drop test process".to_string()), false, + None, ) .expect("process should start"); let pid = started.process.pid; @@ -1028,6 +1218,7 @@ mod tests { None, None, false, + None, ) .expect("process should start"); let pid = started.process.pid; @@ -1061,6 +1252,7 @@ mod tests { None, None, false, + None, ) .expect("process should start"); let pid = started.process.pid; @@ -1093,6 +1285,7 @@ mod tests { None, None, false, + None, ) .expect("process should start"); let pid = started.process.pid; @@ -1122,6 +1315,7 @@ mod tests { None, None, false, + None, ) .expect("plain process should start"); let isolated = registry @@ -1131,6 +1325,7 @@ mod tests { None, None, true, + None, ) .expect("isolated process should start"); @@ -1163,6 +1358,7 @@ mod tests { None, None, false, + None, ) .expect("plain process should start"); let isolated = registry @@ -1172,6 +1368,7 @@ mod tests { None, Some("isolated service".to_string()), true, + None, ) .expect("isolated process should start"); let ids = ( @@ -1229,6 +1426,7 @@ mod tests { None, None, false, + None, ) .expect("short process should start"); let running = registry @@ -1238,6 +1436,7 @@ mod tests { None, None, false, + None, ) .expect("long process should start"); diff --git a/crates/agent-gui/src-tauri/src/runtime/mod.rs b/crates/agent-gui/src-tauri/src/runtime/mod.rs index 6395c5d83..40447ffbc 100644 --- a/crates/agent-gui/src-tauri/src/runtime/mod.rs +++ b/crates/agent-gui/src-tauri/src/runtime/mod.rs @@ -3,8 +3,10 @@ pub mod managed_process_journal; pub mod platform; pub mod process; pub mod project_path; +pub mod sandbox; pub mod sftp; pub mod shell_runner; pub mod shell_session; pub mod task_runner; pub mod terminal; +pub mod windows_sandbox; diff --git a/crates/agent-gui/src-tauri/src/runtime/sandbox.rs b/crates/agent-gui/src-tauri/src/runtime/sandbox.rs new file mode 100644 index 000000000..6dfe6be1c --- /dev/null +++ b/crates/agent-gui/src-tauri/src/runtime/sandbox.rs @@ -0,0 +1,1478 @@ +//! OS 级沙箱(沙箱模式 v1):模型驱动的 Bash / ManagedProcess 在生成子进程前 +//! 由平台原生机制包裹——macOS 走 Seatbelt(/usr/bin/sandbox-exec),Linux 走 +//! bubblewrap(bwrap),Windows 联网模式走 Low Integrity 主令牌,断网模式走 +//! AppContainer(均带工作区写围栏 + Job Object,免管理员/免 UAC)。 +//! +//! 语义为 workspace-write:读默认放行(工具链/依赖散布全盘,default-deny 不现实), +//! 写仅限工作区根 + 临时目录,敏感目录(~/.ssh、应用配置库等)读写全掩蔽,网络可 +//! 整体关断。fail-closed:沙箱被请求而平台机制不可用时直接报错,绝不静默降级为 +//! 无沙箱执行。 +//! +//! Windows 双后端(均免管理员/免 UAC,见 memory windows-sandbox-facts): +//! - sandbox(联网):当前用户主令牌副本降到 Low IL,网络能力与无沙箱进程一致; +//! 工作区/TEMP 同步标 Low,由 MIC NoWriteUp 围栏写入。 +//! - sandboxOffline(断网):AppContainer(零 capability)。WFP 对无网络 capability +//! 的 AppContainer 默认拒绝全部网络含 loopback ⇒ 内核级强制断网,无需提权(对比 +//! Codex:unelevated 仅 env 级软断网,强制断网须提权建专用账号+防火墙)。AC 默认 +//! 拒绝未授权读,系统目录靠自带的 ALL APPLICATION PACKAGES ACE 可读,用户主目录 +//! 默认不可读 ⇒ 断网变体顺带获得敏感目录读掩蔽;读收紧对 offline 场景可接受。 + +use serde::Serialize; +use std::path::{Component, Path, PathBuf}; + +/// 自我再执行启动器子命令标记:Windows `wrap_command` 把 (program, args) 包成 +/// `current_exe __sandbox_exec --write-root --net on|off [--isolated] -- `; +/// 进程启动最早期 `windows_sandbox::run_sandbox_launcher_if_requested` 识别它, +/// 按 --net 建 Low IL 主令牌(联网)或 AppContainer(断网)后执行真实命令。 +/// 非 Windows 平台不产生该标记。 +pub(crate) const SANDBOX_EXEC_SUBCOMMAND: &str = "__sandbox_exec"; + +/// 启动器解析后的调用信息(纯逻辑,跨平台可测)。 +#[cfg_attr(not(windows), allow(dead_code))] +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct LauncherInvocation { + pub write_root: PathBuf, + /// 网络放行与否决定后端:true → Low IL 主令牌(联网),false → AppContainer(断网)。 + pub allow_network: bool, + /// isolated 常驻进程须在启动器死亡后继续存活 ⇒ 启动器不得给子进程挂 + /// KILL_ON_JOB_CLOSE 的 Job Object(对齐 Linux bwrap 省略 --die-with-parent)。 + pub isolated: bool, + pub program: PathBuf, + pub args: Vec, +} + +/// 构造传给自我再执行启动器的参数向量(含子命令标记,作为 argv[1])。 +/// 形如 `[__sandbox_exec, --write-root, , --net, on|off, [--isolated,] --, , ]`。 +#[cfg_attr(not(windows), allow(dead_code))] +pub(crate) fn build_launcher_args( + write_root: &Path, + allow_network: bool, + isolated: bool, + program: &Path, + args: &[String], +) -> Vec { + let mut out = vec![ + SANDBOX_EXEC_SUBCOMMAND.to_string(), + "--write-root".to_string(), + write_root.to_string_lossy().into_owned(), + "--net".to_string(), + if allow_network { "on" } else { "off" }.to_string(), + ]; + if isolated { + out.push("--isolated".to_string()); + } + out.push("--".to_string()); + out.push(program.to_string_lossy().into_owned()); + out.extend(args.iter().cloned()); + out +} + +/// 解析启动器 payload(子命令标记之后的部分): +/// `--write-root --net on|off [--isolated] -- [args...]`。 +/// `--net` 为必填:构造与解析同版本(同一 exe 自我再执行),不存在旧格式兼容问题; +/// 缺失即拒绝,绝不隐式默认成某个后端。 +#[cfg_attr(not(windows), allow(dead_code))] +pub(crate) fn parse_launcher_args(payload: &[String]) -> Result { + let mut it = payload.iter(); + let mut write_root: Option = None; + let mut allow_network: Option = None; + let mut isolated = false; + let mut program: Option = None; + let mut rest: Vec = Vec::new(); + while let Some(tok) = it.next() { + match tok.as_str() { + "--write-root" => { + let value = it + .next() + .ok_or_else(|| "--write-root requires a value".to_string())?; + write_root = Some(PathBuf::from(value)); + } + "--net" => { + let value = it + .next() + .ok_or_else(|| "--net requires a value".to_string())?; + allow_network = Some(match value.as_str() { + "on" => true, + "off" => false, + other => return Err(format!("--net expects on|off, got: {other}")), + }); + } + "--isolated" => isolated = true, + "--" => { + program = it.next().map(PathBuf::from); + rest = it.cloned().collect(); + break; + } + other => return Err(format!("unexpected launcher argument: {other}")), + } + } + let write_root = write_root.ok_or_else(|| "missing --write-root".to_string())?; + let allow_network = allow_network.ok_or_else(|| "missing --net on|off".to_string())?; + let program = program.ok_or_else(|| "missing program after `--`".to_string())?; + Ok(LauncherInvocation { + write_root, + allow_network, + isolated, + program, + args: rest, + }) +} + +/// 由工作区规范路径确定性推导合成 SID(Codex 形式 `S-1-5-21-{4×u32}`)。 +/// 稳定 + 无状态:同一路径永远得同一 SID —— 遗留的继承 ACE 在下次运行仍精确匹配, +/// 无需持久化。用稳定的 FNV-1a(不用 DefaultHasher,其算法跨版本不保证稳定)。 +/// Windows 路径大小写不敏感,先小写化再哈希,`C:\Foo` 与 `c:\foo` 得同一 SID。 +/// 边角:Rust 的 Unicode 小写化与 Windows 的 upcase 折叠(如 dotted/dotless I、ß) +/// 不完全一致,非 ASCII 工作区路径的两种大小写可能得不同 SID,导致遗留继承 ACE 不匹配 +/// → 写被拒。这是 fail-closed(功能受限,非逃逸),ASCII 路径不受影响。 +#[cfg_attr(not(windows), allow(dead_code))] +pub(crate) fn synthetic_workspace_sid(write_root: &Path) -> String { + fn fnv1a64(bytes: &[u8]) -> u64 { + let mut hash: u64 = 0xcbf2_9ce4_8422_2325; + for &b in bytes { + hash ^= b as u64; + hash = hash.wrapping_mul(0x0000_0100_0000_01b3); + } + hash + } + let canonical = write_root.to_string_lossy().to_lowercase(); + let h1 = fnv1a64(canonical.as_bytes()); + // 二次哈希掺入盐,得到独立的低 64 位,凑满 4×u32 子权限。 + let mut salted = canonical.into_bytes(); + salted.push(0); + salted.extend_from_slice(b"liveagent-sandbox"); + let h2 = fnv1a64(&salted); + let a = (h1 >> 32) as u32; + let b = h1 as u32; + let c = (h2 >> 32) as u32; + let d = h2 as u32; + format!("S-1-5-21-{a}-{b}-{c}-{d}") +} + +/// 按 Windows(CommandLineToArgvW)规则拼装命令行,并以 NUL 结尾成 UTF-16。 +/// 算法逐字复刻 Rust 标准库 `make_command_line`/`append_arg`,以保证 Low IL token 下 +/// `CreateProcessAsUserW` 的子进程收到与非沙箱 `std::process::Command` 完全一致的 +/// argv —— 行为对齐,不引入解析差异。纯逻辑,跨平台可测。 +#[cfg_attr(not(windows), allow(dead_code))] +pub(crate) fn build_command_line(program: &str, args: &[String]) -> Vec { + fn append_arg(cmd: &mut Vec, arg: &str) { + let arg: Vec = arg.encode_utf16().collect(); + let space = u16::from(b' '); + let tab = u16::from(b'\t'); + let quote = u16::from(b'"'); + let backslash = u16::from(b'\\'); + let needs_quote = arg.is_empty() || arg.iter().any(|&c| c == space || c == tab); + if needs_quote { + cmd.push(quote); + } + let mut backslashes: usize = 0; + for &w in &arg { + if w == backslash { + backslashes += 1; + } else { + if w == quote { + // 把 " 之前的反斜杠翻倍,再补一个,最后加转义的 "。 + for _ in 0..=backslashes { + cmd.push(backslash); + } + } + backslashes = 0; + } + cmd.push(w); + } + if needs_quote { + for _ in 0..backslashes { + cmd.push(backslash); + } + cmd.push(quote); + } + } + + let mut cmd: Vec = Vec::new(); + append_arg(&mut cmd, program); + for a in args { + cmd.push(u16::from(b' ')); + append_arg(&mut cmd, a); + } + cmd.push(0); + cmd +} + +/// 把裸程序名解析成 PATH 中的绝对路径(Windows 语义:`;` 分隔、套用 PATHEXT), +/// **只搜索 PATH 里的绝对目录,绝不搜索当前/工作目录**。 +/// +/// 缘由:`CreateProcessAsUserW` 的 `lpApplicationName` 若是“部分名”,Win32 只用当前 +/// 盘符+当前目录补全且**不查 PATH**(见 CreateProcess 文档)。而沙箱启动器的 cwd 就是 +/// 工作区(模型可写),裸名 `cmd.exe` 会在工作区里被补全:轻则找不到而整体失败,重则 +/// 命中模型投毒的同名二进制并被当作 shell 执行。故这里预解析成系统 shell 的绝对路径, +/// 剔除 PATH 里的相对项(含 `"."`),即便用户 PATH 带 `.` 也不会落到工作区。 +/// +/// 绝对路径入参原样返回。纯逻辑;`is_file` 谓词注入以便跨平台单测(Windows 路径语义 +/// 由 Windows 编译+真机验证,`is_absolute`/`join` 在本机按 Unix 规则)。 +#[cfg_attr(not(windows), allow(dead_code))] +pub(crate) fn resolve_program_in_path( + program: &Path, + path_env: &str, + pathext: &str, + is_file: &dyn Fn(&Path) -> bool, +) -> Option { + if program.is_absolute() { + return Some(program.to_path_buf()); + } + let name = program.as_os_str(); + // 候选扩展名:先原样(""),再逐个 PATHEXT 项(裸名 pwsh → pwsh.EXE)。 + let mut exts: Vec = vec![String::new()]; + exts.extend( + pathext + .split(';') + .map(str::trim) + .filter(|e| !e.is_empty()) + .map(str::to_string), + ); + for dir in path_env.split(';').map(str::trim) { + let dir_path = Path::new(dir); + // 只认绝对目录:剔除 ""、"."、相对项 —— 杜绝落回工作区。 + if !dir_path.is_absolute() { + continue; + } + for ext in &exts { + let mut file = name.to_os_string(); + file.push(ext); + let candidate = dir_path.join(&file); + if is_file(&candidate) { + return Some(candidate); + } + } + } + None +} + +/// Microsoft Store / MSIX 执行别名位于 `WindowsApps`。沙箱安全上下文不能直接 +/// 通过 `CreateProcess*` 启动这类打包二进制,因此不能把它当作 shell。 +/// 同时按 `/` 与 `\` 分段,以便在非 Windows 宿主上用 Windows 路径字面量做单测。 +#[cfg_attr(not(windows), allow(dead_code))] +pub(crate) fn is_msix_windowsapps_path(path: &Path) -> bool { + path.to_string_lossy() + .split(['\\', '/']) + .any(|seg| seg.eq_ignore_ascii_case("WindowsApps")) +} + +/// HKCU 子键:CAPI/CNG 在 provider 初始化时会对它们 `RegCreateKey`(创建即写)。 +/// Low IL token 若面对 Medium 标签会被 NoWriteUp 拒绝,最终被 PowerShell/.NET +/// 误报成 “BCrypt.dll 加载失败”(exit `0xE0434352`)。 +/// 这是用户证书/密钥库的窄例外,不是放开整个 HKCU。 +#[cfg_attr(not(windows), allow(dead_code))] +pub(crate) const CNG_USER_REGISTRY_SUBKEYS: &[&str] = &[ + r"Software\Microsoft\SystemCertificates", + r"Software\Microsoft\SystemCertificates\CA", + r"Software\Microsoft\SystemCertificates\Root", + r"Software\Microsoft\SystemCertificates\My", + r"Software\Policies\Microsoft\SystemCertificates", + r"Software\Policies\Microsoft\SystemCertificates\CA", + r"Software\Microsoft\Cryptography", +]; + +#[cfg_attr(not(windows), allow(dead_code))] +pub(crate) fn cng_named_registry_object(subkey: &str) -> String { + format!("CURRENT_USER\\{subkey}") +} + +/// CNG 还会把密钥容器 / DPAPI / 证书 URL 缓存写到用户配置目录 +/// (非 TEMP;沙箱的 TEMP 重定向覆盖不到这里)。 +#[cfg_attr(not(windows), allow(dead_code))] +pub(crate) fn cng_user_file_dirs(appdata: &Path, localappdata: &Path) -> Vec { + vec![ + appdata.join("Microsoft").join("Crypto"), + appdata.join("Microsoft").join("Protect"), + localappdata.join("Microsoft").join("CryptnetUrlCache"), + ] +} + +/// HKCU 子键:Windows PowerShell 5.1 / .NET Framework CLR 启动会 `RegCreateKey`。 +/// 与 CNG 证书库是另一条失败面——这里被拒时进程以 HRESULT `0x80070005` +/// (E_ACCESSDENIED)退出,而不是 `0xE0434352` / `NTE_PROVIDER_DLL_FAIL`。 +/// 仍是用户运行时缓存的窄例外,不是放开整个 HKCU。 +#[cfg_attr(not(windows), allow(dead_code))] +pub(crate) const CLR_USER_REGISTRY_SUBKEYS: &[&str] = &[ + r"Software\Microsoft\PowerShell", + r"Software\Microsoft\PowerShell\1", + r"Software\Microsoft\Windows\PowerShell", + r"Software\Microsoft\.NETFramework", +]; + +/// .NET Framework / Windows PowerShell 启动还会写用户 CLR 缓存与模块分析目录 +/// (Fusion、UsageLogs、ModuleAnalysisCache)。不盖这些路径时,powershell.exe +/// 在 Low IL token 下不可写时会以 `0x80070005` 立即崩溃。 +/// +/// 故意不含 `%LOCALAPPDATA%\Temp`:TEMP 已重定向到围栏目录,给用户真实 Temp +/// 盖写 ACE 会把围栏撕开。 +#[cfg_attr(not(windows), allow(dead_code))] +pub(crate) fn clr_user_file_dirs(appdata: &Path, localappdata: &Path) -> Vec { + vec![ + localappdata.join("Microsoft").join("CLR_v4.0"), + localappdata.join("Microsoft").join("CLR_v4.0_32"), + localappdata.join("assembly"), + localappdata + .join("Microsoft") + .join("Windows") + .join("PowerShell"), + localappdata.join("Microsoft").join("PowerShell"), + appdata.join("Microsoft").join("Windows").join("PowerShell"), + appdata.join("Microsoft").join("CLR Security Config"), + localappdata.join("IsolatedStorage"), + ] +} + +#[derive(Debug, Clone, Copy)] +pub(crate) struct SandboxOptions { + pub allow_network: bool, +} + +/// 展开后的沙箱规格:write_root 是允许写入的工作区根(须为 canonicalize 后的 +/// 绝对路径,shell_runner / managed_process 的 workdir 校验已保证)。 +#[derive(Debug, Clone)] +pub(crate) struct SandboxSpec { + pub write_root: PathBuf, + pub allow_network: bool, + /// isolated 常驻进程须在 LiveAgent 退出后继续存活(managed_process 的 isolated + /// 语义)。Linux bwrap 据此省略 `--die-with-parent`;Windows 启动器据此省略 + /// KILL_ON_JOB_CLOSE 的 Job Object(经 `--isolated` 穿透自我再执行边界)。 + /// macOS Seatbelt 无父进程死亡耦合,不读取本字段。 + #[cfg_attr(target_os = "macos", allow(dead_code))] + pub isolated: bool, +} + +impl SandboxSpec { + pub(crate) fn from_options(write_root: PathBuf, options: SandboxOptions) -> Self { + Self { + write_root, + allow_network: options.allow_network, + // 默认非 isolated(Bash 工具子进程随 LiveAgent 退出而终止); + // managed_process 的 isolated 常驻进程构造后显式置 true。 + isolated: false, + } + } +} + +/// 命令安全模式 → 沙箱参数。`ask`/`auto` 不启用 OS 沙箱(`ask` 是前端的逐次人工放行 +/// 闸门,不改变执行隔离),`sandbox` 联网、`sandboxOffline` 断网。 +pub(crate) fn options_from_mode(mode: &str) -> Option { + match mode.trim() { + "sandbox" => Some(SandboxOptions { + allow_network: true, + }), + "sandboxOffline" => Some(SandboxOptions { + allow_network: false, + }), + _ => None, + } +} + +/// 取更严格的一方。严格度:无沙箱 < 联网沙箱 < 断网沙箱。 +pub(crate) fn strictest( + a: Option, + b: Option, +) -> Option { + match (a, b) { + (Some(x), Some(y)) => Some(SandboxOptions { + allow_network: x.allow_network && y.allow_network, + }), + (Some(only), None) | (None, Some(only)) => Some(only), + (None, None) => None, + } +} + +/// 后端独立下限(P1#3):渲染进程送来的 `sandbox` / `sandbox_allow_network` 只能"加严", +/// 绝不能放宽。后端在命令边界回查持久化的 `settings.system.commandSafetyMode` 自行推出 +/// 下限,并与请求值取更严格者——不论请求来自桌面 UI、网关(远端 WebUI)还是 Cron 调度器, +/// 同一下限强制生效(对齐 `load_runtime_ssh_host` 的"服务端重解析持久化配置"范式)。 +/// +/// 读取持久化配置失败 ⇒ 直接报错(fail-closed),绝不因为读不到设置就无沙箱执行。 +pub(crate) fn resolve_effective_options( + requested: Option, +) -> Result, String> { + let mode = crate::commands::settings::load_runtime_command_safety_mode().map_err(|err| { + format!( + "Cannot verify the persisted sandbox floor (settings.system.commandSafetyMode): {err}. \ +Refusing to run the command unsandboxed." + ) + })?; + Ok(strictest(requested, options_from_mode(&mode))) +} + +#[derive(Debug, Clone, Serialize)] +pub struct SandboxCapability { + pub supported: bool, + pub mechanism: &'static str, + pub platform: &'static str, + /// 是否支持断网变体(sandboxOffline)。macOS/Linux 在 `supported` 时为 true; + /// Windows 由运行时探测决定(能否派生 AppContainer SID)。`supported=false` 时 + /// 该字段无意义(整体不可用)。 + pub network_control: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option, +} + +/// 敏感目录掩蔽表(相对 home)。应用自身配置目录(provider 密钥、审批策略所在的 +/// config.sqlite)一并掩蔽;默认工作区在其内部,由 write_root 的后置 allow 规则 +/// 重新放行,不受影响。 +fn sensitive_home_subdirs() -> [&'static str; 4] { + [".ssh", ".aws", ".gnupg", ".config/gh"] +} + +fn app_config_dir() -> Option { + dirs::home_dir().map(|home| home.join(format!(".{}", env!("CARGO_PKG_NAME")))) +} + +fn sensitive_dirs() -> Vec { + let mut dirs_out = Vec::new(); + if let Some(home) = dirs::home_dir() { + for sub in sensitive_home_subdirs() { + dirs_out.push(home.join(sub)); + } + } + if let Some(config) = app_config_dir() { + dirs_out.push(config); + } + dirs_out +} + +fn canonical_or_self(path: &Path) -> PathBuf { + std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()) +} + +/// 词法比较前的路径归一(P2#5)。 +/// +/// `canonical_or_self` 只在路径存在时才 canonicalize,而 Windows 的 canonicalize 会 +/// 加上 verbatim 前缀(`\\?\C:\...`、UNC 形式 `\\?\UNC\server\share`);不存在的路径 +/// 则保持原始形态。`Path::starts_with` 是纯词法的组件比较,不做前缀归一,于是两侧 +/// 前缀形态不一致时(write_root 存在而某敏感目录不存在,或反之)比较恒为 false —— +/// 围栏校验被静默跳过,属 fail-open。这里统一剥掉 verbatim 前缀,并在 Windows 上折叠 +/// 大小写(NTFS 路径大小写不敏感),让比较在两种形态下都成立。 +fn normalize_for_compare(path: &Path) -> PathBuf { + let text = path.to_string_lossy(); + let stripped: String = if let Some(rest) = text.strip_prefix(r"\\?\UNC\") { + format!(r"\\{rest}") + } else if let Some(rest) = text.strip_prefix(r"\\?\") { + rest.to_string() + } else { + text.to_string() + }; + if cfg!(windows) { + PathBuf::from(stripped.to_lowercase()) + } else { + PathBuf::from(stripped) + } +} + +/// `ancestor` 是否包含或等于 `descendant`(归一后按组件比较)。 +fn path_encloses(ancestor: &Path, descendant: &Path) -> bool { + normalize_for_compare(descendant).starts_with(normalize_for_compare(ancestor)) +} + +/// fail-closed 工作区校验(P1#2):拒绝会让写围栏重新暴露敏感目录的工作区。 +/// +/// 写围栏对 write_root 有后置 re-allow(macOS)/后置 --bind(Linux),因此: +/// - **祖先或相等**:工作区包含或等于任一敏感目录(如工作区取 home 或 /), +/// re-allow 会把该敏感目录重新放行 → 一律拒绝。 +/// - **后代**:工作区落在敏感目录内部。凭据目录(~/.ssh/.aws/.gnupg/.config/gh) +/// 下的工作区一律拒绝;应用配置目录(~/.liveagent)豁免——默认工作区 +/// ~/.liveagent/default-project 正位于其内,拒绝它会直接打断开箱即用。 +/// +/// wrap 路径(`wrap_command`)与 Windows 自我再执行启动器(`windows_sandbox::win::execute`) +/// 两个入口都必须调用它,否则同一 write_root 在两条链上前置条件不对称(P3#8)。 +pub(crate) fn validate_workspace(write_root: &Path) -> Result<(), String> { + let root = canonical_or_self(write_root); + let app_config = app_config_dir().map(|p| canonical_or_self(&p)); + + for dir in sensitive_dirs() { + let dir = canonical_or_self(&dir); + if path_encloses(&root, &dir) { + return Err(format!( + "Sandbox refuses workspace \"{}\": it encloses or equals the sensitive directory \ +\"{}\", which the workspace write fence would re-expose. Choose a workspace that does not \ +contain credential or app-config directories.", + root.display(), + dir.display() + )); + } + if path_encloses(&dir, &root) { + // 应用配置目录内部豁免(默认工作区在此),其余敏感目录内部一律拒绝。 + let dir_key = normalize_for_compare(&dir); + if app_config + .as_deref() + .is_some_and(|config| normalize_for_compare(config) == dir_key) + { + continue; + } + return Err(format!( + "Sandbox refuses workspace \"{}\": it lives inside the sensitive directory \"{}\". \ +Choose a workspace outside credential directories.", + root.display(), + dir.display() + )); + } + } + Ok(()) +} + +/// Darwin 用户临时目录形如 `/var/folders///T`(或 `/private/var/folders/.../T`)。 +/// 只有这种布局才允许把写放行提升到父目录,以同时覆盖 confstr 的 `T` 与 `C` +/// (clang 模块缓存等)。对 `/tmp`、`/private/tmp`、`$HOME` 做 `parent()` 会得到 +/// `/` 或 `$HOME`,Seatbelt last-match-wins 会把整盘(含 ~/.ssh)重新放行。 +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +pub(crate) fn darwin_user_temp_parent(tmpdir: &Path) -> Option { + let mut names: Vec<&str> = Vec::new(); + for component in tmpdir.components() { + match component { + Component::RootDir => {} + Component::Normal(name) => names.push(name.to_str()?), + _ => return None, + } + } + let n = names.len(); + if n < 4 || names[n - 1] != "T" || names[n - 4] != "folders" { + return None; + } + let prefix = &names[..n - 4]; + if prefix != ["var"] && prefix != ["private", "var"] { + return None; + } + tmpdir.parent().map(Path::to_path_buf) +} + +/// 临时写根是否安全:不得等于或包住 `/`、`$HOME`、或任一敏感目录,否则后置 +/// write-allow / bwrap `--bind` 会重新暴露凭据(与 `validate_workspace` 同一理由)。 +#[cfg(any(not(windows), test))] +pub(crate) fn temp_write_root_is_safe(path: &Path) -> bool { + let root = canonical_or_self(path); + let cmp = normalize_for_compare(&root); + if cmp == Path::new("/") || cmp.as_os_str().is_empty() { + return false; + } + #[cfg(windows)] + { + if cmp.components().count() <= 1 { + return false; + } + } + if let Some(home) = dirs::home_dir() { + if path_encloses(&root, &canonical_or_self(&home)) { + return false; + } + } + for dir in sensitive_dirs() { + if path_encloses(&root, &canonical_or_self(&dir)) { + return false; + } + } + true +} + +/// 把 `bwrap` 解析成绝对路径,绝不搜索 cwd / 相对 PATH。 +/// +/// 优先 `/usr/bin/bwrap` 与 `/usr/local/bin/bwrap`,避免项目 PATH 前缀 +/// (`node_modules/.bin`、`.venv/bin`)里的同名投毒二进制胜出。随后只走绝对 PATH +/// 目录;`skip_under` 用于 wrap 时跳过工作区(模型可写)内的命中。 +#[cfg_attr(any(windows, target_os = "macos"), allow(dead_code))] +pub(crate) fn resolve_bwrap_executable( + path_env: &str, + is_file: &dyn Fn(&Path) -> bool, + skip_under: Option<&Path>, +) -> Option { + const PINNED: &[&str] = &["/usr/bin/bwrap", "/usr/local/bin/bwrap"]; + let skipped = |candidate: &Path| skip_under.is_some_and(|root| path_encloses(root, candidate)); + for candidate in PINNED { + let path = Path::new(candidate); + if is_file(path) && !skipped(path) { + return Some(path.to_path_buf()); + } + } + for dir in path_env.split(':').map(str::trim) { + let dir_path = Path::new(dir); + if !dir_path.is_absolute() || skipped(dir_path) { + continue; + } + let candidate = dir_path.join("bwrap"); + if is_file(&candidate) && !skipped(&candidate) { + return Some(candidate); + } + } + None +} + +/// 临时目录允许写入集合:TMPDIR(仅 Darwin 用户私有 `/var/folders/.../T` 才提升 +/// 到父级以覆盖 confstr 缓存目录)、std::env::temp_dir、以及系统级 tmp。 +/// 会包住 `/` 或 `$HOME` 的根一律丢弃,绝不写进 Seatbelt allow / bwrap `--bind`。 +#[cfg(not(windows))] +fn writable_temp_dirs() -> Vec { + let mut out: Vec = Vec::new(); + let mut push_canonical = |path: PathBuf| { + if !temp_write_root_is_safe(&path) { + return; + } + let canonical = std::fs::canonicalize(&path).unwrap_or(path); + if !temp_write_root_is_safe(&canonical) { + return; + } + if !out.iter().any(|existing| *existing == canonical) { + out.push(canonical); + } + }; + + if let Ok(tmpdir) = std::env::var("TMPDIR") { + let tmpdir = PathBuf::from(tmpdir.trim_end_matches('/')); + if tmpdir.is_absolute() && tmpdir.is_dir() { + // /var/folders/xx/yyy/T → 放行父级 /var/folders/xx/yyy,同时覆盖 + // DARWIN_USER_CACHE_DIR(…/C)。其它布局(含 TMPDIR=/tmp)禁止提升。 + #[cfg(target_os = "macos")] + if let Some(parent) = darwin_user_temp_parent(&tmpdir) { + push_canonical(parent); + } + push_canonical(tmpdir); + } + } + push_canonical(std::env::temp_dir()); + for path in ["/tmp", "/var/tmp", "/private/tmp", "/private/var/tmp"] { + let path = Path::new(path); + if path.is_dir() { + push_canonical(path.to_path_buf()); + } + } + out +} + +pub fn capability() -> SandboxCapability { + platform::capability() +} + +/// 把即将执行的 (program, args) 包进平台沙箱,返回替换后的 +/// (program, args, mechanism)。平台不支持或依赖缺失时报错(fail-closed)。 +pub(crate) fn wrap_command( + spec: &SandboxSpec, + program: &Path, + args: &[String], +) -> Result<(PathBuf, Vec, &'static str), String> { + let capability = capability(); + if !capability.supported { + return Err(format!( + "Sandbox mode is enabled but unavailable on this platform: {}. \ +Disable sandbox mode in Settings → System, or resolve the issue and retry.", + capability + .reason + .as_deref() + .unwrap_or("unsupported platform") + )); + } + validate_workspace(&spec.write_root)?; + platform::wrap_command(spec, program, args) +} + +#[cfg(target_os = "macos")] +mod platform { + use super::*; + + const SANDBOX_EXEC: &str = "/usr/bin/sandbox-exec"; + + pub(super) fn capability() -> SandboxCapability { + if Path::new(SANDBOX_EXEC).exists() { + SandboxCapability { + supported: true, + mechanism: "seatbelt", + platform: "macos", + network_control: true, + reason: None, + } + } else { + SandboxCapability { + supported: false, + mechanism: "seatbelt", + platform: "macos", + network_control: false, + reason: Some(format!("{SANDBOX_EXEC} not found")), + } + } + } + + /// Seatbelt 字符串字面量转义:反斜杠与双引号。 + fn escape(path: &Path) -> String { + path.to_string_lossy() + .replace('\\', "\\\\") + .replace('"', "\\\"") + } + + fn subpath_filters(paths: &[PathBuf]) -> String { + paths + .iter() + .map(|p| format!("(subpath \"{}\")", escape(p))) + .collect::>() + .join(" ") + } + + /// allow-default + 写入围栏的 Seatbelt profile。规则匹配以“最后命中者优先”, + /// 顺序:全局 allow → 全盘写 deny → 工作区/临时目录写 allow → 设备节点写 + /// allow → 敏感目录读 deny → 工作区读写 re-allow(默认工作区位于应用配置目录 + /// 内,须排在敏感目录 deny 之后)→ 可选网络 deny。 + pub(super) fn seatbelt_profile(spec: &SandboxSpec) -> String { + let mut writable = vec![spec.write_root.clone()]; + writable.extend(writable_temp_dirs()); + + let mut profile = String::from("(version 1)\n(allow default)\n(deny file-write*)\n"); + profile.push_str(&format!( + "(allow file-write* {})\n", + subpath_filters(&writable) + )); + profile.push_str( + "(allow file-write-data file-ioctl (literal \"/dev/null\") (literal \"/dev/zero\") \ +(literal \"/dev/tty\") (literal \"/dev/stdout\") (literal \"/dev/stderr\") \ +(literal \"/dev/dtracehelper\"))\n(allow file-write* (subpath \"/dev/fd\"))\n", + ); + let sensitive = sensitive_dirs(); + if !sensitive.is_empty() { + profile.push_str(&format!( + "(deny file-read* {})\n", + subpath_filters(&sensitive) + )); + } + profile.push_str(&format!( + "(allow file-read* file-write* (subpath \"{}\"))\n", + escape(&spec.write_root) + )); + if !spec.allow_network { + profile.push_str("(deny network*)\n"); + } + profile + } + + pub(super) fn wrap_command( + spec: &SandboxSpec, + program: &Path, + args: &[String], + ) -> Result<(PathBuf, Vec, &'static str), String> { + let mut out = vec!["-p".to_string(), seatbelt_profile(spec)]; + out.push(program.to_string_lossy().into_owned()); + out.extend(args.iter().cloned()); + Ok((PathBuf::from(SANDBOX_EXEC), out, "seatbelt")) + } +} + +#[cfg(all(not(windows), not(target_os = "macos")))] +mod platform { + use super::*; + use std::process::Command; + use std::sync::OnceLock; + + static CAPABILITY: OnceLock = OnceLock::new(); + + fn resolve_installed_bwrap() -> Option { + resolve_bwrap_executable( + &std::env::var("PATH").unwrap_or_default(), + &|path| path.is_file(), + None, + ) + } + + fn probe() -> SandboxCapability { + let unsupported = |reason: String| SandboxCapability { + supported: false, + mechanism: "bubblewrap", + platform: "linux", + network_control: false, + reason: Some(reason), + }; + let Some(bwrap) = resolve_installed_bwrap() else { + return unsupported( + "bubblewrap (bwrap) is not available. Install it, e.g. `apt install bubblewrap`." + .to_string(), + ); + }; + // 探测真实可用性(容器/受限内核里 bwrap 可能存在但无法建 namespace)。 + // 必须用解析出的绝对路径,绝不用 PATH 上的相对名(工作区可写目录可能抢先)。 + match Command::new(&bwrap) + .args([ + "--die-with-parent", + "--unshare-pid", + "--ro-bind", + "/", + "/", + "--proc", + "/proc", + "--dev", + "/dev", + "--", + "/bin/true", + ]) + .output() + { + Ok(output) if output.status.success() => SandboxCapability { + supported: true, + mechanism: "bubblewrap", + platform: "linux", + network_control: true, + reason: None, + }, + Ok(output) => unsupported(format!( + "bubblewrap probe failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + )), + Err(err) => unsupported(format!("bubblewrap (bwrap) is not available: {err}.")), + } + } + + pub(super) fn capability() -> SandboxCapability { + CAPABILITY.get_or_init(probe).clone() + } + + pub(super) fn bwrap_args(spec: &SandboxSpec) -> Vec { + // isolated 常驻进程须在 LiveAgent 退出后存活,不能与父进程死亡耦合; + // 非 isolated(Bash 工具子进程)保持 --die-with-parent,避免遗留孤儿。 + let mut args: Vec = Vec::new(); + if !spec.isolated { + args.push("--die-with-parent".to_string()); + } + args.extend( + [ + "--unshare-pid", + "--ro-bind", + "/", + "/", + "--proc", + "/proc", + "--dev", + "/dev", + ] + .into_iter() + .map(String::from), + ); + + for tmp in writable_temp_dirs() { + let tmp = tmp.to_string_lossy().into_owned(); + args.extend(["--bind".to_string(), tmp.clone(), tmp]); + } + // 掩蔽须在 write_root 绑定之前:默认工作区位于应用配置目录内,后置的 + // --bind 会在 tmpfs 掩蔽之上重新暴露工作区。 + for dir in sensitive_dirs() { + if dir.is_dir() { + args.extend(["--tmpfs".to_string(), dir.to_string_lossy().into_owned()]); + } + } + let root = spec.write_root.to_string_lossy().into_owned(); + args.extend(["--bind".to_string(), root.clone(), root]); + if !spec.allow_network { + args.push("--unshare-net".to_string()); + } + args.push("--".to_string()); + args + } + + pub(super) fn wrap_command( + spec: &SandboxSpec, + program: &Path, + args: &[String], + ) -> Result<(PathBuf, Vec, &'static str), String> { + let bwrap = resolve_bwrap_executable( + &std::env::var("PATH").unwrap_or_default(), + &|path| path.is_file(), + Some(&spec.write_root), + ) + .ok_or_else(|| { + "Sandbox mode is enabled but bwrap was not found outside the workspace. \ +Install bubblewrap to a system path such as /usr/bin/bwrap (a binary inside the \ +project folder is never used)." + .to_string() + })?; + let mut out = bwrap_args(spec); + out.push(program.to_string_lossy().into_owned()); + out.extend(args.iter().cloned()); + Ok((bwrap, out, "bubblewrap")) + } +} + +#[cfg(windows)] +mod platform { + use super::*; + use std::sync::OnceLock; + + static CAPABILITY: OnceLock = OnceLock::new(); + + /// 运行时探测(P1#4):不再硬编码"恒可用"。两个后端都实际建一次安全上下文—— + /// 联网后端复制当前用户主令牌并降到 Low IL,断网后端派生 + /// AppContainer SID。组策略、EDR hook、受限 SKU 会让这些调用在真机上失败; + /// 探测把失败提前暴露成 `supported=false` / `network_control=false`, + /// `wrap_command` 的 fail-closed 守卫因而在 Windows 上真正可达。 + fn probe() -> SandboxCapability { + let unsupported = |reason: String| SandboxCapability { + supported: false, + mechanism: "low-integrity-token", + platform: "windows", + network_control: false, + reason: Some(reason), + }; + // 自我再执行启动器以 current_exe 为壳,解析不出来则整体不可用。 + if let Err(err) = std::env::current_exe() { + return unsupported(format!("cannot resolve current executable: {err}")); + } + let (networked_token, appcontainer) = crate::runtime::windows_sandbox::probe_backends(); + if let Err(err) = networked_token { + return unsupported(format!("low-integrity token backend unavailable: {err}")); + } + SandboxCapability { + supported: true, + mechanism: "low-integrity-token", + platform: "windows", + // 断网变体走 AppContainer:派生不出 AC SID ⇒ 仅 sandboxOffline 不可用, + // 联网写围栏仍然可用(UI 据此只禁用断网项)。 + network_control: appcontainer.is_ok(), + reason: appcontainer + .err() + .map(|err| format!("offline (AppContainer) backend unavailable: {err}")), + } + } + + pub(super) fn capability() -> SandboxCapability { + CAPABILITY.get_or_init(probe).clone() + } + + pub(super) fn wrap_command( + spec: &SandboxSpec, + program: &Path, + args: &[String], + ) -> Result<(PathBuf, Vec, &'static str), String> { + // 自我再执行:把真实命令包进 current_exe 的 __sandbox_exec 启动器。启动器在 + // 进程最早期按 --net 选后端:on → Low IL 主令牌(CreateProcessAsUserW),off → + // AppContainer(CreateProcessW + SECURITY_CAPABILITIES);见 windows_sandbox。 + if !spec.allow_network && !capability().network_control { + return Err(format!( + "Offline sandbox is enabled but the AppContainer backend is unavailable on this \ +machine: {}. Switch to the networked sandbox mode or resolve the issue and retry.", + capability() + .reason + .as_deref() + .unwrap_or("AppContainer SID could not be derived") + )); + } + let current_exe = std::env::current_exe() + .map_err(|err| format!("failed to resolve current executable for sandbox: {err}"))?; + let launcher_args = build_launcher_args( + &spec.write_root, + spec.allow_network, + spec.isolated, + program, + args, + ); + let mechanism = if spec.allow_network { + "low-integrity-token" + } else { + "appcontainer" + }; + Ok((current_exe, launcher_args, mechanism)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[cfg(target_os = "macos")] + #[test] + fn seatbelt_profile_contains_write_root_and_ordering() { + let spec = SandboxSpec { + write_root: PathBuf::from("/tmp/liveagent \"quoted\" ws"), + allow_network: false, + isolated: false, + }; + let profile = platform::seatbelt_profile(&spec); + assert!(profile.starts_with("(version 1)\n(allow default)\n(deny file-write*)\n")); + assert!(profile.contains("liveagent \\\"quoted\\\" ws")); + assert!(profile.ends_with("(deny network*)\n")); + // 工作区 re-allow 必须位于敏感目录 deny 之后(最后命中者优先)。 + let deny_read = profile + .find("(deny file-read*") + .expect("deny file-read rule"); + let reallow = profile + .find("(allow file-read* file-write*") + .expect("workspace re-allow rule"); + assert!(reallow > deny_read); + } + + #[cfg(target_os = "macos")] + #[test] + fn seatbelt_network_allowed_omits_network_rule() { + let spec = SandboxSpec { + write_root: PathBuf::from("/tmp/ws"), + allow_network: true, + isolated: false, + }; + assert!(!platform::seatbelt_profile(&spec).contains("network")); + } + + #[cfg(all(not(windows), not(target_os = "macos")))] + #[test] + fn bwrap_args_order_masks_before_write_root_bind() { + let spec = SandboxSpec { + write_root: PathBuf::from("/home/user/project"), + allow_network: false, + isolated: false, + }; + let args = platform::bwrap_args(&spec); + assert_eq!(args.first().map(String::as_str), Some("--die-with-parent")); + assert!(args.contains(&"--unshare-net".to_string())); + assert_eq!(args.last().map(String::as_str), Some("--")); + let root_bind = args + .iter() + .position(|a| a == "/home/user/project") + .expect("write root bind"); + if let Some(mask) = args.iter().position(|a| a == "--tmpfs") { + assert!(mask < root_bind); + } + } + + // P1#3:isolated 常驻进程不能与父进程死亡耦合,bwrap 须省略 --die-with-parent。 + #[cfg(all(not(windows), not(target_os = "macos")))] + #[test] + fn bwrap_args_isolated_omits_die_with_parent() { + let base = PathBuf::from("/home/user/project"); + let attached = platform::bwrap_args(&SandboxSpec { + write_root: base.clone(), + allow_network: false, + isolated: false, + }); + assert!(attached.contains(&"--die-with-parent".to_string())); + + let isolated = platform::bwrap_args(&SandboxSpec { + write_root: base, + allow_network: false, + isolated: true, + }); + assert!(!isolated.contains(&"--die-with-parent".to_string())); + // 省略死亡耦合后,其余围栏(pid namespace、根只读绑定)保持不变。 + assert_eq!(isolated.first().map(String::as_str), Some("--unshare-pid")); + assert_eq!(isolated.last().map(String::as_str), Some("--")); + } + + // P1#2:工作区若包含/等于敏感目录,写围栏 re-allow 会重新暴露之 → 拒绝。 + #[test] + fn validate_workspace_rejects_ancestor_of_sensitive_dir() { + let Some(home) = dirs::home_dir() else { + return; + }; + // home 本身包含 ~/.ssh 等敏感目录。 + assert!(validate_workspace(&home).is_err()); + } + + // P1#2:凭据目录内部的工作区一律拒绝。 + #[test] + fn validate_workspace_rejects_inside_credential_dir() { + let Some(home) = dirs::home_dir() else { + return; + }; + let inside_ssh = home.join(".ssh").join("ws"); + assert!(validate_workspace(&inside_ssh).is_err()); + } + + // P1#2:应用配置目录内部豁免——默认工作区 ~/.liveagent/default-project 必须放行。 + #[test] + fn validate_workspace_allows_default_project_under_app_config() { + let Some(config) = app_config_dir() else { + return; + }; + let default_project = config.join("default-project"); + assert!(validate_workspace(&default_project).is_ok()); + } + + // P1#2:与任何敏感目录无祖先/后代关系的普通工作区放行。 + #[test] + fn validate_workspace_allows_ordinary_workspace() { + assert!(validate_workspace(Path::new("/tmp/liveagent-ordinary-ws")).is_ok()); + } + + // --- 跨平台纯逻辑(Windows 启动器所依赖,可在任意宿主上运行) --- + + // P2#5:verbatim 前缀必须在词法比较前剥掉,否则 canonicalize 过的一侧与未 + // canonicalize 的一侧永不匹配(fail-open)。 + #[test] + fn normalize_for_compare_strips_verbatim_prefixes() { + assert_eq!( + normalize_for_compare(Path::new(r"\\?\C:\ws\proj")).to_string_lossy(), + if cfg!(windows) { + r"c:\ws\proj".to_string() + } else { + r"C:\ws\proj".to_string() + } + ); + assert_eq!( + normalize_for_compare(Path::new(r"\\?\UNC\server\share\ws")).to_string_lossy(), + r"\\server\share\ws" + ); + } + + #[test] + fn path_encloses_matches_ancestor_and_self() { + assert!(path_encloses( + Path::new("/home/user"), + Path::new("/home/user/.ssh") + )); + assert!(path_encloses( + Path::new("/home/user"), + Path::new("/home/user") + )); + assert!(!path_encloses( + Path::new("/home/user/.ssh"), + Path::new("/home/user") + )); + // 前缀形态不同(一侧 canonicalize 过)仍须匹配。 + #[cfg(windows)] + { + assert!(path_encloses( + Path::new(r"C:\Users\Me"), + Path::new(r"\\?\C:\Users\Me\.ssh") + )); + assert!(path_encloses( + Path::new(r"\\?\C:\Users\Me"), + Path::new(r"c:\users\me\.aws") + )); + } + } + + // P1#3:下限与请求值取更严格者;两者皆无沙箱才不围栏。 + #[test] + fn strictest_takes_the_tighter_side() { + let online = Some(SandboxOptions { + allow_network: true, + }); + let offline = Some(SandboxOptions { + allow_network: false, + }); + assert!(strictest(None, None).is_none()); + assert_eq!(strictest(None, online).map(|o| o.allow_network), Some(true)); + assert_eq!(strictest(online, None).map(|o| o.allow_network), Some(true)); + // 一侧断网 ⇒ 结果断网(不允许被另一侧放宽回联网)。 + assert_eq!( + strictest(online, offline).map(|o| o.allow_network), + Some(false) + ); + assert_eq!( + strictest(offline, online).map(|o| o.allow_network), + Some(false) + ); + } + + #[test] + fn options_from_mode_only_sandbox_modes_fence() { + assert!(options_from_mode("auto").is_none()); + assert!(options_from_mode("ask").is_none()); + assert!(options_from_mode("nonsense").is_none()); + assert_eq!( + options_from_mode("sandbox").map(|o| o.allow_network), + Some(true) + ); + assert_eq!( + options_from_mode("sandboxOffline").map(|o| o.allow_network), + Some(false) + ); + } + + #[test] + fn launcher_args_roundtrip() { + let program = PathBuf::from(r"C:\Program Files\Git\bin\bash.exe"); + let args = vec!["-lc".to_string(), "echo \"hi there\" && ls".to_string()]; + for (allow_network, isolated) in + [(true, false), (false, false), (true, true), (false, true)] + { + let built = build_launcher_args( + Path::new(r"C:\ws\proj"), + allow_network, + isolated, + &program, + &args, + ); + assert_eq!(built[0], SANDBOX_EXEC_SUBCOMMAND); + // payload = built[1..](去掉 argv[1] 子命令标记),即启动器实际解析的部分。 + let parsed = parse_launcher_args(&built[1..]).expect("parse"); + assert_eq!(parsed.write_root, PathBuf::from(r"C:\ws\proj")); + assert_eq!(parsed.allow_network, allow_network); + assert_eq!(parsed.isolated, isolated); + assert_eq!(parsed.program, program); + assert_eq!(parsed.args, args); + } + } + + #[test] + fn parse_launcher_args_rejects_incomplete() { + assert!(parse_launcher_args(&["--write-root".to_string()]).is_err()); + assert!(parse_launcher_args(&["--".to_string()]).is_err()); + assert!(parse_launcher_args(&[]).is_err()); + // 缺 --write-root。 + assert!(parse_launcher_args(&[ + "--net".to_string(), + "on".to_string(), + "--".to_string(), + "cmd.exe".to_string(), + ]) + .is_err()); + // 缺 --net(必填,绝不隐式默认后端)。 + assert!(parse_launcher_args(&[ + "--write-root".to_string(), + r"C:\ws".to_string(), + "--".to_string(), + "cmd.exe".to_string(), + ]) + .is_err()); + // --net 值非法。 + assert!(parse_launcher_args(&[ + "--write-root".to_string(), + r"C:\ws".to_string(), + "--net".to_string(), + "maybe".to_string(), + "--".to_string(), + "cmd.exe".to_string(), + ]) + .is_err()); + } + + #[test] + fn parse_launcher_args_program_without_extra_args() { + let parsed = parse_launcher_args(&[ + "--write-root".to_string(), + r"C:\ws".to_string(), + "--net".to_string(), + "off".to_string(), + "--".to_string(), + "cmd.exe".to_string(), + ]) + .expect("parse"); + assert_eq!(parsed.program, PathBuf::from("cmd.exe")); + assert!(!parsed.allow_network); + assert!(!parsed.isolated); + assert!(parsed.args.is_empty()); + } + + #[test] + fn synthetic_sid_is_deterministic_and_case_insensitive() { + let a = synthetic_workspace_sid(Path::new(r"C:\Users\Me\Project")); + let b = synthetic_workspace_sid(Path::new(r"c:\users\me\project")); + assert_eq!(a, b, "Windows 路径大小写不敏感,应得同一 SID"); + assert!(a.starts_with("S-1-5-21-")); + // 形如 S-1-5-21----:S,1,5,21 + 4 段子权限 = 8 段。 + assert_eq!(a.split('-').count(), 8); + let other = synthetic_workspace_sid(Path::new(r"C:\Users\Me\Other")); + assert_ne!(a, other, "不同路径应得不同 SID"); + } + + #[test] + fn command_line_quotes_spaces_and_escapes_quotes() { + let line = build_command_line( + r"C:\Program Files\App\app.exe", + &[ + "--flag".to_string(), + "a b".to_string(), + r#"say "hi""#.to_string(), + ], + ); + assert_eq!(line.last(), Some(&0u16), "须以 NUL 结尾"); + let decoded = String::from_utf16(&line[..line.len() - 1]).unwrap(); + // 含空格的程序路径整体加引号(反斜杠不因无 `"` 而翻倍)。 + assert!(decoded.starts_with(r#""C:\Program Files\App\app.exe""#)); + // 无特殊字符的参数不加引号。 + assert!(decoded.contains(" --flag ")); + // 含空格的参数加引号。 + assert!(decoded.contains(r#" "a b" "#)); + // 内部的 " 用反斜杠转义。 + assert!(decoded.ends_with(r#""say \"hi\"""#)); + } + + #[test] + fn command_line_doubles_trailing_backslashes_before_closing_quote() { + // 参数含空格需加引号,且以反斜杠结尾时,收尾反斜杠必须翻倍, + // 否则会转义掉闭合引号(CommandLineToArgvW 经典陷阱)。 + let line = build_command_line("prog", &[r"a\b c\".to_string()]); + let decoded = String::from_utf16(&line[..line.len() - 1]).unwrap(); + assert!(decoded.ends_with(r#""a\b c\\""#)); + } + + // resolve_program_in_path:本机(Unix)按 Unix 绝对/分隔规则验证“搜绝对目录、套 + // PATHEXT、跳相对项、绝对入参直通”这套算法;Windows 路径语义由 Windows 编译+真机验证。 + #[test] + fn resolve_program_searches_absolute_dirs_first_match_wins() { + let present: std::collections::HashSet = + [PathBuf::from("/usr/bin/sh")].into_iter().collect(); + let is_file = |p: &Path| present.contains(p); + let got = + resolve_program_in_path(Path::new("sh"), "/nonexist;/usr/bin;/bin", ".EXE", &is_file); + assert_eq!(got, Some(PathBuf::from("/usr/bin/sh"))); + } + + #[test] + fn resolve_program_applies_pathext_to_bare_name() { + let present: std::collections::HashSet = + [PathBuf::from("/tools/pwsh.EXE")].into_iter().collect(); + let is_file = |p: &Path| present.contains(p); + let got = resolve_program_in_path(Path::new("pwsh"), "/tools", ".COM;.EXE", &is_file); + assert_eq!(got, Some(PathBuf::from("/tools/pwsh.EXE"))); + } + + #[test] + fn resolve_program_never_probes_relative_or_dot_dirs() { + // PATH 里的 "." 与相对项绝不被探测:谓词只应收到绝对候选。 + let is_file = |p: &Path| { + assert!( + p.is_absolute(), + "resolver probed a non-absolute path: {p:?}" + ); + false + }; + let got = resolve_program_in_path(Path::new("cmd.exe"), ".;rel/dir;/abs", ".EXE", &is_file); + assert_eq!(got, None); + } + + #[test] + fn resolve_program_passes_absolute_input_through_without_probing() { + let is_file = |_: &Path| panic!("absolute input must not be probed"); + let got = resolve_program_in_path(Path::new("/bin/sh"), "/other", ".EXE", &is_file); + assert_eq!(got, Some(PathBuf::from("/bin/sh"))); + } + + #[test] + fn msix_windowsapps_path_is_detected_case_insensitively() { + assert!(is_msix_windowsapps_path(Path::new( + r"C:\Users\Me\AppData\Local\Microsoft\WindowsApps\pwsh.exe" + ))); + assert!(is_msix_windowsapps_path(Path::new( + r"C:\Program Files\WindowsApps\Microsoft.PowerShell_8wekyb3d8bbwe\pwsh.exe" + ))); + assert!(!is_msix_windowsapps_path(Path::new( + r"C:\Program Files\PowerShell\7\pwsh.exe" + ))); + assert!(!is_msix_windowsapps_path(Path::new( + r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe" + ))); + } + + #[test] + fn cng_user_write_surface_is_narrow_user_store_not_home() { + assert!(CNG_USER_REGISTRY_SUBKEYS + .iter() + .all(|key| key.starts_with(r"Software\"))); + assert!(CNG_USER_REGISTRY_SUBKEYS + .iter() + .all(|key| { key.contains("SystemCertificates") || key.contains("Cryptography") })); + assert_eq!( + cng_named_registry_object(r"Software\Microsoft\SystemCertificates"), + r"CURRENT_USER\Software\Microsoft\SystemCertificates" + ); + let dirs = cng_user_file_dirs(Path::new("/roaming"), Path::new("/local")); + assert_eq!( + dirs, + vec![ + PathBuf::from("/roaming/Microsoft/Crypto"), + PathBuf::from("/roaming/Microsoft/Protect"), + PathBuf::from("/local/Microsoft/CryptnetUrlCache"), + ] + ); + assert!(!dirs + .iter() + .any(|path| path == Path::new("/roaming") || path == Path::new("/local"))); + } + + #[test] + fn clr_user_write_surface_is_narrow_runtime_cache_not_home() { + assert!(CLR_USER_REGISTRY_SUBKEYS + .iter() + .all(|key| key.starts_with(r"Software\Microsoft\"))); + assert!(CLR_USER_REGISTRY_SUBKEYS + .iter() + .all(|key| { key.contains("PowerShell") || key.contains(".NETFramework") })); + let dirs = clr_user_file_dirs(Path::new("/roaming"), Path::new("/local")); + assert_eq!( + dirs, + vec![ + PathBuf::from("/local/Microsoft/CLR_v4.0"), + PathBuf::from("/local/Microsoft/CLR_v4.0_32"), + PathBuf::from("/local/assembly"), + PathBuf::from("/local/Microsoft/Windows/PowerShell"), + PathBuf::from("/local/Microsoft/PowerShell"), + PathBuf::from("/roaming/Microsoft/Windows/PowerShell"), + PathBuf::from("/roaming/Microsoft/CLR Security Config"), + PathBuf::from("/local/IsolatedStorage"), + ] + ); + assert!(!dirs.iter().any(|path| { + path == Path::new("/roaming") + || path == Path::new("/local") + || path == Path::new("/local/Temp") + || path == Path::new("/local/Microsoft") + })); + } + + #[test] + fn darwin_user_temp_parent_only_matches_var_folders_layout() { + assert_eq!( + darwin_user_temp_parent(Path::new("/var/folders/zz/abc123/T")), + Some(PathBuf::from("/var/folders/zz/abc123")) + ); + assert_eq!( + darwin_user_temp_parent(Path::new("/private/var/folders/zz/abc123/T")), + Some(PathBuf::from("/private/var/folders/zz/abc123")) + ); + // /tmp 的父级是 `/`,绝不能提升。 + assert_eq!(darwin_user_temp_parent(Path::new("/tmp")), None); + assert_eq!(darwin_user_temp_parent(Path::new("/private/tmp")), None); + assert_eq!(darwin_user_temp_parent(Path::new("/var/tmp")), None); + assert_eq!(darwin_user_temp_parent(Path::new("/tmp/T")), None); + assert_eq!( + darwin_user_temp_parent(Path::new("/var/folders/zz/abc123")), + None + ); + } + + #[test] + fn temp_write_root_rejects_filesystem_root_and_home() { + assert!(!temp_write_root_is_safe(Path::new("/"))); + if let Some(home) = dirs::home_dir() { + assert!(!temp_write_root_is_safe(&home)); + } + assert!(temp_write_root_is_safe(Path::new("/tmp"))); + assert!(temp_write_root_is_safe(Path::new("/var/folders/zz/abc123"))); + } + + #[test] + fn resolve_bwrap_prefers_system_path_over_workspace_path_prefix() { + let present: std::collections::HashSet = [ + PathBuf::from("/workspace/node_modules/.bin/bwrap"), + PathBuf::from("/usr/bin/bwrap"), + ] + .into_iter() + .collect(); + let is_file = |p: &Path| present.contains(p); + let got = resolve_bwrap_executable("/workspace/node_modules/.bin:/usr/bin", &is_file, None); + assert_eq!(got, Some(PathBuf::from("/usr/bin/bwrap"))); + } + + #[test] + fn resolve_bwrap_skips_workspace_and_relative_path_entries() { + let present: std::collections::HashSet = [ + PathBuf::from("/workspace/node_modules/.bin/bwrap"), + PathBuf::from("/opt/nix/bin/bwrap"), + ] + .into_iter() + .collect(); + let is_file = |p: &Path| present.contains(p); + let got = resolve_bwrap_executable( + ".:/workspace/node_modules/.bin:/opt/nix/bin", + &is_file, + Some(Path::new("/workspace")), + ); + assert_eq!(got, Some(PathBuf::from("/opt/nix/bin/bwrap"))); + } + + #[test] + fn resolve_bwrap_refuses_when_only_workspace_copy_exists() { + let is_file = |p: &Path| p == Path::new("/workspace/.venv/bin/bwrap"); + let got = resolve_bwrap_executable( + "/workspace/.venv/bin", + &is_file, + Some(Path::new("/workspace")), + ); + assert_eq!(got, None); + } +} diff --git a/crates/agent-gui/src-tauri/src/runtime/shell_runner.rs b/crates/agent-gui/src-tauri/src/runtime/shell_runner.rs index e91ac58d9..cb0028d7b 100644 --- a/crates/agent-gui/src-tauri/src/runtime/shell_runner.rs +++ b/crates/agent-gui/src-tauri/src/runtime/shell_runner.rs @@ -14,6 +14,7 @@ use crate::runtime::platform::{ expand_tilde_path, maybe_augment_macos_path, shell_basename, strip_windows_verbatim_prefix, }; use crate::runtime::process::{configure_child_process_group, terminate_child_process_tree}; +use crate::runtime::sandbox::{self, SandboxOptions, SandboxSpec}; const MAX_STDOUT_BYTES: usize = 400 * 1024; // 400KB const MAX_STDERR_BYTES: usize = 400 * 1024; // 400KB @@ -111,6 +112,10 @@ pub struct ShellRunResponse { pub platform: String, pub profile: String, pub shell_family: String, + /// 沙箱机制("seatbelt"/"bubblewrap"/"low-integrity-token"/"appcontainer"), + /// 未启用沙箱时为 None。 + #[serde(skip_serializing_if = "Option::is_none")] + pub sandbox: Option, pub stdout: String, pub stderr: String, pub stdout_truncated: bool, @@ -179,6 +184,13 @@ fn canonicalize_workdir(workdir: &str) -> Result { Ok(strip_windows_verbatim_prefix(fs::canonicalize(&p)?)) } +/// Canonical workspace root for runtime entry points that need to construct a +/// sandbox spec. Keep the internal error type private while sharing the exact +/// validation/canonicalization semantics with the one-shot runner. +pub(crate) fn canonical_workdir(workdir: &str) -> Result { + canonicalize_workdir(workdir).map_err(|error| error.to_string()) +} + fn normalize_rel_path_input(input: &str) -> String { input.trim().replace('\\', "/") } @@ -372,6 +384,9 @@ fn windows_powershell_command(cmd: &str) -> String { "[Console]::InputEncoding = [System.Text.UTF8Encoding]::new($false)", "[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)", "$OutputEncoding = [Console]::OutputEncoding", + // 重定向到文件时 PowerShell 的 Console.Out 默认块缓冲,ManagedProcess + // 周期性 Write-Output/echo 在进程退出前不会出现在日志里。 + "try { [Console]::Out.AutoFlush = $true; [Console]::Error.AutoFlush = $true } catch {}", cmd, ] .join("; ") @@ -511,6 +526,8 @@ struct ShellCandidate { pub(crate) struct SpawnedPlatformShell { pub child: std::process::Child, pub profile: ShellExecutionProfile, + /// 生效的沙箱机制;None 表示未启用沙箱。 + pub sandbox: Option<&'static str>, } fn platform_shell_candidates(cmd: &str) -> Vec { @@ -680,10 +697,247 @@ fn default_platform_shell_profile() -> ShellExecutionProfile { }) } +/// 沙箱下 shell 候选可用性的进程级缓存。key = (候选程序路径, 沙箱机制):同一 shell 在 +/// Low IL token 与 AppContainer 两种机制下兼容性可能不同,须分别记录;探测结果与工作区无关 +/// (loader 死亡源于令牌/内核对象语义,非路径),故 key 不含 write_root。 +#[cfg(windows)] +static SANDBOX_SHELL_PROBE_CACHE: std::sync::OnceLock< + Mutex>, +> = std::sync::OnceLock::new(); + +/// 子进程启动即死、不能当沙箱 shell 的退出码: +/// - 0xC0000142 DLL 初始化失败(msys/cygwin 在沙箱上下文下的典型死法) +/// - 0xC0000135 DLL 缺失 +/// - 0xC0000022 拒绝访问(NTSTATUS) +/// - 0xE0434352 CLR 未处理异常(PowerShell 把 CNG NTE_PROVIDER_DLL_FAIL 包装成 +/// “BCrypt.dll 加载失败”;进程已进 CLR,故不是 NTSTATUS loader 码) +/// - 0x8009001D NTE_PROVIDER_DLL_FAIL 本体 +/// - 0x80070005 HRESULT E_ACCESSDENIED(Windows PowerShell / .NET Framework +/// 写 CLR 用户缓存失败;与 0xC0000022 / 0xE0434352 不是同一条路径) +/// - 0xFFFF0000 PowerShell 宿主在 CLR 初始化失败(内部 HRESULT 80070005)时的 +/// 包装退出码。漏掉这两个码会把已崩溃的 powershell.exe 探测成可用 +/// +/// 命中 ⇒ 该候选在此沙箱机制下起不来,落到下一候选。 +#[cfg_attr(not(windows), allow(dead_code))] +fn is_loader_failure_exit(code: i32) -> bool { + matches!( + code as u32, + 0xC000_0142 + | 0xC000_0135 + | 0xC000_0022 + | 0xE043_4352 + | 0x8009_001D + | 0x8007_0005 + | 0xFFFF_0000 + ) +} + +/// 探测裁决:给定探测进程的退出码(None = 超时/被杀/无退出码),该候选是否可用。 +/// 只有明确的启动即死码判不可用;其余(超时、普通非零退出)一律放行,由真实 +/// spawn 自行失败并走既有错误链——探测只负责识别“启动即死”这一类硬不兼容。 +#[cfg_attr(not(windows), allow(dead_code))] +fn sandbox_probe_verdict(exit_code: Option) -> bool { + !exit_code.is_some_and(is_loader_failure_exit) +} + +/// PATH 上的第一个同名二进制若落在 WindowsApps,沙箱安全上下文无法直接启动它。 +#[cfg(windows)] +fn candidate_resolves_to_windowsapps(program: &Path) -> bool { + if sandbox::is_msix_windowsapps_path(program) { + return true; + } + let path_env = std::env::var("PATH").unwrap_or_default(); + let pathext = std::env::var("PATHEXT").unwrap_or_else(|_| ".COM;.EXE;.BAT;.CMD".to_string()); + sandbox::resolve_program_in_path(program, &path_env, &pathext, &|p| p.is_file()) + .is_some_and(|p| sandbox::is_msix_windowsapps_path(&p)) +} + +/// Windows 沙箱下探测某 shell 候选能否活过启动(结果进程级缓存)。 +/// +/// 经启动器 spawn 一条 `exit 0` 的最小命令,等待 ≤2s:退出码命中启动即死 +/// (Git Bash 的 0xC0000142,PowerShell/CNG 的 0xE0434352,或 CLR 的 0x80070005)⇒ 不可用,调用方落到 +/// 下一候选。pwsh/powershell 在写围栏下并不必然可用;cmd.exe 不走 CNG,通常是最后 +/// 兜底。探测本身失败(wrap/spawn 出错)判可用:让真实 spawn 复现错误并走既有 +/// fail-closed/错误报告路径,探测不吞错。 +#[cfg(windows)] +fn sandbox_candidate_usable( + spec: &SandboxSpec, + candidate: &ShellCandidate, + mechanism: &'static str, +) -> bool { + use wait_timeout::ChildExt; + + if candidate_resolves_to_windowsapps(&candidate.program) { + return false; + } + + let cache = SANDBOX_SHELL_PROBE_CACHE.get_or_init(|| Mutex::new(HashMap::new())); + let key = (candidate.program.clone(), mechanism); + if let Ok(map) = cache.lock() { + if let Some(&usable) = map.get(&key) { + return usable; + } + } + + let probe_args: Vec = match candidate.profile.profile { + "windows-git-bash" => vec!["-c".to_string(), "exit 0".to_string()], + "windows-pwsh" | "windows-powershell" => vec![ + "-NoLogo".to_string(), + "-NoProfile".to_string(), + "-NonInteractive".to_string(), + "-Command".to_string(), + "exit 0".to_string(), + ], + _ => vec!["/D".to_string(), "/C".to_string(), "exit 0".to_string()], + }; + + let usable = match sandbox::wrap_command(spec, &candidate.program, &probe_args) { + Ok((program, args, _)) => { + let exit_code = Command::new(&program) + .args(&args) + .current_dir(&spec.write_root) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .ok() + .and_then( + |mut child| match child.wait_timeout(Duration::from_secs(2)) { + Ok(Some(status)) => status.code(), + _ => { + let _ = child.kill(); + let _ = child.wait(); + None + } + }, + ); + sandbox_probe_verdict(exit_code) + } + Err(_) => true, + }; + if let Ok(mut map) = cache.lock() { + map.insert(key, usable); + } + usable +} + +/// `stdbuf` 在 Git Bash 上通常位于 `Git\usr\bin`,与 `Git\bin\bash.exe` 不在同一目录。 +fn find_stdbuf_near(shell: &Path) -> Option { + let names: &[&str] = if cfg!(windows) { + &["stdbuf.exe", "stdbuf"] + } else { + &["stdbuf"] + }; + let mut dirs = Vec::new(); + if let Some(parent) = shell.parent().filter(|p| !p.as_os_str().is_empty()) { + dirs.push(parent.to_path_buf()); + dirs.push(parent.join("usr").join("bin")); + if let Some(grand) = parent.parent() { + dirs.push(grand.join("usr").join("bin")); + } + } + dirs.push(PathBuf::from("/usr/bin")); + dirs.push(PathBuf::from("/bin")); + for dir in dirs { + for name in names { + let candidate = dir.join(name); + if candidate.is_file() { + return Some(candidate); + } + } + } + None +} + +/// Quote a string for POSIX `sh` single quotes so it can be embedded in `-c`. +fn posix_single_quote(value: &str) -> String { + let mut out = String::from("'"); + let mut first = true; + for part in value.split('\'') { + if !first { + out.push_str("'\\''"); + } + first = false; + out.push_str(part); + } + out.push('\''); + out +} + +fn path_for_posix_shell(path: &Path) -> String { + #[cfg(windows)] + { + let raw = path.to_string_lossy(); + let trimmed = raw.trim().trim_start_matches(r"\\?\"); + let bytes = trimmed.as_bytes(); + if bytes.len() >= 2 && bytes[1] == b':' { + let drive = (bytes[0] as char).to_ascii_lowercase(); + let rest = trimmed[2..].replace('\\', "/"); + return format!("/{drive}{rest}"); + } + trimmed.replace('\\', "/") + } + #[cfg(not(windows))] + { + path.to_string_lossy().into_owned() + } +} + +/// POSIX 托管进程用 `stdbuf -oL` 包一层,让 echo 循环等周期输出在重定向到 +/// 日志文件时按行可见。映像仍是 bash/zsh/sh,Windows 沙箱对 Git Bash 的 +/// Everyone SID / msys 盖章才能继续命中。 +fn posix_line_buffered_script( + shell: &str, + command: &str, + stdbuf: Option<&Path>, + log_path: Option<&Path>, +) -> String { + let quoted = posix_single_quote(command); + let redirect = log_path + .map(|path| { + format!( + " >> {} 2>&1", + posix_single_quote(&path_for_posix_shell(path)) + ) + }) + .unwrap_or_default(); + if let Some(stdbuf) = stdbuf { + let stdbuf_q = posix_single_quote(&path_for_posix_shell(stdbuf)); + return format!("exec {stdbuf_q} -oL -eL {shell} -c {quoted}{redirect}"); + } + format!( + "if command -v stdbuf >/dev/null 2>&1; then exec stdbuf -oL -eL {shell} -c {quoted}{redirect}; fi\n{{ {command}; }}{redirect}" + ) +} + +fn apply_line_buffered_stdio( + command: &str, + candidate: &ShellCandidate, + log_path: Option<&Path>, +) -> (Vec, Vec<(String, String)>) { + let mut args = candidate.args.clone(); + let extra_envs = vec![("PYTHONUNBUFFERED".to_string(), "1".to_string())]; + if candidate.profile.shell_family == "posix" { + let stdbuf = find_stdbuf_near(&candidate.program); + if let Some(script) = args.last_mut() { + *script = posix_line_buffered_script( + candidate.profile.display_shell, + command, + stdbuf.as_deref(), + log_path, + ); + } + } + (args, extra_envs) +} + pub(crate) fn spawn_platform_shell_command( command: &str, cwd: &Path, envs: &[(String, String)], + sandbox_spec: Option<&SandboxSpec>, + line_buffered: bool, + log_path: Option<&Path>, mut stdio_factory: F, ) -> Result where @@ -693,16 +947,50 @@ where let system_proxy_envs = crate::services::system_proxy::shell_proxy_envs()?; for candidate in platform_shell_candidates(command) { + let (candidate_args, extra_envs) = if line_buffered { + apply_line_buffered_stdio(command, &candidate, log_path) + } else { + (candidate.args.clone(), Vec::new()) + }; + // 沙箱包裹在 shell candidate 选定后、spawn 前进行,fail-closed:包裹 + // 失败(平台不支持/依赖缺失)直接报错,绝不回退为无沙箱执行。 + // sandbox-exec/bwrap 按名字解析 shell 时同样遵循 PATH,语义不变。 + let (spawn_program, spawn_args, sandbox_mechanism) = match sandbox_spec { + Some(spec) => { + let (program, args, mechanism) = + sandbox::wrap_command(spec, &candidate.program, &candidate_args)?; + (program, args, Some(mechanism)) + } + None => (candidate.program.clone(), candidate_args, None), + }; + // Windows 沙箱专属:候选回退链平时靠 spawn 失败推进,但沙箱下 spawn 的永远是 + // LiveAgent.exe 启动器(总能成功),loader 级不兼容(如 Git Bash 的 msys 依赖 + // 在沙箱上下文下 0xC0000142)只体现为命令“执行了但立即死”。用一次缓存的探测 + // (`exit 0`)提前识别,落到下一候选,不给模型返回死 shell。pwsh 在沙箱里也会 + // 因 CNG 用户证书库不可写而以 CLR 0xE0434352 崩溃,不能假定“原生 PE 必然可用”。 + #[cfg(windows)] + if let (Some(spec), Some(mechanism)) = (sandbox_spec, sandbox_mechanism) { + if !sandbox_candidate_usable(spec, &candidate, mechanism) { + errors.push(format!( + "{} ({}) skipped: incompatible with the {mechanism} sandbox (startup \ + failure under the sandbox security context, e.g. STATUS_DLL_INIT_FAILED, CLR \ + 0xE0434352 / 0xFFFF0000, or HRESULT 0x80070005)", + candidate.profile.profile, candidate.profile.display_shell + )); + continue; + } + } let (stdout, stderr) = stdio_factory().map_err(|err| format!("Failed to prepare shell stdio: {err}"))?; - let mut c = Command::new(&candidate.program); - c.args(&candidate.args); + let mut c = Command::new(&spawn_program); + c.args(&spawn_args); // 系统代理 env 先注入,调用方 envs(如 LIVEAGENT_HOOK_*)后写保持更高优先级。 for (key, value) in &system_proxy_envs { c.env(key, value); } c.envs( envs.iter() + .chain(extra_envs.iter()) .map(|(key, value)| (key.as_str(), value.as_str())), ); if candidate.augment_macos_path { @@ -721,6 +1009,7 @@ where return Ok(SpawnedPlatformShell { child, profile: candidate.profile, + sandbox: sandbox_mechanism, }); } Err(err) => errors.push(format!( @@ -738,6 +1027,9 @@ where Err(ShellError::Other(format!("Failed to start command: {detail}")).to_string()) } +/// 无 env 注入、无沙箱的最简入口。生产链路一律走 `run_shell_script_with_envs` 并显式 +/// 传入沙箱参数(P1#2:Cron 曾因这里的 `None` 而恒以无沙箱方式执行),故此入口仅供测试。 +#[cfg(test)] pub(crate) fn run_shell_script( workdir: String, command: String, @@ -756,6 +1048,7 @@ pub(crate) fn run_shell_script( provider_id, cancel_token, &[], + None, ) } @@ -769,6 +1062,7 @@ pub(crate) fn run_shell_script_with_envs( _provider_id: Option, cancel_token: Option, envs: &[(String, String)], + sandbox_options: Option, ) -> Result { let cmd = command.trim(); if cmd.is_empty() { @@ -781,11 +1075,27 @@ pub(crate) fn run_shell_script_with_envs( let timeout = Duration::from_millis(effective_timeout_ms); let start = Instant::now(); - let spawned = spawn_platform_shell_command(cmd, &actual_cwd, envs, || { - Ok((Stdio::piped(), Stdio::piped())) - })?; + // 沙箱写围栏锚定 workdir(工作区根)而非 cwd:cwd 可能是子目录,但工具语义 + // 允许写整个工作区。 + let sandbox_spec = match sandbox_options { + Some(options) => { + let wd = canonicalize_workdir(&workdir).map_err(|e| e.to_string())?; + Some(SandboxSpec::from_options(wd, options)) + } + None => None, + }; + let spawned = spawn_platform_shell_command( + cmd, + &actual_cwd, + envs, + sandbox_spec.as_ref(), + false, + None, + || Ok((Stdio::piped(), Stdio::piped())), + )?; let mut child = spawned.child; let shell_profile = spawned.profile; + let sandbox_mechanism = spawned.sandbox; let shell_name = shell_basename(shell_profile.display_shell); let stdout = child @@ -868,6 +1178,7 @@ process output to a log file, for example: `nohup command > /tmp/liveagent-task. platform: shell_profile.platform.to_string(), profile: shell_profile.profile.to_string(), shell_family: shell_profile.shell_family.to_string(), + sandbox: sandbox_mechanism.map(str::to_string), stdout: stdout_str, stderr: stderr_str, stdout_truncated, @@ -883,15 +1194,46 @@ process output to a log file, for example: `nohup command > /tmp/liveagent-task. #[cfg(test)] mod tests { use super::{ - default_platform_shell_profile, normalize_timeout_ms, run_shell_script, - sanitize_rel_path_core, ShellRunRegistry, DEFAULT_SHELL_TIMEOUT_MS, MAX_SHELL_TIMEOUT_MS, - MIN_SHELL_TIMEOUT_MS, + default_platform_shell_profile, is_loader_failure_exit, normalize_timeout_ms, + run_shell_script, sandbox_probe_verdict, sanitize_rel_path_core, ShellRunRegistry, + DEFAULT_SHELL_TIMEOUT_MS, MAX_SHELL_TIMEOUT_MS, MIN_SHELL_TIMEOUT_MS, }; use std::fs; use std::path::PathBuf; use std::sync::Arc; use std::time::{Duration, Instant}; + // 沙箱候选探测的裁决逻辑(纯函数,平台无关):只有启动即死码判不可用; + // 超时/普通失败一律放行交由真实 spawn 走既有错误链。 + #[test] + fn sandbox_probe_verdict_only_rejects_loader_ntstatus() { + // loader 死亡三码(as i32 后为负数,与 std ExitStatus::code() 的表示一致)。 + assert!(is_loader_failure_exit(0xC000_0142_u32 as i32)); // STATUS_DLL_INIT_FAILED + assert!(is_loader_failure_exit(0xC000_0135_u32 as i32)); // STATUS_DLL_NOT_FOUND + assert!(is_loader_failure_exit(0xC000_0022_u32 as i32)); // STATUS_ACCESS_DENIED + assert!(!sandbox_probe_verdict(Some(0xC000_0142_u32 as i32))); + // PowerShell/CNG:BCrypt“加载失败”其实是 CLR 未处理异常,不是 NTSTATUS。 + assert_eq!((-532_462_766i32) as u32, 0xE043_4352); + assert!(is_loader_failure_exit(-532_462_766)); + assert!(!sandbox_probe_verdict(Some(-532_462_766))); + assert!(is_loader_failure_exit(0x8009_001D_u32 as i32)); // NTE_PROVIDER_DLL_FAIL + assert!(!sandbox_probe_verdict(Some(0x8009_001D_u32 as i32))); + // Windows PowerShell / .NET Framework:CLR 用户缓存写拒绝是 HRESULT,不是 NTSTATUS。 + assert_eq!((-2_147_024_891i32) as u32, 0x8007_0005); + assert!(is_loader_failure_exit(-2_147_024_891)); + assert!(!sandbox_probe_verdict(Some(-2_147_024_891))); + // PowerShell 宿主把 CLR 80070005 包装成 0xFFFF0000(-65536)。 + assert_eq!((-65536i32) as u32, 0xFFFF_0000); + assert!(is_loader_failure_exit(-65536)); + assert!(!sandbox_probe_verdict(Some(-65536))); + // 正常退出、普通失败、其它 NTSTATUS、超时(None)都不构成“候选不可用”。 + assert!(sandbox_probe_verdict(Some(0))); + assert!(sandbox_probe_verdict(Some(1))); + assert!(sandbox_probe_verdict(Some(127))); + assert!(sandbox_probe_verdict(Some(0xC000_0005_u32 as i32))); // ACCESS_VIOLATION:运行期崩溃,非启动即死 + assert!(sandbox_probe_verdict(None)); + } + #[test] fn sanitize_rel_path_accepts_windows_style_separators() { assert_eq!( @@ -1222,4 +1564,25 @@ mod tests { let _ = fs::remove_dir_all(&workdir); } + + #[test] + fn posix_line_buffered_script_embeds_quoted_command_and_stdbuf() { + let stdbuf = PathBuf::from(r"C:\Program Files\Git\usr\bin\stdbuf.exe"); + let log_path = PathBuf::from(r"C:\Users\me\.liveagent\process-logs\proc.log"); + let script = super::posix_line_buffered_script( + "bash", + "echo ready; sleep 1", + Some(stdbuf.as_path()), + Some(log_path.as_path()), + ); + assert!(script.contains("stdbuf.exe")); + assert!(script.contains("-oL -eL bash -c")); + assert!(script.contains("'echo ready; sleep 1'")); + assert!(script.contains(" >> ")); + #[cfg(windows)] + { + assert!(script.contains("/c/Program Files/Git/usr/bin/stdbuf.exe")); + assert!(script.contains("/c/Users/me/.liveagent/process-logs/proc.log")); + } + } } diff --git a/crates/agent-gui/src-tauri/src/runtime/shell_session.rs b/crates/agent-gui/src-tauri/src/runtime/shell_session.rs index 5851ee9da..136fa6755 100644 --- a/crates/agent-gui/src-tauri/src/runtime/shell_session.rs +++ b/crates/agent-gui/src-tauri/src/runtime/shell_session.rs @@ -10,9 +10,10 @@ use std::time::{Duration, Instant}; use crate::runtime::platform::shell_basename; use crate::runtime::process::{terminate_child_process_tree, terminate_process_tree_by_pid}; +use crate::runtime::sandbox::{SandboxOptions, SandboxSpec}; use crate::runtime::shell_runner::{ - resolve_shell_cwd, spawn_platform_shell_command, ShellExecutionProfile, MAX_SHELL_TIMEOUT_MS, - MIN_SHELL_TIMEOUT_MS, + canonical_workdir, resolve_shell_cwd, spawn_platform_shell_command, ShellExecutionProfile, + MAX_SHELL_TIMEOUT_MS, MIN_SHELL_TIMEOUT_MS, }; const DEFAULT_START_YIELD_MS: u64 = 10_000; @@ -72,6 +73,9 @@ pub struct ShellSessionResponse { pub platform: String, pub profile: String, pub shell_family: String, + /// 生效的沙箱机制;None 表示未启用沙箱。 + #[serde(skip_serializing_if = "Option::is_none")] + pub sandbox: Option, pub timeout_ms: Option, } @@ -139,6 +143,7 @@ struct ShellSession { pid: u32, started_at: Instant, profile: ShellExecutionProfile, + sandbox: Option, timeout_ms: Option, output_capacity_bytes: usize, response_capacity_bytes: usize, @@ -152,6 +157,7 @@ impl ShellSession { id: String, pid: u32, profile: ShellExecutionProfile, + sandbox: Option, timeout_ms: Option, config: &ShellSessionConfig, ) -> Self { @@ -160,6 +166,7 @@ impl ShellSession { pid, started_at: Instant::now(), profile, + sandbox, timeout_ms, output_capacity_bytes: config.output_capacity_bytes.max(1), response_capacity_bytes: config.response_capacity_bytes.max(4), @@ -378,6 +385,7 @@ impl ShellSession { platform: self.profile.platform.to_string(), profile: self.profile.profile.to_string(), shell_family: self.profile.shell_family.to_string(), + sandbox: self.sandbox.clone(), timeout_ms: self.timeout_ms, } } @@ -416,6 +424,7 @@ impl ShellSessionManager { yield_time_ms: Option, timeout_ms: Option, max_timeout_ms: Option, + sandbox_options: Option, ) -> Result { let session_id = normalize_session_id(&session_id)?; let command = command.trim(); @@ -424,6 +433,16 @@ impl ShellSessionManager { } let actual_cwd = resolve_shell_cwd(&workdir, cwd.as_deref())?; let effective_timeout_ms = normalize_explicit_timeout(timeout_ms, max_timeout_ms); + // 沙箱写围栏始终锚定 workdir(工作区根),即使 cwd 指向工作区子目录。 + // 与一次性 shell_run 使用同一 canonicalize/构造逻辑,避免两个执行入口 + // 的围栏语义漂移。 + let sandbox_spec = match sandbox_options { + Some(options) => Some(SandboxSpec::from_options( + canonical_workdir(&workdir)?, + options, + )), + None => None, + }; let session = { let mut sessions = self @@ -436,9 +455,15 @@ impl ShellSessionManager { } self.make_room_locked(&mut sessions)?; - let spawned = spawn_platform_shell_command(command, &actual_cwd, &[], || { - Ok((Stdio::piped(), Stdio::piped())) - })?; + let spawned = spawn_platform_shell_command( + command, + &actual_cwd, + &[], + sandbox_spec.as_ref(), + false, + None, + || Ok((Stdio::piped(), Stdio::piped())), + )?; let mut child = spawned.child; let stdout = child .stdout @@ -452,6 +477,7 @@ impl ShellSessionManager { session_id.clone(), child.id(), spawned.profile, + spawned.sandbox.map(str::to_string), effective_timeout_ms, &self.config, )); @@ -546,7 +572,7 @@ impl ShellSessionManager { sessions .get(&session_id) .cloned() - .ok_or_else(|| format!("shell session not found or expired: {session_id}")) + .ok_or_else(|| shell_session_not_found_message(&session_id)) } fn cleanup_locked(&self, sessions: &mut HashMap>, now: Instant) { @@ -603,6 +629,19 @@ fn normalize_session_id(value: &str) -> Result { Ok(value.to_string()) } +fn shell_session_not_found_message(session_id: &str) -> String { + if uuid::Uuid::parse_str(session_id).is_ok() { + format!( + "shell session not found or expired: {session_id}. \ + ProcessWait/ProcessStop only accept Bash session_id values. \ + If this is a ManagedProcess process_id, use \ + ManagedProcess(action=\"wait\"|\"read_log\"|\"stop\", process_id=\"{session_id}\")." + ) + } else { + format!("shell session not found or expired: {session_id}") + } +} + fn normalize_explicit_timeout(timeout_ms: Option, max_timeout_ms: Option) -> Option { let max_timeout_ms = max_timeout_ms .unwrap_or(MAX_SHELL_TIMEOUT_MS) @@ -767,6 +806,21 @@ mod tests { .collect() } + #[test] + fn uuid_session_miss_points_at_managed_process_wait() { + let id = "c7c220e6-bd2a-4fb5-9ffa-35634c22c79d"; + let message = shell_session_not_found_message(id); + assert!(message.contains(id)); + assert!(message.contains("ManagedProcess")); + assert!(message.contains("action=\"wait\"")); + let bash_id = "bash-05a08b61-7863-4469-96cf-772bfb0f31a0"; + let bash_message = shell_session_not_found_message(bash_id); + assert_eq!( + bash_message, + format!("shell session not found or expired: {bash_id}") + ); + } + #[test] fn short_command_completes_during_initial_yield() { let manager = ShellSessionManager::default(); @@ -779,6 +833,7 @@ mod tests { Some(2_000), None, Some(30_000), + None, ) .expect("short command should run"); assert_eq!(response.status, ShellSessionStatus::Completed); @@ -798,6 +853,7 @@ mod tests { Some(20), None, Some(30_000), + None, ) .expect("long command should start"); assert_eq!(first.status, ShellSessionStatus::Running); @@ -824,6 +880,7 @@ mod tests { Some(20), None, Some(30_000), + None, ) .expect("command should start without a hard timeout"); assert_eq!(first.status, ShellSessionStatus::Running); @@ -847,6 +904,7 @@ mod tests { Some(50), None, Some(30_000), + None, ) .expect("command should start"); assert_eq!(first.status, ShellSessionStatus::Running); @@ -873,6 +931,7 @@ mod tests { display_shell: "sh", }, None, + None, &config, ); session.append_output(ShellOutputStream::Stdout, "12345".to_string()); @@ -900,6 +959,7 @@ mod tests { display_shell: "sh", }, None, + None, &config, ); session.append_output(ShellOutputStream::Stdout, "a".repeat(70 * 1024)); @@ -934,6 +994,7 @@ mod tests { display_shell: "sh", }, None, + None, &config, ); session.append_output(ShellOutputStream::Stdout, "a".to_string()); @@ -967,6 +1028,7 @@ mod tests { display_shell: "sh", }, None, + None, &config, ); session.finish(ShellSessionStatus::Completed, Some(0)); @@ -977,6 +1039,114 @@ mod tests { assert_eq!(second.duration_ms, first.duration_ms); } + #[test] + fn response_preserves_the_effective_sandbox_mechanism() { + let config = ShellSessionConfig::default(); + let session = ShellSession::new( + "sandbox-response".to_string(), + 1, + ShellExecutionProfile { + platform: "test", + profile: "test", + shell_family: "posix", + display_shell: "sh", + }, + Some("low-integrity-token".to_string()), + None, + &config, + ); + session.finish(ShellSessionStatus::Completed, Some(0)); + + let response = session.wait(None, Duration::ZERO); + assert_eq!(response.sandbox.as_deref(), Some("low-integrity-token")); + } + + /// Exercise the production session manager with an explicit sandbox option. + /// The sibling directory deliberately lives outside the workspace but outside + /// the platform-approved temp roots as well, so a successful write there + /// would prove that the session lost its fence. + #[cfg(unix)] + #[test] + fn sandboxed_session_enforces_workspace_write_fence() { + use crate::runtime::sandbox::{self, SandboxOptions}; + + let capability = sandbox::capability(); + if !capability.supported { + let error = ShellSessionManager::default() + .start( + "sandbox-unavailable".to_string(), + workdir(), + "printf should-not-run".to_string(), + None, + Some(2_000), + None, + Some(30_000), + Some(SandboxOptions { + allow_network: true, + }), + ) + .expect_err("sandbox startup must fail closed when the backend is unavailable"); + assert!( + error.contains("Sandbox mode is enabled but unavailable") + || error.contains("sandbox") + || error.contains("bubblewrap"), + "unexpected fail-closed error: {error}" + ); + return; + } + + let home = dirs::home_dir().expect("home directory"); + let outer = tempfile::Builder::new() + .prefix(".liveagent-sandbox-session-") + .tempdir_in(home) + .expect("temporary sandbox parent"); + let workspace = outer.path().join("workspace"); + let sibling = outer.path().join("sibling"); + std::fs::create_dir(&workspace).expect("workspace directory"); + std::fs::create_dir(&sibling).expect("sibling directory"); + + let manager = ShellSessionManager::default(); + let first = manager + .start( + "sandbox-fence".to_string(), + workspace.display().to_string(), + "set -e; printf inside > inside.txt; printf outside > ../sibling/outside.txt" + .to_string(), + None, + Some(2_000), + None, + Some(30_000), + Some(SandboxOptions { + allow_network: true, + }), + ) + .expect("sandboxed session should start"); + let response = if first.status == ShellSessionStatus::Running { + manager + .wait("sandbox-fence", Some(first.cursor), Some(5_000)) + .expect("sandboxed session should remain waitable") + } else { + first + }; + + assert_eq!(response.sandbox.as_deref(), Some(capability.mechanism)); + assert_eq!(response.status, ShellSessionStatus::Failed); + assert_ne!( + response.exit_code, + Some(0), + "sibling write unexpectedly succeeded" + ); + assert_eq!( + std::fs::read_to_string(workspace.join("inside.txt")).expect("workspace write"), + "inside" + ); + assert!( + !sibling.join("outside.txt").exists(), + "sandboxed session wrote outside its workspace" + ); + manager.shutdown_cleanup(); + } + #[test] fn explicit_timeout_terminates_the_session() { let manager = ShellSessionManager::default(); @@ -989,6 +1159,7 @@ mod tests { Some(250), Some(1_000), Some(30_000), + None, ) .expect("timed command should start"); assert_eq!(first.status, ShellSessionStatus::Running); @@ -1014,6 +1185,7 @@ mod tests { Some(300), None, Some(30_000), + None, ) .expect("process tree should start"); let child_pid = collect_text(&first) @@ -1053,6 +1225,7 @@ mod tests { Some(1_500), None, Some(30_000), + None, ) .expect("background child should not hold session response forever"); assert_eq!(response.status, ShellSessionStatus::Completed); @@ -1076,6 +1249,7 @@ mod tests { Some(2_000), None, Some(30_000), + None, ) .expect("terminal session should start"); assert_eq!(response.status, ShellSessionStatus::Completed); @@ -1110,6 +1284,7 @@ mod tests { Some(20), None, Some(30_000), + None, ) .expect("first session should start"); assert_eq!(running.status, ShellSessionStatus::Running); @@ -1122,6 +1297,7 @@ mod tests { Some(20), None, Some(30_000), + None, ) .expect_err("all-running limit should reject a new session"); assert!(error.contains("session limit reached")); @@ -1136,6 +1312,7 @@ mod tests { Some(2_000), None, Some(30_000), + None, ) .expect("terminal session should be evicted for replacement"); assert_eq!(replacement.status, ShellSessionStatus::Completed); diff --git a/crates/agent-gui/src-tauri/src/runtime/windows_sandbox.rs b/crates/agent-gui/src-tauri/src/runtime/windows_sandbox.rs new file mode 100644 index 000000000..c32c1ecaf --- /dev/null +++ b/crates/agent-gui/src-tauri/src/runtime/windows_sandbox.rs @@ -0,0 +1,3211 @@ +//! Windows 沙箱启动器(自我再执行模型,免管理员 / 免 UAC)。 +//! +//! `sandbox::wrap_command`(Windows)不直接返回真实命令,而是把它包成对本 exe 的再调用: +//! `current_exe __sandbox_exec --write-root --net on|off [--isolated] -- `。 +//! 进程启动最早期(`lib::run` 首行)调用 `run_sandbox_launcher_if_requested`:若检出该 +//! 子命令,就地按 `--net` 选后端执行真实命令,等待其退出,以其退出码退出——绝不返回去 +//! 初始化 Tauri。 +//! +//! 双后端(均免管理员/免 UAC,见 memory `windows-sandbox-facts`,均已研究+对抗验证): +//! +//! A. 联网沙箱(`--net on`)= Low Integrity 主令牌(`DuplicateTokenEx` + +//! `CreateProcessAsUserW`) +//! - 复制当前用户主令牌,保留原登录会话和 SSPI/Schannel 凭据;不设置 WFP/AppContainer +//! 网络限制,也不注入离线代理或 `*_OFFLINE` 环境变量。因此 HTTP(S)、DNS、loopback、 +//! LAN 与监听端口的语义和无沙箱进程一致。 +//! - 子令牌降到 Low IL;工作区、围栏 TEMP 及 PowerShell 必需的窄运行时缓存同步标为 +//! Low。Mandatory Integrity Control 的 NoWriteUp 拒绝写入 Medium 的 home、工作区 +//! 父目录和盘符根,从而保留 workspace-write 围栏。 +//! - Git Bash/PowerShell 的命名对象与标准句柄继续做 Low IL / BNO 兼容处理,避免 +//! `STATUS_DLL_INIT_FAILED`;这些处理不改变网络策略。 +//! - 不能改回 `CreateRestrictedToken`:restricted token 会使 Schannel +//! `AcquireCredentialsHandle` 返回 `SEC_E_NO_CREDENTIALS`,表现为联网沙箱 HTTPS 断网。 +//! +//! B. 断网沙箱(`--net off`)= AppContainer(`CreateProcessW` + `SECURITY_CAPABILITIES`) +//! - AppContainer 只携带按工作区派生的私有文件 capability,不携带任何网络 capability; +//! WFP 因而默认拒绝**全部**网络(含 loopback)⇒ 内核级强制断网,无需提权。对比 +//! Codex:unelevated 仅 env 级软断网,强制断网须提权建专用账号 + 防火墙/WFP 规则。 +//! - AC 默认拒绝未授权“读”:系统目录靠自带的 `ALL APPLICATION PACKAGES` ACE 可读(工具链 +//! 可用),用户主目录默认不可读 ⇒ 断网变体顺带获得敏感目录读掩蔽(联网后端缺失项)。 +//! - 写围栏:对私有 capability SID 复用同一套授权写 ACE(工作区根 + 受围栏临时目录)。 +//! 不能直接给 package SID 授权:Windows 会把含具体 AppContainer SID 的对象视为 +//! package 资源,随后普通 Low-IL 联网沙箱即使命中用户 ACE 也无法读取该对象。 +//! - env 叠加(防御纵深):`HTTP(S)_PROXY=http://127.0.0.1:9`、`CARGO_NET_OFFLINE` 等,让 +//! 工具在内核断网之上再快速明确失败(对齐 Codex `env.rs`)。 +//! +//! 两后端共用启动尾:`STARTUPINFOEXW` + `PROC_THREAD_ATTRIBUTE_HANDLE_LIST` 只继承 3 个 +//! std 句柄(取代 `bInheritHandles=TRUE` 的全句柄表继承,收敛句柄泄漏面);显式 +//! `lpDesktop = winsta0\default`(Low IL token/AC 启动必须显式设桌面,否则句柄站点解析歧义); +//! Job Object `KILL_ON_JOB_CLOSE` 在非 isolated 时兜底级联杀,isolated 常驻进程则跳过 +//!(对齐 Linux bwrap 省略 `--die-with-parent`);启动失败退出码(0xC0000142/0135/0022 +//! 以及 CLR `0xE0434352` / HRESULT `0x80070005`)转可读中英诊断经既有管道上传。 +//! 盖章跳过等诊断默认不写子进程 stderr(会污染 ManagedProcess 日志);设置 +//! `LIVEAGENT_SANDBOX_LOG=1` 才回落到 stderr。 + +/// 非 Windows:自我再执行启动器不存在,空操作。 +#[cfg(not(windows))] +pub fn run_sandbox_launcher_if_requested() {} + +/// 运行时探测两个 Windows 后端能否真的建出安全上下文(P1#4)。 +/// 返回 (联网 Low IL token 后端,断网 AppContainer 后端);非 Windows 平台不参与编译。 +#[cfg(windows)] +pub(crate) fn probe_backends() -> (Result<(), String>, Result<(), String>) { + (win::probe_networked_token(), win::probe_appcontainer()) +} + +/// Windows:若本次进程是 `__sandbox_exec` 启动器,执行真实命令并以其退出码退出; +/// 否则原样返回,交由正常的 Tauri 启动流程继续。 +#[cfg(windows)] +pub fn run_sandbox_launcher_if_requested() { + use crate::runtime::sandbox::{parse_launcher_args, SANDBOX_EXEC_SUBCOMMAND}; + + let raw: Vec = std::env::args().collect(); + // raw[0] = exe 自身;raw[1] = 子命令标记;raw[2..] = 启动器 payload。 + if raw.get(1).map(String::as_str) != Some(SANDBOX_EXEC_SUBCOMMAND) { + return; + } + + let code = match parse_launcher_args(&raw[2..]) { + Ok(inv) => { + match win::execute( + &inv.write_root, + inv.allow_network, + inv.isolated, + &inv.program, + &inv.args, + ) { + Ok(code) => code, + Err(err) => { + // fail-closed:已进入沙箱启动器分支,任何建令牌/派生失败都必须让命令 + // 整体不执行,绝不回退到无沙箱运行。 + eprintln!("liveagent sandbox launcher failed: {err}"); + 127 + } + } + } + Err(err) => { + eprintln!("liveagent sandbox launcher: invalid arguments: {err}"); + 127 + } + }; + std::process::exit(code); +} + +#[cfg(windows)] +mod win { + use std::ffi::c_void; + use std::path::{Path, PathBuf}; + use std::ptr::{null, null_mut}; + + use windows_sys::Win32::Foundation::{ + CloseHandle, GetLastError, LocalFree, SetHandleInformation, HANDLE, + }; + use windows_sys::Win32::Security::Authorization::{ + ConvertSidToStringSidW, ConvertStringSecurityDescriptorToSecurityDescriptorW, + ConvertStringSidToSidW, GetNamedSecurityInfoW, GetSecurityInfo, SetEntriesInAclW, + SetNamedSecurityInfoW, SetSecurityInfo, EXPLICIT_ACCESS_W, TRUSTEE_W, + }; + #[cfg(test)] + use windows_sys::Win32::Security::CreateRestrictedToken; + use windows_sys::Win32::Security::Isolation::{ + CreateAppContainerProfile, DeriveAppContainerSidFromAppContainerName, + }; + use windows_sys::Win32::Security::{ + AddAccessAllowedAce, AddAce, CopySid, DeriveCapabilitySidsFromName, DuplicateTokenEx, + EqualSid, FreeSid, GetAce, GetAclInformation, GetLengthSid, GetSecurityDescriptorSacl, + GetTokenInformation, InitializeAcl, IsTokenRestricted, SetTokenInformation, + ACCESS_ALLOWED_ACE, ACE_HEADER, ACL, ACL_SIZE_INFORMATION, SECURITY_CAPABILITIES, + SID_AND_ATTRIBUTES, TOKEN_DEFAULT_DACL, TOKEN_GROUPS, TOKEN_USER, + }; + use windows_sys::Win32::System::Console::GetStdHandle; + use windows_sys::Win32::System::Environment::SetEnvironmentVariableW; + use windows_sys::Win32::System::JobObjects::{ + AssignProcessToJobObject, CreateJobObjectW, SetInformationJobObject, + JOBOBJECT_EXTENDED_LIMIT_INFORMATION, + }; + use windows_sys::Win32::System::LibraryLoader::{GetModuleHandleW, GetProcAddress}; + use windows_sys::Win32::System::Registry::{ + RegCloseKey, RegCreateKeyExW, HKEY, HKEY_CURRENT_USER, + }; + use windows_sys::Win32::System::Threading::{ + CreateProcessAsUserW, CreateProcessW, DeleteProcThreadAttributeList, GetCurrentProcess, + GetCurrentProcessId, GetExitCodeProcess, InitializeProcThreadAttributeList, + OpenProcessToken, ResumeThread, UpdateProcThreadAttribute, WaitForSingleObject, + PROCESS_INFORMATION, STARTUPINFOEXW, STARTUPINFOW, + }; + + fn sandbox_diag(msg: impl std::fmt::Display) { + if std::env::var_os("LIVEAGENT_SANDBOX_LOG").is_some() { + eprintln!("{msg}"); + } + } + + // 以本地常量代替对 windows-sys 各 feature 常量导出的依赖:字段类型均为整型别名 + // (windows-sys 用 type alias 而非 newtype),直接赋整型字面量即可,极大降低 + // “某常量是否在某 feature 下导出”的编译风险。数值均取自 Win32 头文件。 + const TOKEN_QUERY: u32 = 0x0008; + const TOKEN_DUPLICATE: u32 = 0x0002; + const TOKEN_ASSIGN_PRIMARY: u32 = 0x0001; + const TOKEN_ADJUST_DEFAULT: u32 = 0x0080; + + #[cfg(test)] + const DISABLE_MAX_PRIVILEGE: u32 = 0x1; + #[cfg(test)] + const LUA_TOKEN: u32 = 0x4; + #[cfg(test)] + const WRITE_RESTRICTED: u32 = 0x8; + + const SE_GROUP_LOGON_ID: u32 = 0xC000_0000; + const TOKEN_GROUPS_CLASS: i32 = 2; // TOKEN_INFORMATION_CLASS::TokenGroups + const TOKEN_USER_CLASS: i32 = 1; // TokenUser + const TOKEN_DEFAULT_DACL_CLASS: i32 = 6; // TOKEN_INFORMATION_CLASS::TokenDefaultDacl + const TOKEN_INTEGRITY_LEVEL_CLASS: i32 = 25; // TokenIntegrityLevel + + const SE_FILE_OBJECT: i32 = 1; // SE_OBJECT_TYPE + const SE_REGISTRY_KEY: i32 = 4; + const SE_KERNEL_OBJECT: i32 = 6; + const READ_CONTROL: u32 = 0x0002_0000; + const WRITE_DAC: u32 = 0x0004_0000; + const WRITE_OWNER: u32 = 0x0008_0000; + const ERROR_ACCESS_DENIED: u32 = 5; + const DIRECTORY_QUERY: u32 = 0x1; + const DIRECTORY_TRAVERSE: u32 = 0x2; + const DIRECTORY_CREATE_OBJECT: u32 = 0x4; + const DIRECTORY_CREATE_SUBDIRECTORY: u32 = 0x8; + const DIRECTORY_ALL_ACCESS: u32 = 0x000F_000F; + const OBJ_CASE_INSENSITIVE: u32 = 0x0000_0040; + const OBJ_OPENIF: u32 = 0x0000_0080; + const KEY_READ: u32 = 0x0002_0019; + const KEY_WRITE: u32 = 0x0002_0006; + const KEY_ALL_ACCESS: u32 = 0x000F_003F; + const DACL_SECURITY_INFORMATION: u32 = 0x0000_0004; + #[cfg(test)] + const PROTECTED_DACL_SECURITY_INFORMATION: u32 = 0x8000_0000; + const LABEL_SECURITY_INFORMATION: u32 = 0x0000_0010; + const SE_GROUP_INTEGRITY: u32 = 0x0000_0020; + const OBJECT_INHERIT_ACE: u32 = 0x1; + const CONTAINER_INHERIT_ACE: u32 = 0x2; + const GRANT_ACCESS: i32 = 1; // ACCESS_MODE + const REVOKE_ACCESS: i32 = 4; + const TRUSTEE_IS_SID: i32 = 0; // TRUSTEE_FORM + const TRUSTEE_IS_UNKNOWN: i32 = 0; // TRUSTEE_TYPE + const ACL_SIZE_INFORMATION_CLASS: i32 = 2; // ACL_INFORMATION_CLASS::AclSizeInformation + const ACCESS_ALLOWED_ACE_TYPE: u8 = 0; + const ACL_REVISION: u32 = 2; + const GENERIC_ALL: u32 = 0x1000_0000; + const SE_GROUP_ENABLED: u32 = 0x0000_0004; + + // 文件访问权掩码(标准值);DELETE 本地定义以回避导入位置歧义。 + const FILE_GENERIC_READ: u32 = 0x0012_0089; + const FILE_GENERIC_WRITE: u32 = 0x0012_0116; + const FILE_GENERIC_EXECUTE: u32 = 0x0012_00A0; + const DELETE_RIGHT: u32 = 0x0001_0000; + + const HANDLE_FLAG_INHERIT: u32 = 0x1; + const STARTF_USESTDHANDLES: u32 = 0x0000_0100; + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + const CREATE_SUSPENDED: u32 = 0x0000_0004; + const EXTENDED_STARTUPINFO_PRESENT: u32 = 0x0008_0000; + const INFINITE: u32 = 0xFFFF_FFFF; + const STD_INPUT_HANDLE: u32 = 0xFFFF_FFF6; // (DWORD)-10 + const STD_OUTPUT_HANDLE: u32 = 0xFFFF_FFF5; // -11 + const STD_ERROR_HANDLE: u32 = 0xFFFF_FFF4; // -12 + + // ProcThreadAttribute 常量:低 16 位是序号,高位是标志(值取自 WinBase.h 的 + // ProcThreadAttributeValue 宏展开)。HANDLE_LIST=0x00020002、SECURITY_CAPABILITIES=0x00020009。 + const PROC_THREAD_ATTRIBUTE_HANDLE_LIST: usize = 0x0002_0002; + const PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES: usize = 0x0002_0009; + // ProcThreadAttributeBnoIsolation = 19, Input 标志 ⇒ 0x00020013。把 Win32 + // Local\ 名字重定向到进程私有前缀;MSYS 直调 NtCreateDirectoryObject 不受影响。 + const PROC_THREAD_ATTRIBUTE_BNO_ISOLATION: usize = 0x0002_0013; + + #[repr(C)] + struct ProcessBnoIsolationAttribute { + isolation_enabled: i32, + isolation_prefix: [u16; 136], + } + + const JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE: u32 = 0x2000; + const JOB_OBJECT_EXTENDED_LIMIT_INFO_CLASS: i32 = 9; // JobObjectExtendedLimitInformation + + #[cfg(test)] + const WRITE_RESTRICTED_SID: &str = "S-1-5-33"; + const LOW_INTEGRITY_SID: &str = "S-1-16-4096"; + const LOW_INTEGRITY_SDDL: &str = "S:(ML;OICI;NW;;;LW)"; + + // loader 早期失败的 NTSTATUS 退出码——子进程根本没进 main 就被内核/加载器杀死。 + // 用于把裸退出码翻成可读诊断(见 loader_failure_hint)。 + const STATUS_DLL_INIT_FAILED: u32 = 0xC000_0142; + const STATUS_DLL_NOT_FOUND: u32 = 0xC000_0135; + const STATUS_ACCESS_DENIED: u32 = 0xC000_0022; + // CLR 未处理异常:PowerShell 把 CNG `NTE_PROVIDER_DLL_FAIL` 包装成“BCrypt 加载失败” + // 后以此码退出。不是 NTSTATUS,shell 探测原先漏掉它,会把已崩溃的 pwsh 当成可用。 + const CLR_UNHANDLED_EXCEPTION: u32 = 0xE043_4352; + const NTE_PROVIDER_DLL_FAIL: u32 = 0x8009_001D; + // HRESULT_FROM_WIN32(ERROR_ACCESS_DENIED):Windows PowerShell / .NET Framework + // 写 CLR 用户缓存失败时的直接退出码,与上面的 CLR 包装码不是同一条路径。 + const E_ACCESSDENIED: u32 = 0x8007_0005; + // powershell.exe 宿主在 CLR 初始化失败时不回传 HRESULT,而是用这个包装码。 + const POWERSHELL_CLR_INIT_FAILED: u32 = 0xFFFF_0000; + + /// PSID 别名(windows-sys 里就是 `*mut c_void`),提升可读性。 + type PSID = *mut c_void; + + // windows-sys 0.61 的 FFI 布尔返回是 `windows_sys::core::BOOL`(= i32);此处直接 + // 用 i32 作参数(透明别名,可接收所有这些函数的返回)。 + #[inline] + fn ok(b: i32) -> bool { + b != 0 + } + + fn last_error(ctx: &str) -> String { + let code = unsafe { GetLastError() }; + format!("{ctx} (GetLastError={code})") + } + + /// str → 以 NUL 结尾的 UTF-16。 + fn to_wide(s: &str) -> Vec { + s.encode_utf16().chain(std::iter::once(0)).collect() + } + + /// ConvertStringSidToSidW 分配的 SID,Drop 时 LocalFree。 + struct LocalSid(PSID); + + impl Drop for LocalSid { + fn drop(&mut self) { + if !self.0.is_null() { + unsafe { + LocalFree(self.0 as _); + } + } + } + } + + /// `DeriveCapabilitySidsFromName` 同时分配 SID 指针数组和每个 SID;两层都须 + /// `LocalFree`。只在派生期间持有,随后把目标 SID 复制进 Rust 自持缓冲。 + struct LocalSidArray { + ptr: *mut PSID, + count: u32, + } + + impl Drop for LocalSidArray { + fn drop(&mut self) { + unsafe { + if !self.ptr.is_null() { + for index in 0..self.count as usize { + let sid = *self.ptr.add(index); + if !sid.is_null() { + LocalFree(sid as _); + } + } + LocalFree(self.ptr as _); + } + } + } + } + + fn string_to_sid(s: &str) -> Result { + let wide = to_wide(s); + let mut sid: PSID = null_mut(); + let r = unsafe { ConvertStringSidToSidW(wide.as_ptr(), &mut sid) }; + if !ok(r) || sid.is_null() { + return Err(last_error(&format!("ConvertStringSidToSidW({s})"))); + } + Ok(LocalSid(sid)) + } + + fn sddl_to_sd(sddl: &str) -> Result<*mut c_void, String> { + let wide = to_wide(sddl); + let mut sd: *mut c_void = null_mut(); + let r = unsafe { + ConvertStringSecurityDescriptorToSecurityDescriptorW( + wide.as_ptr(), + 1, + &mut sd, + null_mut(), + ) + }; + if !ok(r) || sd.is_null() { + return Err(last_error( + "ConvertStringSecurityDescriptorToSecurityDescriptorW", + )); + } + Ok(sd) + } + + fn with_low_integrity_sacl( + f: impl FnOnce(*mut ACL) -> Result, + ) -> Result { + let sd = sddl_to_sd(LOW_INTEGRITY_SDDL)?; + let result = unsafe { + let mut present: i32 = 0; + let mut sacl: *mut ACL = null_mut(); + let mut defaulted: i32 = 0; + if !ok(GetSecurityDescriptorSacl( + sd, + &mut present, + &mut sacl, + &mut defaulted, + )) || sacl.is_null() + { + LocalFree(sd as _); + return Err(last_error("GetSecurityDescriptorSacl(low integrity)")); + } + f(sacl) + }; + unsafe { + LocalFree(sd as _); + } + result + } + + /// 联网子进程保留当前用户的完整网络/登录会话能力,因此用 Low IL + NoWriteUp + /// 拦住 Medium 的 home、盘符根和工作区父目录;工作区与围栏 TEMP 标 Low 后仍可写。 + fn set_token_low_integrity(token: HANDLE) -> Result<(), String> { + let sid = string_to_sid(LOW_INTEGRITY_SID)?; + let mut label = SID_AND_ATTRIBUTES { + Sid: sid.0, + Attributes: SE_GROUP_INTEGRITY, + }; + let r = unsafe { + SetTokenInformation( + token, + TOKEN_INTEGRITY_LEVEL_CLASS, + &mut label as *mut _ as *mut c_void, + std::mem::size_of::() as u32, + ) + }; + if !ok(r) { + return Err(last_error("SetTokenInformation(TokenIntegrityLevel=Low)")); + } + Ok(()) + } + + fn set_low_integrity_label(object_type: i32, name: &str) -> Result { + let mut path_wide = to_wide(name); + with_low_integrity_sacl(|sacl| unsafe { + Ok(SetNamedSecurityInfoW( + path_wide.as_mut_ptr(), + object_type, + LABEL_SECURITY_INFORMATION, + null_mut(), + null_mut(), + null_mut(), + sacl, + )) + }) + } + + /// Mandatory Label 属于 SACL,但 `LABEL_SECURITY_INFORMATION` 的访问检查要求 + /// `WRITE_OWNER`;对象 owner 只隐式拥有 READ_CONTROL/WRITE_DAC,普通的 Modify DACL + /// 因而会返回 ERROR_ACCESS_DENIED。只在该错误上给启动器用户补最小 WRITE_OWNER + /// ACE 后重试,无需管理员/UAC,也不把其它标签/API 错误误判成 ACL 问题。 + fn ensure_low_integrity_label( + object_type: i32, + name: &str, + launcher_user_sid: PSID, + ) -> Result<(), String> { + let first = set_low_integrity_label(object_type, name)?; + if first == 0 { + return Ok(()); + } + if first != ERROR_ACCESS_DENIED { + return Err(format!( + "SetNamedSecurityInfoW(low IL, {name}) failed (error={first})" + )); + } + + ensure_named_write_ace(object_type, name, launcher_user_sid, WRITE_OWNER).map_err( + |grant_err| { + format!( + "SetNamedSecurityInfoW(low IL, {name}) failed (error={first}); \ + granting WRITE_OWNER to the launcher user also failed: {grant_err}" + ) + }, + )?; + let retry = set_low_integrity_label(object_type, name)?; + if retry != 0 { + return Err(format!( + "SetNamedSecurityInfoW(low IL, {name}) failed after granting WRITE_OWNER \ + (initial error={first}, retry error={retry})" + )); + } + Ok(()) + } + + fn stamp_low_integrity_tree(path: &Path, launcher_user_sid: PSID) { + for entry in walkdir::WalkDir::new(path).into_iter().flatten() { + let name = entry.path().to_string_lossy(); + if let Err(err) = ensure_low_integrity_label(SE_FILE_OBJECT, &name, launcher_user_sid) { + sandbox_diag(format!("liveagent sandbox: low IL skipped ({name}): {err}")); + } + } + } + + fn set_handle_low_integrity(handle: HANDLE, label: &str) { + let invalid: HANDLE = usize::MAX as HANDLE; + if handle.is_null() || handle == invalid { + return; + } + let _ = with_low_integrity_sacl(|sacl| { + let rc = unsafe { + SetSecurityInfo( + handle, + SE_KERNEL_OBJECT, + LABEL_SECURITY_INFORMATION, + null_mut(), + null_mut(), + null_mut(), + sacl, + ) + }; + if rc != 0 { + sandbox_diag(format!( + "liveagent sandbox: std handle low IL skipped ({label}): error={rc}" + )); + } + Ok(()) + }); + } + + fn looks_like_powershell(program: &Path) -> bool { + let name = program.file_name().and_then(|n| n.to_str()).unwrap_or(""); + name.eq_ignore_ascii_case("powershell.exe") || name.eq_ignore_ascii_case("pwsh.exe") + } + + fn ensure_runtime_low_integrity_surface( + write_root: &Path, + temp: &Path, + program: &Path, + launcher_user_sid: PSID, + ) { + stamp_low_integrity_tree(write_root, launcher_user_sid); + if let Err(err) = + ensure_low_integrity_label(SE_FILE_OBJECT, &temp.to_string_lossy(), launcher_user_sid) + { + sandbox_diag(format!( + "liveagent sandbox: TEMP low IL skipped ({temp:?}): {err}" + )); + } + if !looks_like_powershell(program) { + return; + } + use crate::runtime::sandbox::{ + clr_user_file_dirs, cng_named_registry_object, cng_user_file_dirs, + CLR_USER_REGISTRY_SUBKEYS, CNG_USER_REGISTRY_SUBKEYS, + }; + for subkey in CNG_USER_REGISTRY_SUBKEYS + .iter() + .chain(CLR_USER_REGISTRY_SUBKEYS) + { + let name = cng_named_registry_object(subkey); + if let Err(err) = ensure_low_integrity_label(SE_REGISTRY_KEY, &name, launcher_user_sid) + { + sandbox_diag(format!( + "liveagent sandbox: registry low IL skipped ({name}): {err}" + )); + } + } + let (Some(appdata), Some(local)) = (dirs::data_dir(), dirs::data_local_dir()) else { + return; + }; + for dir in cng_user_file_dirs(&appdata, &local) + .into_iter() + .chain(clr_user_file_dirs(&appdata, &local)) + { + if let Err(err) = ensure_low_integrity_label( + SE_FILE_OBJECT, + &dir.to_string_lossy(), + launcher_user_sid, + ) { + sandbox_diag(format!( + "liveagent sandbox: runtime dir low IL skipped ({dir:?}): {err}" + )); + } + } + } + + /// 打开当前进程的主令牌;附带 DUPLICATE / ASSIGN_PRIMARY / ADJUST_DEFAULT, + /// 供联网后端复制主令牌并设置 Low IL。 + fn open_process_token() -> Result { + let mut token: HANDLE = null_mut(); + let access = TOKEN_QUERY | TOKEN_DUPLICATE | TOKEN_ASSIGN_PRIMARY | TOKEN_ADJUST_DEFAULT; + let r = unsafe { OpenProcessToken(GetCurrentProcess(), access, &mut token) }; + if !ok(r) { + return Err(last_error("OpenProcessToken")); + } + Ok(token) + } + + /// 从令牌 TokenGroups 里读出登录 SID(SE_GROUP_LOGON_ID),复制成自持字节缓冲。 + fn logon_sid_bytes(token: HANDLE) -> Result, String> { + let mut len: u32 = 0; + // 首次调用取所需长度(预期失败并置 len)。 + unsafe { GetTokenInformation(token, TOKEN_GROUPS_CLASS, null_mut(), 0, &mut len) }; + if len == 0 { + return Err(last_error("GetTokenInformation(TokenGroups) size probe")); + } + // 用 u64 缓冲保证 8 字节对齐(TOKEN_GROUPS 含指针,Vec 不保证对齐)。 + let mut buf: Vec = vec![0u64; ((len as usize) + 7) / 8]; + let r = unsafe { + GetTokenInformation( + token, + TOKEN_GROUPS_CLASS, + buf.as_mut_ptr() as *mut c_void, + len, + &mut len, + ) + }; + if !ok(r) { + return Err(last_error("GetTokenInformation(TokenGroups)")); + } + unsafe { + let groups = buf.as_ptr() as *const TOKEN_GROUPS; + let count = (*groups).GroupCount; + let arr = (*groups).Groups.as_ptr(); + for i in 0..count as usize { + let entry: &SID_AND_ATTRIBUTES = &*arr.add(i); + if entry.Attributes & SE_GROUP_LOGON_ID == SE_GROUP_LOGON_ID { + let sid_len = GetLengthSid(entry.Sid); + if sid_len == 0 { + return Err(last_error("GetLengthSid(logon sid)")); + } + let mut sid_buf = vec![0u8; sid_len as usize]; + if !ok(CopySid(sid_len, sid_buf.as_mut_ptr() as PSID, entry.Sid)) { + return Err(last_error("CopySid(logon sid)")); + } + return Ok(sid_buf); + } + } + } + Err("logon SID (SE_GROUP_LOGON_ID) not present in token".to_string()) + } + + fn token_user_sid_bytes(token: HANDLE) -> Result, String> { + let mut len: u32 = 0; + unsafe { GetTokenInformation(token, TOKEN_USER_CLASS, null_mut(), 0, &mut len) }; + if len == 0 { + return Err(last_error("GetTokenInformation(TokenUser) size probe")); + } + let mut buf: Vec = vec![0u64; ((len as usize) + 7) / 8]; + let r = unsafe { + GetTokenInformation( + token, + TOKEN_USER_CLASS, + buf.as_mut_ptr() as *mut c_void, + len, + &mut len, + ) + }; + if !ok(r) { + return Err(last_error("GetTokenInformation(TokenUser)")); + } + unsafe { + let user = &*(buf.as_ptr() as *const TOKEN_USER); + let sid_len = GetLengthSid(user.User.Sid); + if sid_len == 0 { + return Err(last_error("GetLengthSid(token user)")); + } + let mut sid_buf = vec![0u8; sid_len as usize]; + if !ok(CopySid( + sid_len, + sid_buf.as_mut_ptr() as PSID, + user.User.Sid, + )) { + return Err(last_error("CopySid(token user)")); + } + Ok(sid_buf) + } + } + + /// 用 {登录 SID, S-1-5-33, 合成 SID} 作限制性 SID,建 WRITE_RESTRICTED 主令牌。 + #[cfg(test)] + fn create_restricted_token(base: HANDLE, restricting: &[PSID]) -> Result { + let mut sids: Vec = restricting + .iter() + .map(|&sid| SID_AND_ATTRIBUTES { + Sid: sid, + Attributes: 0, + }) + .collect(); + let mut restricted: HANDLE = null_mut(); + let flags = DISABLE_MAX_PRIVILEGE | LUA_TOKEN | WRITE_RESTRICTED; + let r = unsafe { + CreateRestrictedToken( + base, + flags, + 0, + null(), + 0, + null(), + sids.len() as u32, + sids.as_mut_ptr(), + &mut restricted, + ) + }; + if !ok(r) { + return Err(last_error("CreateRestrictedToken")); + } + Ok(restricted) + } + + fn duplicate_primary_token(base: HANDLE) -> Result { + let mut token: HANDLE = null_mut(); + let access = TOKEN_QUERY | TOKEN_DUPLICATE | TOKEN_ASSIGN_PRIMARY | TOKEN_ADJUST_DEFAULT; + let r = unsafe { + DuplicateTokenEx( + base, + access, + null(), + 2, // SecurityImpersonation + 1, // TokenPrimary + &mut token, + ) + }; + if !ok(r) { + return Err(last_error("DuplicateTokenEx(TokenPrimary)")); + } + if ok(unsafe { IsTokenRestricted(token) }) { + unsafe { + CloseHandle(token); + } + return Err( + "DuplicateTokenEx unexpectedly returned a restricted token; HTTPS credentials \ + would be unavailable" + .to_string(), + ); + } + Ok(token) + } + + /// 向令牌的 default DACL 追加「登录 SID 全权」ACE。 + /// + /// 子进程新建的内核对象(msys/cygwin 共享内存、signal pipe、事件等)套用该 DACL; + /// 登录 SID 让同一登录会话稳定重开这些对象。GENERIC_ALL 只作用于“该进程自建” + /// 的对象,不放宽文件写围栏。 + fn append_sid_to_default_dacl(token: HANDLE, sid: PSID) -> Result<(), String> { + const ACL_APPEND_AT_END: u32 = 0xFFFF_FFFF; // MAXDWORD ⇒ AddAce 追加到尾部 + unsafe { + let mut len: u32 = 0; + GetTokenInformation(token, TOKEN_DEFAULT_DACL_CLASS, null_mut(), 0, &mut len); + if len == 0 { + return Err(last_error( + "GetTokenInformation(TokenDefaultDacl) size probe", + )); + } + let mut buf: Vec = vec![0u64; ((len as usize) + 7) / 8]; + if !ok(GetTokenInformation( + token, + TOKEN_DEFAULT_DACL_CLASS, + buf.as_mut_ptr() as *mut c_void, + len, + &mut len, + )) { + return Err(last_error("GetTokenInformation(TokenDefaultDacl)")); + } + let old_dacl = (*(buf.as_ptr() as *const TOKEN_DEFAULT_DACL)).DefaultDacl; + // NULL default DACL ⇒ 新对象无保护(everyone 全权),两遍判定天然皆过,无需追加。 + if old_dacl.is_null() { + return Ok(()); + } + + let mut info = ACL_SIZE_INFORMATION { + AceCount: 0, + AclBytesInUse: 0, + AclBytesFree: 0, + }; + if !ok(GetAclInformation( + old_dacl, + &mut info as *mut _ as *mut c_void, + std::mem::size_of::() as u32, + ACL_SIZE_INFORMATION_CLASS, + )) { + return Err(last_error("GetAclInformation(default DACL)")); + } + let sid_len = GetLengthSid(sid); + if sid_len == 0 { + return Err(last_error("GetLengthSid(default DACL trustee)")); + } + // ACCESS_ALLOWED_ACE 自带一个 u32 的 SidStart 占位,故净增 = 结构长 - 4 + SID 长; + // SID 长恒为 4 的倍数,天然满足 ACL 的 DWORD 对齐。 + let ace_len = std::mem::size_of::() as u32 - 4 + sid_len; + let new_len = ((info.AclBytesInUse + ace_len) + 3) & !3; + + let mut new_buf: Vec = vec![0u64; ((new_len as usize) + 7) / 8]; + let new_acl = new_buf.as_mut_ptr() as *mut ACL; + if !ok(InitializeAcl(new_acl, new_len, ACL_REVISION)) { + return Err(last_error("InitializeAcl(default DACL)")); + } + // 原 ACE 顺序照抄(default DACL 全为 allow ACE,顺序无语义,仍保守保序)。 + for i in 0..info.AceCount { + let mut ace: *mut c_void = null_mut(); + if !ok(GetAce(old_dacl, i, &mut ace)) || ace.is_null() { + return Err(last_error("GetAce(default DACL)")); + } + let size = (*(ace as *const ACE_HEADER)).AceSize as u32; + if !ok(AddAce(new_acl, ACL_REVISION, ACL_APPEND_AT_END, ace, size)) { + return Err(last_error("AddAce(copy default DACL)")); + } + } + if !ok(AddAccessAllowedAce(new_acl, ACL_REVISION, GENERIC_ALL, sid)) { + return Err(last_error("AddAccessAllowedAce(logon sid)")); + } + let tdd = TOKEN_DEFAULT_DACL { + DefaultDacl: new_acl, + }; + // SetTokenInformation 把 DACL 拷贝进令牌,new_buf 随后释放无碍。 + if !ok(SetTokenInformation( + token, + TOKEN_DEFAULT_DACL_CLASS, + &tdd as *const _ as *const c_void, + std::mem::size_of::() as u32, + )) { + return Err(last_error("SetTokenInformation(TokenDefaultDacl)")); + } + } + Ok(()) + } + + /// AppContainer SID(FreeSid 释放,区别于 LocalSid 的 LocalFree)。 + struct AcSid(PSID); + + impl Drop for AcSid { + fn drop(&mut self) { + if !self.0.is_null() { + unsafe { + FreeSid(self.0); + } + } + } + } + + /// AC profile 名:确定性、每工作区一个。硬限制 64 字符:前缀 18 + dir_key ≤ 43 + /// (4 段 u32 十进制,下划线连接)= ≤ 61;字符集 [0-9A-Za-z._] 合法。 + fn appcontainer_profile_name(dir_key: &str) -> String { + format!("LiveAgent.Sandbox.{dir_key}") + } + + /// 私有 capability 仅作为文件/注册表 ACL 的工作区身份,不对应任何 Windows + /// 网络 capability。名字与工作区确定性绑定,因此不同工作区互不可写。 + fn workspace_capability_name(dir_key: &str) -> String { + format!("LiveAgent.Workspace.{dir_key}") + } + + fn workspace_capability_sid(dir_key: &str) -> Result, String> { + let name = workspace_capability_name(dir_key); + let name_w = to_wide(&name); + let mut group_ptr: *mut PSID = null_mut(); + let mut group_count = 0u32; + let mut capability_ptr: *mut PSID = null_mut(); + let mut capability_count = 0u32; + let derived = unsafe { + DeriveCapabilitySidsFromName( + name_w.as_ptr(), + &mut group_ptr, + &mut group_count, + &mut capability_ptr, + &mut capability_count, + ) + }; + let _groups = LocalSidArray { + ptr: group_ptr, + count: group_count, + }; + let capabilities = LocalSidArray { + ptr: capability_ptr, + count: capability_count, + }; + if !ok(derived) { + return Err(last_error(&format!("DeriveCapabilitySidsFromName({name})"))); + } + if capabilities.ptr.is_null() || capabilities.count == 0 { + return Err(format!( + "DeriveCapabilitySidsFromName({name}) returned no capability SID" + )); + } + unsafe { + let sid = *capabilities.ptr; + if sid.is_null() { + return Err(format!( + "DeriveCapabilitySidsFromName({name}) returned a null capability SID" + )); + } + let sid_len = GetLengthSid(sid); + if sid_len == 0 { + return Err(last_error("GetLengthSid(workspace capability)")); + } + let mut copied = vec![0u8; sid_len as usize]; + if !ok(CopySid(sid_len, copied.as_mut_ptr() as PSID, sid)) { + return Err(last_error("CopySid(workspace capability)")); + } + Ok(copied) + } + } + + fn derive_appcontainer_profile_sid(dir_key: &str) -> Result { + let name = appcontainer_profile_name(dir_key); + let name_w = to_wide(&name); + let mut sid: PSID = null_mut(); + let derived = + unsafe { DeriveAppContainerSidFromAppContainerName(name_w.as_ptr(), &mut sid) }; + if derived == 0 && !sid.is_null() { + Ok(AcSid(sid)) + } else { + Err(format!( + "DeriveAppContainerSidFromAppContainerName({name}) failed hr={derived:#010X}" + )) + } + } + + /// 取(必要时创建)工作区专属 AppContainer profile 的 SID。 + /// + /// Profile 本身不注册任何 capability;启动时只注入私有工作区 capability, + /// 不注入网络 capability,所以 WFP 仍默认拒绝全部网络含 loopback。Create 失败 + /// (典型:已存在)即走 Derive;二者都失败才报错(fail-closed)。Profile 留存不删。 + fn appcontainer_profile_sid(dir_key: &str) -> Result { + let name = appcontainer_profile_name(dir_key); + let name_w = to_wide(&name); + let display_w = to_wide("LiveAgent Sandbox (offline)"); + let desc_w = to_wide("LiveAgent per-workspace offline sandbox"); + let mut sid: PSID = null_mut(); + let created = unsafe { + CreateAppContainerProfile( + name_w.as_ptr(), + display_w.as_ptr(), + desc_w.as_ptr(), + null(), // 零 capability + 0, + &mut sid, + ) + }; + if created == 0 && !sid.is_null() { + return Ok(AcSid(sid)); + } + derive_appcontainer_profile_sid(dir_key).map_err(|derive_err| { + format!( + "AppContainer profile unavailable: CreateAppContainerProfile hr={created:#010X}; \ + {derive_err}" + ) + }) + } + + #[cfg(test)] + pub(super) fn workspace_capability_sid_for_test(dir_key: &str) -> Result { + let sid = workspace_capability_sid(dir_key)?; + sid_string(sid.as_ptr() as PSID) + } + + #[cfg(test)] + pub(super) fn seed_legacy_appcontainer_ace_for_test( + path: &Path, + dir_key: &str, + ) -> Result<(), String> { + let sid = derive_appcontainer_profile_sid(dir_key)?; + ensure_write_ace(path, sid.0) + } + + /// 测试钩子:按名字纯派生 AC SID 并转成字符串形式(不创建 profile,无系统副作用)。 + #[cfg(test)] + pub(super) fn appcontainer_profile_sid_for_test(name: &str) -> Option { + use windows_sys::Win32::Security::Authorization::ConvertSidToStringSidW; + let name_w = to_wide(name); + let mut sid: PSID = null_mut(); + let derived = + unsafe { DeriveAppContainerSidFromAppContainerName(name_w.as_ptr(), &mut sid) }; + if derived != 0 || sid.is_null() { + return None; + } + let sid = AcSid(sid); // RAII:FreeSid + let mut s: *mut u16 = null_mut(); + let r = unsafe { ConvertSidToStringSidW(sid.0, &mut s) }; + if !ok(r) || s.is_null() { + return None; + } + let mut len = 0usize; + unsafe { + while *s.add(len) != 0 { + len += 1; + } + let out = String::from_utf16_lossy(std::slice::from_raw_parts(s, len)); + LocalFree(s as _); + Some(out) + } + } + + /// 测试钩子(真机):建受限令牌 → 追加登录 SID 到 default DACL → 读回验证 ACE + /// 确实存在(0xC0000142 修复的可断言部分)。返回 (追加前含登录 SID, 追加后含)。 + #[cfg(test)] + pub(super) fn default_dacl_fix_roundtrip_for_test() -> Result<(bool, bool), String> { + fn dacl_contains(token: HANDLE, sid: PSID) -> Result { + unsafe { + let mut len: u32 = 0; + GetTokenInformation(token, TOKEN_DEFAULT_DACL_CLASS, null_mut(), 0, &mut len); + if len == 0 { + return Err(last_error("GetTokenInformation(TokenDefaultDacl) probe")); + } + let mut buf: Vec = vec![0u64; ((len as usize) + 7) / 8]; + if !ok(GetTokenInformation( + token, + TOKEN_DEFAULT_DACL_CLASS, + buf.as_mut_ptr() as *mut c_void, + len, + &mut len, + )) { + return Err(last_error("GetTokenInformation(TokenDefaultDacl)")); + } + let dacl = (*(buf.as_ptr() as *const TOKEN_DEFAULT_DACL)).DefaultDacl; + if dacl.is_null() { + return Ok(false); + } + let mut info = ACL_SIZE_INFORMATION { + AceCount: 0, + AclBytesInUse: 0, + AclBytesFree: 0, + }; + if !ok(GetAclInformation( + dacl, + &mut info as *mut _ as *mut c_void, + std::mem::size_of::() as u32, + ACL_SIZE_INFORMATION_CLASS, + )) { + return Err(last_error("GetAclInformation(TokenDefaultDacl)")); + } + for i in 0..info.AceCount { + let mut ace: *mut c_void = null_mut(); + if !ok(GetAce(dacl, i, &mut ace)) || ace.is_null() { + continue; + } + if (*(ace as *const ACE_HEADER)).AceType == ACCESS_ALLOWED_ACE_TYPE { + let allow = ace as *const ACCESS_ALLOWED_ACE; + let sid_ptr = &(*allow).SidStart as *const u32 as PSID; + if ok(EqualSid(sid_ptr, sid)) { + return Ok(true); + } + } + } + Ok(false) + } + } + + let synthetic = string_to_sid("S-1-5-21-1-2-3-4")?; + let write_restricted = string_to_sid(WRITE_RESTRICTED_SID)?; + let token = OwnedHandle(open_process_token()?); + let logon = logon_sid_bytes(token.0)?; + let logon_ptr = logon.as_ptr() as PSID; + let restricting: [PSID; 3] = [logon_ptr, write_restricted.0, synthetic.0]; + let rt = OwnedHandle(create_restricted_token(token.0, &restricting)?); + let before = dacl_contains(rt.0, logon_ptr)?; + append_sid_to_default_dacl(rt.0, logon_ptr)?; + let after = dacl_contains(rt.0, logon_ptr)?; + Ok((before, after)) + } + + /// CloseHandle RAII:错误提前返回时不再需要手工逐支关闭。 + struct OwnedHandle(HANDLE); + + impl Drop for OwnedHandle { + fn drop(&mut self) { + if !self.0.is_null() { + unsafe { + CloseHandle(self.0); + } + } + } + } + + /// ProcThreadAttributeList RAII(两段式分配;Drop 时 Delete)。 + /// + /// 注意生命周期契约:经 `set` 挂上的 value 指针必须存活到本对象 Drop(MSDN 对 + /// UpdateProcThreadAttribute 的要求)——调用方须把 value 声明在本对象**之前** + /// (Rust 局部量逆序析构 ⇒ 本对象先于 value 析构)。 + struct AttrList { + buf: Vec, // u64 保证 8 字节对齐 + } + + impl AttrList { + fn new(count: u32) -> Result { + let mut size: usize = 0; + unsafe { InitializeProcThreadAttributeList(null_mut(), count, 0, &mut size) }; + if size == 0 { + return Err(last_error("InitializeProcThreadAttributeList size probe")); + } + let mut buf: Vec = vec![0u64; (size + 7) / 8]; + let r = unsafe { + InitializeProcThreadAttributeList( + buf.as_mut_ptr() as *mut c_void, + count, + 0, + &mut size, + ) + }; + if !ok(r) { + return Err(last_error("InitializeProcThreadAttributeList")); + } + Ok(Self { buf }) + } + + fn ptr(&mut self) -> *mut c_void { + self.buf.as_mut_ptr() as *mut c_void + } + + fn set( + &mut self, + attribute: usize, + value: *const c_void, + size: usize, + ctx: &str, + ) -> Result<(), String> { + let r = unsafe { + UpdateProcThreadAttribute(self.ptr(), 0, attribute, value, size, null_mut(), null()) + }; + if !ok(r) { + return Err(last_error(ctx)); + } + Ok(()) + } + } + + impl Drop for AttrList { + fn drop(&mut self) { + unsafe { DeleteProcThreadAttributeList(self.buf.as_mut_ptr() as *mut c_void) }; + } + } + + /// 子进程 loader 早期死亡(未进 main)的 NTSTATUS 退出码 → 可读诊断(中英双语, + /// 经 stderr 走既有管道上传给模型/UI;裸退出码对用户与模型都不可行动)。 + fn loader_failure_hint(exit_code: u32) -> Option<&'static str> { + match exit_code { + STATUS_DLL_INIT_FAILED => Some( + "a DLL failed to initialize under the sandbox (STATUS_DLL_INIT_FAILED); \ + MSYS/Cygwin-based tools (e.g. Git Bash) may be incompatible here and the shell \ + runner will try the next shell candidate / 沙箱内有 DLL 初始化失败(0xC0000142):\ + MSYS/Cygwin 系工具(如 Git Bash)可能与该沙箱不兼容,shell 将自动尝试下一候选", + ), + STATUS_DLL_NOT_FOUND => Some( + "a required DLL was not found under the sandbox (STATUS_DLL_NOT_FOUND); the tool's \ + install directory may be unreadable in this mode / 沙箱内找不到所需 DLL(0xC0000135):\ + 该工具的安装目录在此模式下可能不可读", + ), + STATUS_ACCESS_DENIED => Some( + "the sandbox denied access while starting the process (STATUS_ACCESS_DENIED); the \ + program or its directory is not readable in this mode / 沙箱拒绝了进程启动所需的访问\ + (0xC0000022):该程序或其目录在此模式下不可读", + ), + CLR_UNHANDLED_EXCEPTION | NTE_PROVIDER_DLL_FAIL => Some( + "the runtime failed during crypto provider init under the sandbox token \ + (CLR 0xE0434352 / NTE_PROVIDER_DLL_FAIL); this is usually HKCU certificate-store \ + or %APPDATA%\\Microsoft\\Crypto being unwritable, not a broken BCrypt.dll. The \ + shell runner will try the next candidate / 沙箱内加密提供程序初始化失败(0xE0434352):\ + 通常是用户证书库或 Crypto 目录不可写,并非本机 pwsh/BCrypt.dll 损坏,shell 将尝试下一候选", + ), + E_ACCESSDENIED | POWERSHELL_CLR_INIT_FAILED => Some( + "the runtime was denied a write during CLR/PowerShell startup \ + (HRESULT 0x80070005 E_ACCESSDENIED / exit 0xFFFF0000); this is usually \ + the user CLR cache or PowerShell module-analysis directory being \ + unwritable at Low Integrity, not a broken powershell.exe. \ + The shell runner will try the next candidate / 沙箱内 CLR/PowerShell \ + 启动时写被拒绝(0x80070005 / 0xFFFF0000):通常是用户 CLR 缓存或 \ + PowerShell 模块分析目录不可写,并非本机 powershell.exe 损坏,shell 将尝试下一候选", + ), + _ => None, + } + } + + /// 断网沙箱的 env 叠加(防御纵深,对齐 Codex):内核级 WFP 阻断之上,让常见工具 + /// 不必等 TCP 失败,直接按各自的 offline/代理约定快速、明确地报错。黑洞代理指向 + /// 127.0.0.1:9(discard 端口,无监听;AC 内 loopback 本就被拒)。 + /// 设置在启动器自身环境上,经 lpEnvironment=NULL 的继承传给子进程(与 TEMP 重定向同路)。 + fn set_offline_env() -> Result<(), String> { + const BLACKHOLE: &str = "http://127.0.0.1:9"; + let pairs: &[(&str, &str)] = &[ + ("HTTP_PROXY", BLACKHOLE), + ("HTTPS_PROXY", BLACKHOLE), + ("ALL_PROXY", BLACKHOLE), + ("NO_PROXY", ""), // 清空例外表,黑洞代理不留旁路(Windows env 大小写不敏感,亦覆盖小写变体) + ("CARGO_NET_OFFLINE", "true"), + ("PIP_NO_INDEX", "1"), + ("NPM_CONFIG_OFFLINE", "true"), + ]; + for (name, value) in pairs { + let name_w = to_wide(name); + let value_w = to_wide(value); + unsafe { + if !ok(SetEnvironmentVariableW(name_w.as_ptr(), value_w.as_ptr())) { + return Err(last_error(&format!("SetEnvironmentVariableW({name})"))); + } + } + } + Ok(()) + } + + /// 命名对象 DACL 上是否已有受托 SID 且权限位足够的 ACE。命中即认为已盖章 + /// (可继承 ACE 会自动传播到后建的子对象),跳过昂贵的重新传播。任何探测失败 + /// 按“未盖章”处理。 + fn named_has_ace(object_type: i32, path_wide: &[u16], sid: PSID, required_access: u32) -> bool { + unsafe { + let mut dacl: *mut ACL = null_mut(); + let mut psd: *mut c_void = null_mut(); + let rc = GetNamedSecurityInfoW( + path_wide.as_ptr(), + object_type, + DACL_SECURITY_INFORMATION, + null_mut(), + null_mut(), + &mut dacl, + null_mut(), + &mut psd, + ); + if rc != 0 || dacl.is_null() { + if !psd.is_null() { + LocalFree(psd as _); + } + return false; + } + let mut info = ACL_SIZE_INFORMATION { + AceCount: 0, + AclBytesInUse: 0, + AclBytesFree: 0, + }; + let mut found = false; + if ok(GetAclInformation( + dacl, + &mut info as *mut _ as *mut c_void, + std::mem::size_of::() as u32, + ACL_SIZE_INFORMATION_CLASS, + )) { + for i in 0..info.AceCount { + let mut ace: *mut c_void = null_mut(); + if !ok(GetAce(dacl, i, &mut ace)) || ace.is_null() { + continue; + } + let header = ace as *const ACE_HEADER; + if (*header).AceType == ACCESS_ALLOWED_ACE_TYPE { + let allow = ace as *const ACCESS_ALLOWED_ACE; + let sid_ptr = &(*allow).SidStart as *const u32 as PSID; + if ok(EqualSid(sid_ptr, sid)) + && ((*allow).Mask & required_access) == required_access + { + found = true; + break; + } + } + } + } + LocalFree(psd as _); + found + } + } + + /// 在命名对象上盖“可继承(OI)(CI)”的授权写 ACE(不存在才盖)。 + /// + /// 为何只授不撤(P3#8,已知取舍,非疏漏): + /// - 受托 SID 由工作区路径确定性推导 ⇒ 每个工作区**最多一条** ACE(`named_has_ace` + /// 幂等守卫),不随运行次数累积; + /// - 该 SID 不映射任何活跃主体,遗留 ACE 不授予任何真实用户额外权限(惰性无害); + /// - 同一工作区可能有多个沙箱进程并发存活(Bash + ManagedProcess + resumable + /// session),按进程退出撤销会打断仍在运行的兄弟进程的写围栏; + /// - `SetNamedSecurityInfoW` 回写“撤销后的 DACL”还会踩空 DACL 陷阱(见上方 + /// `old_dacl.is_null()` 分支)。 + /// + /// 代价是资源管理器/注册表权限页会显示一个无法解析的 S-1-5-21-* 项,且卸载不清理。 + /// 若要提供清理,应做成显式的“清理沙箱 ACE”运维动作(遍历工作区列表按合成 SID + /// 精确删除),而不是塞进单次命令的生命周期里。 + fn ensure_named_write_ace( + object_type: i32, + name: &str, + sid: PSID, + access: u32, + ) -> Result<(), String> { + let mut path_wide = to_wide(name); + if named_has_ace(object_type, &path_wide, sid, access) { + return Ok(()); + } + unsafe { + let mut old_dacl: *mut ACL = null_mut(); + let mut psd: *mut c_void = null_mut(); + let rc = GetNamedSecurityInfoW( + path_wide.as_ptr(), + object_type, + DACL_SECURITY_INFORMATION, + null_mut(), + null_mut(), + &mut old_dacl, + null_mut(), + &mut psd, + ); + if rc != 0 { + return Err(format!("GetNamedSecurityInfoW({name}) failed (error={rc})")); + } + + // NULL DACL = 隐式“everyone 全权”:目标 SID 本就被授予写,无需盖章; + // 若仍用 SetEntriesInAclW(oldacl=NULL) 生成“仅目标 SID”的 DACL 再回写,反而把 + // 正常(无沙箱)访问锁死。故此情形直接跳过。 + if old_dacl.is_null() { + if !psd.is_null() { + LocalFree(psd as _); + } + return Ok(()); + } + + let mut ea: EXPLICIT_ACCESS_W = std::mem::zeroed(); + ea.grfAccessPermissions = access; + ea.grfAccessMode = GRANT_ACCESS; + ea.grfInheritance = OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE; + ea.Trustee = TRUSTEE_W { + pMultipleTrustee: null_mut(), + MultipleTrusteeOperation: 0, // NO_MULTIPLE_TRUSTEE + TrusteeForm: TRUSTEE_IS_SID, + TrusteeType: TRUSTEE_IS_UNKNOWN, + ptstrName: sid as *mut u16, + }; + + let mut new_dacl: *mut ACL = null_mut(); + let rc = SetEntriesInAclW(1, &ea, old_dacl, &mut new_dacl); + if rc != 0 || new_dacl.is_null() { + if !psd.is_null() { + LocalFree(psd as _); + } + return Err(format!("SetEntriesInAclW({name}) failed (error={rc})")); + } + + let rc = SetNamedSecurityInfoW( + path_wide.as_mut_ptr(), + object_type, + DACL_SECURITY_INFORMATION, + null_mut(), + null_mut(), + new_dacl, + null_mut(), + ); + LocalFree(new_dacl as _); + if !psd.is_null() { + LocalFree(psd as _); + } + if rc != 0 { + return Err(format!("SetNamedSecurityInfoW({name}) failed (error={rc})")); + } + } + Ok(()) + } + + /// 精确撤销旧版本写入的某个受托 SID。`REVOKE_ACCESS` 只移除该 SID 的 ACE, + /// 保留 owner、继承设置和其余 DACL;目录上的继承变化由 Windows 向下传播。 + fn remove_named_ace(object_type: i32, name: &str, sid: PSID) -> Result<(), String> { + let mut path_wide = to_wide(name); + if !named_has_ace(object_type, &path_wide, sid, 0) { + return Ok(()); + } + unsafe { + let mut old_dacl: *mut ACL = null_mut(); + let mut psd: *mut c_void = null_mut(); + let rc = GetNamedSecurityInfoW( + path_wide.as_ptr(), + object_type, + DACL_SECURITY_INFORMATION, + null_mut(), + null_mut(), + &mut old_dacl, + null_mut(), + &mut psd, + ); + if rc != 0 { + return Err(format!("GetNamedSecurityInfoW({name}) failed (error={rc})")); + } + if old_dacl.is_null() { + if !psd.is_null() { + LocalFree(psd as _); + } + return Ok(()); + } + + let mut ea: EXPLICIT_ACCESS_W = std::mem::zeroed(); + ea.grfAccessMode = REVOKE_ACCESS; + ea.Trustee = TRUSTEE_W { + pMultipleTrustee: null_mut(), + MultipleTrusteeOperation: 0, + TrusteeForm: TRUSTEE_IS_SID, + TrusteeType: TRUSTEE_IS_UNKNOWN, + ptstrName: sid as *mut u16, + }; + let mut new_dacl: *mut ACL = null_mut(); + let rc = SetEntriesInAclW(1, &ea, old_dacl, &mut new_dacl); + if rc != 0 || new_dacl.is_null() { + if !psd.is_null() { + LocalFree(psd as _); + } + return Err(format!( + "SetEntriesInAclW(revoke {name}) failed (error={rc})" + )); + } + let rc = SetNamedSecurityInfoW( + path_wide.as_mut_ptr(), + object_type, + DACL_SECURITY_INFORMATION, + null_mut(), + null_mut(), + new_dacl, + null_mut(), + ); + LocalFree(new_dacl as _); + if !psd.is_null() { + LocalFree(psd as _); + } + if rc != 0 { + return Err(format!( + "SetNamedSecurityInfoW(revoke {name}) failed (error={rc})" + )); + } + } + Ok(()) + } + + fn ensure_write_ace(path: &Path, sid: PSID) -> Result<(), String> { + ensure_named_write_ace( + SE_FILE_OBJECT, + &path.to_string_lossy(), + sid, + FILE_GENERIC_READ | FILE_GENERIC_WRITE | FILE_GENERIC_EXECUTE | DELETE_RIGHT, + ) + } + + fn remove_write_ace(path: &Path, sid: PSID) -> Result<(), String> { + remove_named_ace(SE_FILE_OBJECT, &path.to_string_lossy(), sid) + } + + #[cfg(test)] + pub(super) fn prepare_modify_only_low_il_probe(path: &Path) -> Result<(), String> { + let token = OwnedHandle(open_process_token()?); + let user = token_user_sid_bytes(token.0)?; + let user_sid = user.as_ptr() as PSID; + let mut ea: EXPLICIT_ACCESS_W = unsafe { std::mem::zeroed() }; + ea.grfAccessPermissions = + FILE_GENERIC_READ | FILE_GENERIC_WRITE | FILE_GENERIC_EXECUTE | DELETE_RIGHT; + ea.grfAccessMode = GRANT_ACCESS; + ea.grfInheritance = OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE; + ea.Trustee = TRUSTEE_W { + pMultipleTrustee: null_mut(), + MultipleTrusteeOperation: 0, + TrusteeForm: TRUSTEE_IS_SID, + TrusteeType: TRUSTEE_IS_UNKNOWN, + ptstrName: user_sid as *mut u16, + }; + + let mut dacl: *mut ACL = null_mut(); + let rc = unsafe { SetEntriesInAclW(1, &ea, null_mut(), &mut dacl) }; + if rc != 0 || dacl.is_null() { + return Err(format!( + "SetEntriesInAclW(modify-only probe) failed (error={rc})" + )); + } + let mut name_wide = to_wide(&path.to_string_lossy()); + let rc = unsafe { + SetNamedSecurityInfoW( + name_wide.as_mut_ptr(), + SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION, + null_mut(), + null_mut(), + dacl, + null_mut(), + ) + }; + unsafe { + LocalFree(dacl as _); + } + if rc != 0 { + return Err(format!( + "SetNamedSecurityInfoW(modify-only probe) failed (error={rc})" + )); + } + + let label_rc = set_low_integrity_label(SE_FILE_OBJECT, &path.to_string_lossy())?; + if label_rc != ERROR_ACCESS_DENIED { + return Err(format!( + "modify-only probe should reject LABEL_SECURITY_INFORMATION with error 5, \ + got {label_rc}" + )); + } + Ok(()) + } + + fn create_hkcu_key(subkey: &str) -> Result<(), String> { + let wide = to_wide(subkey); + let mut hkey: HKEY = null_mut(); + let mut disposition: u32 = 0; + let rc = unsafe { + RegCreateKeyExW( + HKEY_CURRENT_USER, + wide.as_ptr(), + 0, + null(), + 0, + KEY_READ | KEY_WRITE, + null(), + &mut hkey, + &mut disposition, + ) + }; + if rc != 0 { + return Err(format!("RegCreateKeyExW({subkey}) failed (error={rc})")); + } + unsafe { + let _ = RegCloseKey(hkey); + } + Ok(()) + } + + fn ensure_plain_directory(path: &Path) -> Result<(), String> { + use std::os::windows::fs::MetadataExt; + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400; + std::fs::create_dir_all(path) + .map_err(|err| format!("create dir {path:?} failed: {err}"))?; + let meta = std::fs::symlink_metadata(path) + .map_err(|err| format!("stat {path:?} failed: {err}"))?; + if meta.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return Err(format!("{path:?} is a reparse point; refusing to stamp")); + } + Ok(()) + } + + /// 给 CAPI/CNG 用户证书库与密钥容器盖 fence_sid 写 ACE。 + /// + /// 启动器此时仍持完整用户令牌,盖章发生在 `CreateProcessAsUserW` 之前。 + /// 单项失败只告警:探测层会把仍然崩溃的 pwsh(0xE0434352)跳过,落到 cmd, + /// 不因证书库策略把整个沙箱判死。绝不 stamp HKLM(需管理员)。 + fn ensure_cng_user_write_surface(sid: PSID) { + use crate::runtime::sandbox::{ + cng_named_registry_object, cng_user_file_dirs, CNG_USER_REGISTRY_SUBKEYS, + }; + for subkey in CNG_USER_REGISTRY_SUBKEYS { + if let Err(err) = create_hkcu_key(subkey) { + sandbox_diag(format!( + "liveagent sandbox: CNG registry create skipped ({subkey}): {err}" + )); + continue; + } + let name = cng_named_registry_object(subkey); + if let Err(err) = ensure_named_write_ace(SE_REGISTRY_KEY, &name, sid, KEY_ALL_ACCESS) { + sandbox_diag(format!( + "liveagent sandbox: CNG registry ACE skipped ({name}): {err}" + )); + } + } + let (Some(appdata), Some(local)) = (dirs::data_dir(), dirs::data_local_dir()) else { + return; + }; + for dir in cng_user_file_dirs(&appdata, &local) { + if let Err(err) = ensure_plain_directory(&dir).and_then(|_| ensure_write_ace(&dir, sid)) + { + sandbox_diag(format!( + "liveagent sandbox: CNG dir ACE skipped ({dir:?}): {err}" + )); + } + } + } + + /// 给 .NET Framework / Windows PowerShell 的用户运行时缓存盖 fence_sid 写 ACE。 + /// + /// 与 CNG 证书库是独立失败面:这里被拒时 powershell.exe 以 `0x80070005` 崩, + /// 不是 `0xE0434352`。单项失败只告警,探测层会把仍然崩溃的 powershell 跳过。 + /// `assembly`(Fusion)只盖目录本身——树可能很大,新文件靠(OI)(CI)继承; + /// CLR_v4.0 / PowerShell 缓存较小,首次盖章时向下传播到已有文件,否则 + /// `UsageLogs\powershell.exe.log` 等既有文件仍无限制性 SID,写依旧被拒。 + fn ensure_clr_user_write_surface(sid: PSID) { + use crate::runtime::sandbox::{ + clr_user_file_dirs, cng_named_registry_object, CLR_USER_REGISTRY_SUBKEYS, + }; + for subkey in CLR_USER_REGISTRY_SUBKEYS { + if let Err(err) = create_hkcu_key(subkey) { + sandbox_diag(format!( + "liveagent sandbox: CLR registry create skipped ({subkey}): {err}" + )); + continue; + } + let name = cng_named_registry_object(subkey); + if let Err(err) = ensure_named_write_ace(SE_REGISTRY_KEY, &name, sid, KEY_ALL_ACCESS) { + sandbox_diag(format!( + "liveagent sandbox: CLR registry ACE skipped ({name}): {err}" + )); + } + } + let (Some(appdata), Some(local)) = (dirs::data_dir(), dirs::data_local_dir()) else { + return; + }; + for dir in clr_user_file_dirs(&appdata, &local) { + let stamp = if dir.file_name().is_some_and(|name| name == "assembly") { + ensure_plain_directory(&dir).and_then(|_| ensure_write_ace(&dir, sid)) + } else { + ensure_plain_directory(&dir).and_then(|_| ensure_write_ace_tree(&dir, sid)) + }; + if let Err(err) = stamp { + sandbox_diag(format!( + "liveagent sandbox: CLR dir ACE skipped ({dir:?}): {err}" + )); + } + } + } + + /// v1.3 早期版本曾把具体 AppContainer profile SID 写进这些持久对象。 + /// 该 ACE 会让普通 Low-IL 联网进程无法访问同一对象;迁移时只撤销本工作区 + /// 的确定性 profile SID,其他用户/应用/工作区 ACE 均保持不变。 + fn remove_legacy_appcontainer_runtime_surface(sid: PSID) { + use crate::runtime::sandbox::{ + clr_user_file_dirs, cng_named_registry_object, cng_user_file_dirs, + CLR_USER_REGISTRY_SUBKEYS, CNG_USER_REGISTRY_SUBKEYS, + }; + for subkey in CNG_USER_REGISTRY_SUBKEYS + .iter() + .chain(CLR_USER_REGISTRY_SUBKEYS) + { + if create_hkcu_key(subkey).is_err() { + continue; + } + let name = cng_named_registry_object(subkey); + if let Err(err) = remove_named_ace(SE_REGISTRY_KEY, &name, sid) { + sandbox_diag(format!( + "liveagent sandbox: legacy AppContainer registry ACE cleanup skipped \ + ({name}): {err}" + )); + } + } + let (Some(appdata), Some(local)) = (dirs::data_dir(), dirs::data_local_dir()) else { + return; + }; + for dir in cng_user_file_dirs(&appdata, &local) + .into_iter() + .chain(clr_user_file_dirs(&appdata, &local)) + { + if !dir.exists() { + continue; + } + if let Err(err) = remove_write_ace(&dir, sid) { + sandbox_diag(format!( + "liveagent sandbox: legacy AppContainer runtime ACE cleanup skipped \ + ({dir:?}): {err}" + )); + } + } + } + + /// 首次给目录盖写 ACE 后,把同一 ACE 推到已有子对象(最多 5 层)。 + /// 目录上已有 fence SID 时仍扫子对象:历史盖章可能只盖了目录本身,UsageLogs + /// 里既有文件仍无限制性 SID,整棵跳过就会让 powershell 继续 0x80070005。 + fn ensure_write_ace_tree(path: &Path, sid: PSID) -> Result<(), String> { + ensure_write_ace(path, sid)?; + for entry in walkdir::WalkDir::new(path) + .max_depth(5) + .into_iter() + .flatten() + { + if entry.path() == path { + continue; + } + if let Err(err) = ensure_write_ace(entry.path(), sid) { + sandbox_diag(format!( + "liveagent sandbox: CLR child ACE skipped ({:?}): {err}", + entry.path() + )); + } + } + Ok(()) + } + + #[repr(C)] + struct UnicodeString { + length: u16, + maximum_length: u16, + buffer: *const u16, + } + + #[repr(C)] + struct ObjectAttributes { + length: u32, + root_directory: HANDLE, + object_name: *const UnicodeString, + attributes: u32, + security_descriptor: *mut c_void, + security_qos: *mut c_void, + } + + #[repr(C)] + struct ObjectDirectoryInformation { + name: UnicodeString, + type_name: UnicodeString, + } + + type NtStatusFn3 = unsafe extern "system" fn(*mut HANDLE, u32, *const ObjectAttributes) -> i32; + type NtQueryDirectoryObjectFn = + unsafe extern "system" fn(HANDLE, *mut c_void, u32, u8, u8, *mut u32, *mut u32) -> i32; + + fn ntdll_proc(name: &[u8]) -> Option<*const c_void> { + unsafe { + let ntdll = GetModuleHandleW(to_wide("ntdll.dll").as_ptr()); + if ntdll.is_null() { + return None; + } + GetProcAddress(ntdll, name.as_ptr()).map(|proc| proc as *const c_void) + } + } + + fn with_object_attributes( + nt_path: &str, + attributes: u32, + security_descriptor: *mut c_void, + body: impl FnOnce(&ObjectAttributes) -> R, + ) -> R { + let wide = to_wide(nt_path); + let us = UnicodeString { + length: ((wide.len() - 1) * 2) as u16, + maximum_length: (wide.len() * 2) as u16, + buffer: wide.as_ptr(), + }; + let oa = ObjectAttributes { + length: std::mem::size_of::() as u32, + root_directory: null_mut(), + object_name: &us, + attributes, + security_descriptor, + security_qos: null_mut(), + }; + body(&oa) + } + + fn nt_open_by_name( + fn_name: &[u8], + nt_path: &str, + access: u32, + extra_attr: u32, + ) -> Result { + let label = String::from_utf8_lossy(&fn_name[..fn_name.len().saturating_sub(1)]); + let proc = ntdll_proc(fn_name).ok_or_else(|| format!("{label} unavailable"))?; + let open: NtStatusFn3 = unsafe { std::mem::transmute(proc) }; + with_object_attributes( + nt_path, + OBJ_CASE_INSENSITIVE | extra_attr, + null_mut(), + |oa| { + let mut handle = null_mut(); + let status = unsafe { open(&mut handle, access, oa) }; + if status < 0 || handle.is_null() { + Err(format!("{label}({nt_path}) ntstatus={status:#010X}")) + } else { + Ok(OwnedHandle(handle)) + } + }, + ) + } + + fn open_directory_object(nt_path: &str, access: u32) -> Result { + nt_open_by_name(b"NtOpenDirectoryObject\0", nt_path, access, 0) + } + + fn create_directory_object( + nt_path: &str, + access: u32, + sd: *mut c_void, + ) -> Result { + let proc = ntdll_proc(b"NtCreateDirectoryObject\0") + .ok_or_else(|| "NtCreateDirectoryObject unavailable".to_string())?; + let create: NtStatusFn3 = unsafe { std::mem::transmute(proc) }; + with_object_attributes(nt_path, OBJ_CASE_INSENSITIVE | OBJ_OPENIF, sd, |oa| { + let mut handle = null_mut(); + let status = unsafe { create(&mut handle, access, oa) }; + if status < 0 || handle.is_null() { + Err(format!( + "NtCreateDirectoryObject({nt_path}) ntstatus={status:#010X}" + )) + } else { + Ok(OwnedHandle(handle)) + } + }) + } + + fn sid_string(sid: PSID) -> Result { + unsafe { + let mut s: *mut u16 = null_mut(); + if !ok(ConvertSidToStringSidW(sid, &mut s)) || s.is_null() { + return Err(last_error("ConvertSidToStringSidW")); + } + let mut len = 0usize; + while *s.add(len) != 0 { + len += 1; + } + let out = String::from_utf16_lossy(std::slice::from_raw_parts(s, len)); + LocalFree(s as _); + Ok(out) + } + } + + fn namespace_security_descriptor(sids: &[PSID]) -> Result<*mut c_void, String> { + let mut sddl = String::from("D:(A;OICI;GA;;;SY)(A;OICI;GA;;;BA)(A;OICI;GA;;;WD)"); + for &sid in sids { + match sid_string(sid) { + Ok(s) => sddl.push_str(&format!("(A;OICI;GA;;;{s})")), + Err(err) => sandbox_diag(format!("liveagent sandbox: SID to SDDL skipped: {err}")), + } + } + let wide = to_wide(&sddl); + let mut sd: *mut c_void = null_mut(); + let r = unsafe { + ConvertStringSecurityDescriptorToSecurityDescriptorW( + wide.as_ptr(), + 1, + &mut sd, + null_mut(), + ) + }; + if !ok(r) || sd.is_null() { + return Err(last_error( + "ConvertStringSecurityDescriptorToSecurityDescriptorW", + )); + } + Ok(sd) + } + + fn rtl_upcase_wchar(ch: u16) -> u16 { + ntdll_proc(b"RtlUpcaseUnicodeChar\0") + .map(|proc| { + type RtlUpcaseUnicodeCharFn = unsafe extern "system" fn(u16) -> u16; + let f: RtlUpcaseUnicodeCharFn = unsafe { std::mem::transmute(proc) }; + unsafe { f(ch) } + }) + .unwrap_or_else(|| { + if (b'a' as u16..=b'z' as u16).contains(&ch) { + ch - 32 + } else { + ch + } + }) + } + + /// cygwin `hash_path_name`: `hash = RtlUpcase(c) + (hash<<6) + (hash<<16) - hash` + /// (`ino_t` = u64).安装 key 是该哈希的 16 位小写十六进制。 + fn hash_path_name(nt_path: &str) -> u64 { + let mut hash: u64 = 0; + for ch in nt_path.encode_utf16() { + let u = rtl_upcase_wchar(ch) as u64; + hash = u + .wrapping_add(hash.wrapping_shl(6)) + .wrapping_add(hash.wrapping_shl(16)) + .wrapping_sub(hash); + } + hash + } + + fn nt_path_for_msys_hash(dll: &Path) -> Option { + let lossy = dll.to_string_lossy(); + let prefixed = if lossy.starts_with(r"\\?\") { + lossy.into_owned() + } else if lossy.starts_with(r"\\") { + format!(r"\\?\UNC\{}", lossy.trim_start_matches(r"\\")) + } else { + format!(r"\\?\{lossy}") + }; + let mut chars: Vec = prefixed.encode_utf16().collect(); + if chars.len() >= 2 { + chars[1] = b'?' as u16; + } + Some(String::from_utf16_lossy(&chars)) + } + + fn msys_runtime_dll(program: &Path) -> Option { + let parent = program.parent()?; + let candidates = [ + parent.join("msys-2.0.dll"), + parent + .join("..") + .join("usr") + .join("bin") + .join("msys-2.0.dll"), + parent.join("cygwin1.dll"), + ]; + for candidate in candidates { + if candidate.is_file() { + return Some(candidate.canonicalize().unwrap_or(candidate)); + } + } + None + } + + fn msys_object_dir_names(program: &Path) -> Vec { + let Some(dll) = msys_runtime_dll(program) else { + return Vec::new(); + }; + let Some(nt) = nt_path_for_msys_hash(&dll) else { + return Vec::new(); + }; + let key = format!("{:016x}", hash_path_name(&nt)); + sandbox_diag(format!( + "liveagent sandbox: msys install key {key} from {nt}" + )); + let prefix = if dll + .file_name() + .is_some_and(|name| name.eq_ignore_ascii_case("cygwin1.dll")) + { + "cygwin1S5" + } else { + "msys-2.0S5" + }; + let mut names = vec![format!(r"\BaseNamedObjects\{prefix}-{key}")]; + if let Some(session) = current_session_id() { + names.push(format!(r"\Sessions\BNOLINKS\{session}\{prefix}-{key}")); + } + names + } + + #[cfg(test)] + pub(super) fn msys_object_dir_names_for_test(program: &Path) -> Vec { + msys_object_dir_names(program) + } + + /// 真机钩子:盖章 msys 目录后,用 WRITE_RESTRICTED 令牌模拟打开 + /// (DesiredAccess = cygwin CYG_SHARED_DIR_ACCESS)。用来把 DACL 问题与 + /// bash DllMain 其它失败面分开。 + #[cfg(test)] + pub(super) fn restricted_token_can_open_msys_dir(program: &Path) -> Result<(), String> { + use windows_sys::Win32::Security::{ImpersonateLoggedOnUser, RevertToSelf}; + let names = msys_object_dir_names(program); + let Some(name) = names.first() else { + return Err("no msys object directory names".into()); + }; + let synthetic_str = crate::runtime::sandbox::synthetic_workspace_sid(Path::new( + "liveagent-sandbox-msys-probe", + )); + let synthetic = string_to_sid(&synthetic_str)?; + let wr = string_to_sid(WRITE_RESTRICTED_SID)?; + let token = OwnedHandle(open_process_token()?); + let logon = logon_sid_bytes(token.0)?; + let logon_ptr = logon.as_ptr() as PSID; + let restricting = [logon_ptr, wr.0, synthetic.0]; + let _held = ensure_named_directory_write_surface(name, &restricting) + .ok_or_else(|| format!("failed to create/hold {name}"))?; + let rt = OwnedHandle(create_restricted_token(token.0, &restricting)?); + append_sid_to_default_dacl(rt.0, logon_ptr)?; + if !ok(unsafe { ImpersonateLoggedOnUser(rt.0) }) { + return Err(last_error("ImpersonateLoggedOnUser")); + } + let access = DIRECTORY_QUERY + | DIRECTORY_TRAVERSE + | DIRECTORY_CREATE_OBJECT + | DIRECTORY_CREATE_SUBDIRECTORY + | READ_CONTROL; + let opened = nt_open_by_name(b"NtOpenDirectoryObject\0", name, access, 0); + unsafe { + RevertToSelf(); + } + opened.map(|_| ()) + } + + fn kernel_handle_has_ace(handle: HANDLE, sid: PSID) -> bool { + unsafe { + let mut dacl: *mut ACL = null_mut(); + let mut psd: *mut c_void = null_mut(); + let rc = GetSecurityInfo( + handle, + SE_KERNEL_OBJECT, + DACL_SECURITY_INFORMATION, + null_mut(), + null_mut(), + &mut dacl, + null_mut(), + &mut psd, + ); + if rc != 0 || dacl.is_null() { + if !psd.is_null() { + LocalFree(psd as _); + } + return false; + } + let mut info = ACL_SIZE_INFORMATION { + AceCount: 0, + AclBytesInUse: 0, + AclBytesFree: 0, + }; + let mut found = false; + if ok(GetAclInformation( + dacl, + &mut info as *mut _ as *mut c_void, + std::mem::size_of::() as u32, + ACL_SIZE_INFORMATION_CLASS, + )) { + for i in 0..info.AceCount { + let mut ace: *mut c_void = null_mut(); + if !ok(GetAce(dacl, i, &mut ace)) || ace.is_null() { + continue; + } + if (*(ace as *const ACE_HEADER)).AceType == ACCESS_ALLOWED_ACE_TYPE { + let allow = ace as *const ACCESS_ALLOWED_ACE; + let sid_ptr = &(*allow).SidStart as *const u32 as PSID; + if ok(EqualSid(sid_ptr, sid)) { + found = true; + break; + } + } + } + } + LocalFree(psd as _); + found + } + } + + fn ensure_kernel_handle_write_ace( + handle: HANDLE, + sid: PSID, + access: u32, + label: &str, + ) -> Result<(), String> { + if kernel_handle_has_ace(handle, sid) { + return Ok(()); + } + unsafe { + let mut old_dacl: *mut ACL = null_mut(); + let mut psd: *mut c_void = null_mut(); + let rc = GetSecurityInfo( + handle, + SE_KERNEL_OBJECT, + DACL_SECURITY_INFORMATION, + null_mut(), + null_mut(), + &mut old_dacl, + null_mut(), + &mut psd, + ); + if rc != 0 { + return Err(format!("GetSecurityInfo({label}) failed (error={rc})")); + } + if old_dacl.is_null() { + if !psd.is_null() { + LocalFree(psd as _); + } + return Ok(()); + } + let mut ea: EXPLICIT_ACCESS_W = std::mem::zeroed(); + ea.grfAccessPermissions = access; + ea.grfAccessMode = GRANT_ACCESS; + ea.grfInheritance = OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE; + ea.Trustee = TRUSTEE_W { + pMultipleTrustee: null_mut(), + MultipleTrusteeOperation: 0, + TrusteeForm: TRUSTEE_IS_SID, + TrusteeType: TRUSTEE_IS_UNKNOWN, + ptstrName: sid as *mut u16, + }; + let mut new_dacl: *mut ACL = null_mut(); + let rc = SetEntriesInAclW(1, &ea, old_dacl, &mut new_dacl); + if rc != 0 || new_dacl.is_null() { + if !psd.is_null() { + LocalFree(psd as _); + } + return Err(format!("SetEntriesInAclW({label}) failed (error={rc})")); + } + let rc = SetSecurityInfo( + handle, + SE_KERNEL_OBJECT, + DACL_SECURITY_INFORMATION, + null_mut(), + null_mut(), + new_dacl, + null_mut(), + ); + LocalFree(new_dacl as _); + if !psd.is_null() { + LocalFree(psd as _); + } + if rc != 0 { + return Err(format!("SetSecurityInfo({label}) failed (error={rc})")); + } + } + Ok(()) + } + + fn stamp_kernel_handle(handle: HANDLE, sids: &[PSID], access: u32, label: &str) { + for (i, &sid) in sids.iter().enumerate() { + if let Err(err) = ensure_kernel_handle_write_ace(handle, sid, access, label) { + sandbox_diag(format!( + "liveagent sandbox: kernel ACE skipped ({label}#{i}): {err}" + )); + } + } + } + + fn nt_set_dacl(handle: HANDLE, sd: *mut c_void, label: &str) { + let Some(proc) = ntdll_proc(b"NtSetSecurityObject\0") else { + sandbox_diag("liveagent sandbox: NtSetSecurityObject unavailable"); + return; + }; + type NtSetSecurityObjectFn = unsafe extern "system" fn(HANDLE, u32, *mut c_void) -> i32; + let set: NtSetSecurityObjectFn = unsafe { std::mem::transmute(proc) }; + let status = unsafe { set(handle, DACL_SECURITY_INFORMATION, sd) }; + if status < 0 { + sandbox_diag(format!( + "liveagent sandbox: NtSetSecurityObject({label}) ntstatus={status:#010X}" + )); + } + } + + fn stamp_directory_object(nt_path: &str, sids: &[PSID]) { + // 标准用户对 `\BaseNamedObjects` 没有 DIRECTORY_ALL_ACCESS;对自己创建的 + // msys 子目录则有 WRITE_DAC。按“查询+写 DACL”打开,失败只告警。 + let access = DIRECTORY_QUERY | DIRECTORY_TRAVERSE | READ_CONTROL | WRITE_DAC; + match open_directory_object(nt_path, access) { + Ok(dir) => stamp_kernel_handle( + dir.0, + sids, + DIRECTORY_ALL_ACCESS | GENERIC_ALL | WRITE_DAC, + nt_path, + ), + Err(err) => sandbox_diag(format!("liveagent sandbox: open {nt_path} skipped: {err}")), + } + } + + fn ensure_named_directory_write_surface(nt_path: &str, sids: &[PSID]) -> Option { + let sd = match namespace_security_descriptor(sids) { + Ok(sd) => sd, + Err(err) => { + sandbox_diag(format!( + "liveagent sandbox: namespace SD skipped ({nt_path}): {err}" + )); + stamp_directory_object(nt_path, sids); + stamp_directory_children(nt_path, sids, false); + return None; + } + }; + let access = DIRECTORY_ALL_ACCESS + | DIRECTORY_CREATE_OBJECT + | DIRECTORY_CREATE_SUBDIRECTORY + | READ_CONTROL + | WRITE_DAC; + let handle = match create_directory_object(nt_path, access, sd) { + Ok(dir) => Some(dir), + Err(err) => { + sandbox_diag(format!( + "liveagent sandbox: create {nt_path} skipped: {err}" + )); + open_directory_object(nt_path, access).ok() + } + }; + if let Some(dir) = handle.as_ref() { + // OBJ_OPENIF 命中已有对象时创建时的 SD 不会覆盖 everyone_sd; + // 必须再 NtSetSecurityObject 把限制性 SID 写进 DACL。 + nt_set_dacl(dir.0, sd, nt_path); + set_handle_low_integrity(dir.0, nt_path); + stamp_kernel_handle( + dir.0, + sids, + DIRECTORY_ALL_ACCESS | GENERIC_ALL | WRITE_DAC, + nt_path, + ); + } + unsafe { + LocalFree(sd as _); + } + stamp_directory_children(nt_path, sids, false); + handle + } + + #[repr(C)] + struct IoStatusBlock { + status: isize, + information: usize, + } + + fn open_file_object(nt_path: &str, access: u32) -> Result { + let proc = + ntdll_proc(b"NtOpenFile\0").ok_or_else(|| "NtOpenFile unavailable".to_string())?; + type NtOpenFileFn = unsafe extern "system" fn( + *mut HANDLE, + u32, + *const ObjectAttributes, + *mut IoStatusBlock, + u32, + u32, + ) -> i32; + let open: NtOpenFileFn = unsafe { std::mem::transmute(proc) }; + const FILE_SHARE_ALL: u32 = 0x7; + with_object_attributes(nt_path, OBJ_CASE_INSENSITIVE, null_mut(), |oa| { + let mut handle = null_mut(); + let mut iosb = IoStatusBlock { + status: 0, + information: 0, + }; + let status = unsafe { open(&mut handle, access, oa, &mut iosb, FILE_SHARE_ALL, 0) }; + if status < 0 || handle.is_null() { + Err(format!("NtOpenFile({nt_path}) ntstatus={status:#010X}")) + } else { + Ok(OwnedHandle(handle)) + } + }) + } + + fn stamp_nt_path_dacl(nt_path: &str, sids: &[PSID]) { + let access = READ_CONTROL | WRITE_DAC; + let handle = + open_directory_object(nt_path, access).or_else(|_| open_file_object(nt_path, access)); + match handle { + Ok(h) => stamp_kernel_handle( + h.0, + sids, + DIRECTORY_ALL_ACCESS | GENERIC_ALL | WRITE_DAC, + nt_path, + ), + Err(err) => sandbox_diag(format!("liveagent sandbox: stamp {nt_path} skipped: {err}")), + } + } + + fn utf16_to_string(us: &UnicodeString) -> String { + if us.buffer.is_null() || us.length == 0 { + return String::new(); + } + let n = (us.length as usize) / 2; + unsafe { String::from_utf16_lossy(std::slice::from_raw_parts(us.buffer, n)) } + } + + fn stamp_directory_children(nt_dir: &str, sids: &[PSID], only_msys: bool) { + let Ok(dir) = open_directory_object(nt_dir, DIRECTORY_QUERY | DIRECTORY_TRAVERSE) else { + return; + }; + let Some(query_ptr) = ntdll_proc(b"NtQueryDirectoryObject\0") else { + return; + }; + let query: NtQueryDirectoryObjectFn = unsafe { std::mem::transmute(query_ptr) }; + let mut buf = vec![0u8; 4096]; + let mut context: u32 = 0; + let mut restart = 1u8; + let mut children: Vec<(String, String)> = Vec::new(); + loop { + let mut ret_len: u32 = 0; + let status = unsafe { + query( + dir.0, + buf.as_mut_ptr() as *mut c_void, + buf.len() as u32, + 1, // ReturnSingleEntry:避免自己解析 packed 目录项 + restart, + &mut context, + &mut ret_len, + ) + }; + restart = 0; + const STATUS_NO_MORE_ENTRIES: u32 = 0x8000_001A; + if status as u32 == STATUS_NO_MORE_ENTRIES { + break; + } + if status < 0 { + break; + } + if buf.len() < std::mem::size_of::() { + break; + } + let info = unsafe { &*(buf.as_ptr() as *const ObjectDirectoryInformation) }; + let name = utf16_to_string(&info.name); + let ty = utf16_to_string(&info.type_name); + if name.is_empty() { + continue; + } + if only_msys { + let lower = name.to_ascii_lowercase(); + if !(lower.starts_with("msys-") || lower.starts_with("cygwin")) { + continue; + } + } + children.push((name, ty)); + } + drop(dir); + for (name, ty) in children { + let child = format!("{nt_dir}\\{name}"); + let open_fn: &[u8] = match ty.as_str() { + "Directory" => b"NtOpenDirectoryObject\0", + "Section" => b"NtOpenSection\0", + "Event" => b"NtOpenEvent\0", + "Mutant" => b"NtOpenMutant\0", + "Semaphore" => b"NtOpenSemaphore\0", + "Timer" => b"NtOpenTimer\0", + _ => b"", + }; + if !open_fn.is_empty() { + if let Ok(h) = nt_open_by_name(open_fn, &child, READ_CONTROL | WRITE_DAC, 0) { + stamp_kernel_handle(h.0, sids, GENERIC_ALL | WRITE_DAC, &child); + } + } + if ty == "Directory" { + // msys 目录内部的 section/event 全部盖章(这些才是 DllMain 要重开的对象)。 + stamp_directory_children(&child, sids, false); + } + } + } + + fn current_session_id() -> Option { + unsafe { + let k32 = GetModuleHandleW(to_wide("kernel32.dll").as_ptr()); + if k32.is_null() { + return None; + } + let proc = GetProcAddress(k32, b"ProcessIdToSessionId\0".as_ptr())?; + type ProcessIdToSessionIdFn = unsafe extern "system" fn(u32, *mut u32) -> i32; + let f: ProcessIdToSessionIdFn = std::mem::transmute(proc); + let mut id = 0u32; + if f(GetCurrentProcessId(), &mut id) == 0 { + return None; + } + Some(id) + } + } + + fn looks_like_msys_bash(program: &Path) -> bool { + let name = program.file_name().and_then(|n| n.to_str()).unwrap_or(""); + if !name.eq_ignore_ascii_case("bash.exe") { + return false; + } + let Some(parent) = program.parent() else { + return false; + }; + parent.join("msys-2.0.dll").is_file() + || parent + .join("..") + .join("usr") + .join("bin") + .join("msys-2.0.dll") + .is_file() + } + + /// 对象目录默认不是 permanent:最后句柄关闭就会从命名空间消失。 + /// 启动器必须把返回的句柄活到沙箱子进程退出,子进程的 OBJ_OPENIF 才能命中 + /// 已有目录,而不去 `\BaseNamedObjects` 上做第二遍写检查。 + fn ensure_object_namespace_write_surface( + sids: &[PSID], + program: &Path, + isolation_prefix: &str, + ) -> Vec { + let mut held = Vec::new(); + if let Some(h) = ensure_named_directory_write_surface( + &format!(r"\BaseNamedObjects\{isolation_prefix}"), + sids, + ) { + held.push(h); + } + if looks_like_msys_bash(program) { + for name in msys_object_dir_names(program) { + if let Some(h) = ensure_named_directory_write_surface(&name, sids) { + held.push(h); + } + } + } + // Git Bash 信号管线是 `\\.\pipe\msys---sigwait`,创建时要过 + // `\Device\NamedPipe` 的第二遍写检查。盖不上只告警。 + stamp_nt_path_dacl(r"\Device\NamedPipe", sids); + held + } + + /// 创建并盖章一个受围栏的临时目录(系统 temp 下,按工作区确定性命名),把 + /// TEMP/TMP/TMPDIR 指向它——否则沙箱进程写默认 %TEMP% 会被限制性判定拒绝。 + fn setup_fenced_temp( + write_root: &Path, + sid: PSID, + legacy_appcontainer_sid: PSID, + dir_key: &str, + extra_sids: &[PSID], + ) -> Result { + let base = std::env::temp_dir().join(format!("liveagent-sandbox-{dir_key}")); + // 路径确定性且可预测 ⇒ 另一同用户进程可能抢先把它建成 junction/symlink 指向敏感 + // 目录,使授权写 ACE 盖到目标、TEMP 重定向落进目标。拒绝 reparse point 以堵此路 + //(残留 TOCTOU:盖章/使用之间的替换需另一恶意同用户进程,严重度低)。 + ensure_plain_directory(&base)?; + remove_write_ace(&base, legacy_appcontainer_sid)?; + ensure_write_ace(&base, sid)?; + for (i, &extra) in extra_sids.iter().enumerate() { + if let Err(err) = ensure_write_ace(&base, extra) { + sandbox_diag(format!( + "liveagent sandbox: TEMP extra ACE skipped (#{i}): {err}" + )); + } + } + let _ = write_root; // 保留签名清晰度;temp 独立于工作区。 + let base_wide = to_wide(&base.to_string_lossy()); + for name in ["TEMP", "TMP", "TMPDIR"] { + let name_wide = to_wide(name); + unsafe { + if !ok(SetEnvironmentVariableW( + name_wide.as_ptr(), + base_wide.as_ptr(), + )) { + return Err(last_error(&format!("SetEnvironmentVariableW({name})"))); + } + } + } + Ok(base) + } + + /// 令三个标准句柄可继承,并作为 STARTF_USESTDHANDLES 传给子进程(stdin=NUL、 + /// stdout/stderr=父层管道,均由 shell_runner 建好后经继承落到本启动器)。 + fn inheritable_std_handles() -> Result<(HANDLE, HANDLE, HANDLE), String> { + // GetStdHandle 在句柄缺失时返回 INVALID_HANDLE_VALUE(-1)而非 null;两者都跳过。 + let invalid: HANDLE = usize::MAX as HANDLE; + unsafe { + let stdin = GetStdHandle(STD_INPUT_HANDLE); + let stdout = GetStdHandle(STD_OUTPUT_HANDLE); + let stderr = GetStdHandle(STD_ERROR_HANDLE); + for h in [stdin, stdout, stderr] { + if !h.is_null() && h != invalid { + // 失败不致命:句柄可能本就可继承;继续尝试。 + SetHandleInformation(h, HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT); + } + } + Ok((stdin, stdout, stderr)) + } + } + + /// 运行时探测:联网后端(Low IL 主令牌副本)能否真的建起来。 + /// + /// 走与 `execute` 相同的令牌核心序列(打开主令牌 → DuplicateTokenEx → 降 Low IL), + /// 只是不启动进程、不修改任何文件系统标签。 + /// 组策略、EDR hook、受限 SKU 会让其中任一步在真机失败;探测把失败提前反映到 + /// `capability().supported`,从而让 `wrap_command` 的 fail-closed 守卫在 Windows + /// 上真正可达(此前硬编码 `supported: true`,该守卫恒不触发)。 + /// 令牌句柄经 `OwnedHandle` 立即释放,无系统级副作用。 + pub(super) fn probe_networked_token() -> Result<(), String> { + let token = OwnedHandle(open_process_token()?); + let networked = OwnedHandle(duplicate_primary_token(token.0)?); + set_token_low_integrity(networked.0)?; + Ok(()) + } + + /// 运行时探测:断网后端能否派生 AppContainer profile SID 与私有 capability + /// SID。均为纯派生,不创建 profile、无系统副作用;任一步失败都不能宣称具备 + /// 断网沙箱能力。 + pub(super) fn probe_appcontainer() -> Result<(), String> { + let name = appcontainer_profile_name("probe"); + let name_w = to_wide(&name); + let mut sid: PSID = null_mut(); + let derived = + unsafe { DeriveAppContainerSidFromAppContainerName(name_w.as_ptr(), &mut sid) }; + if derived != 0 || sid.is_null() { + return Err(format!( + "DeriveAppContainerSidFromAppContainerName hr={derived:#010X}" + )); + } + let _sid = AcSid(sid); // RAII:FreeSid + let _capability = workspace_capability_sid("probe")?; + Ok(()) + } + + pub(super) fn execute( + write_root: &Path, + allow_network: bool, + isolated: bool, + program: &Path, + args: &[String], + ) -> Result { + use crate::runtime::sandbox::{ + build_command_line, is_msix_windowsapps_path, resolve_program_in_path, + synthetic_workspace_sid, validate_workspace, + }; + + // P3#8:启动器是独立进程,不能依赖父进程侧 wrap_command 已做过校验——两个入口 + // 必须共用同一套前置条件,否则任一侧演进就会漂移出 fail-closed 不对称。 + // (幂等纯校验,重复执行无副作用。) + validate_workspace(write_root)?; + + let synthetic_str = synthetic_workspace_sid(write_root); + // temp 目录 / AC profile 名沿用合成 SID 的数值段,确定性且文件系统安全。 + let dir_key = synthetic_str + .trim_start_matches("S-1-5-21-") + .replace('-', "_"); + + // 先解析程序;绝不从模型可写的工作区按相对路径启动映像。 + let path_env = std::env::var("PATH").unwrap_or_default(); + let pathext = + std::env::var("PATHEXT").unwrap_or_else(|_| ".COM;.EXE;.BAT;.CMD".to_string()); + let resolved = resolve_program_in_path(program, &path_env, &pathext, &|p| p.is_file()) + .ok_or_else(|| { + format!( + "sandbox refuses to resolve program {program:?}: not found in any absolute \ + PATH directory (the workspace cwd is intentionally never searched)" + ) + })?; + if is_msix_windowsapps_path(&resolved) { + return Err(format!( + "sandbox refuses Microsoft Store / MSIX program {resolved:?}: WindowsApps \ + binaries cannot be started through the sandbox security contexts \ + (CreateProcessAsUserW returns 5). Install PowerShell via MSI or use \ + powershell.exe / cmd.exe" + )); + } + // --- 后端安全上下文 --- + // fence_sid = 文件写 ACE 的受托 SID(联网=当前用户 SID,断网=工作区私有 + // capability SID)。profile/capability/user/token 的自持内存全部声明在外层, + // 确保其 PSID 指针活到 CreateProcess* 返回之后。 + let appcontainer_sid = if allow_network { + derive_appcontainer_profile_sid(&dir_key)? + } else { + appcontainer_profile_sid(&dir_key)? + }; + let workspace_capability = if allow_network { + None + } else { + Some(workspace_capability_sid(&dir_key)?) + }; + let network_token: Option; + let fence_sid: PSID; + let logon_sid: Option>; + let user_sid: Option>; + if allow_network { + let token = OwnedHandle(open_process_token()?); + let logon = logon_sid_bytes(token.0)?; + let logon_ptr = logon.as_ptr() as PSID; + let user = token_user_sid_bytes(token.0)?; + let user_ptr = user.as_ptr() as PSID; + let rt = OwnedHandle(duplicate_primary_token(token.0)?); + // 登录 SID 进入 default DACL,保证子进程自建的命名对象在同一登录会话可重开。 + append_sid_to_default_dacl(rt.0, logon_ptr)?; + // 联网模式不能使用 restricted token:SSPI/Schannel 会在 HTTPS 初始化时以 + // SEC_E_NO_CREDENTIALS 失败。复制当前用户主令牌并降到 Low IL,网络语义 + // 保持不变,NTFS 写围栏由 NoWriteUp + Low workspace/TEMP 强制执行。 + set_token_low_integrity(rt.0)?; + network_token = Some(rt); + fence_sid = user_ptr; + logon_sid = Some(logon); + user_sid = Some(user); + } else { + // 私有 capability 只授权本工作区文件面,不是网络 capability;WFP 仍 + // 对该 AppContainer 内核级全断网(含 loopback)。 + set_offline_env()?; + network_token = None; + fence_sid = workspace_capability + .as_ref() + .map(|sid| sid.as_ptr() as PSID) + .ok_or_else(|| "offline workspace capability SID is unavailable".to_string())?; + logon_sid = None; + user_sid = None; + } + + // --- 文件系统写围栏(受托人 = fence_sid) --- + // 迁移旧版直接授给 package SID 的 ACE。该 ACE 与普通 Low-IL 联网令牌 + // 不共存,必须先精确撤销,再授予用户 SID / 私有 capability SID。 + remove_write_ace(write_root, appcontainer_sid.0)?; + ensure_write_ace(write_root, fence_sid)?; + let extra_temp: Vec = logon_sid + .as_ref() + .map(|logon| logon.as_ptr() as PSID) + .into_iter() + .collect(); + let fenced_temp = setup_fenced_temp( + write_root, + fence_sid, + appcontainer_sid.0, + &dir_key, + &extra_temp, + )?; + if allow_network { + let launcher_user_sid = user_sid + .as_ref() + .map(|sid| sid.as_ptr() as PSID) + .ok_or_else(|| "sandbox launcher user SID is unavailable".to_string())?; + // 工作区根必须标上 Low,否则 Low 子进程连新建文件都会被 NoWriteUp 拒绝。 + ensure_low_integrity_label( + SE_FILE_OBJECT, + &write_root.to_string_lossy(), + launcher_user_sid, + )?; + ensure_runtime_low_integrity_surface( + write_root, + &fenced_temp, + &resolved, + launcher_user_sid, + ); + } + // CNG/CLR:PowerShell 启动会写用户证书库和运行时缓存;只给这组窄路径补访问面。 + remove_legacy_appcontainer_runtime_surface(appcontainer_sid.0); + let mut runtime_sids: Vec = vec![fence_sid]; + if let Some(ref logon) = logon_sid { + runtime_sids.push(logon.as_ptr() as PSID); + } + for &sid in &runtime_sids { + ensure_cng_user_write_surface(sid); + ensure_clr_user_write_surface(sid); + } + + // --- 标准句柄 + 命令行 --- + let (h_in, h_out, h_err) = inheritable_std_handles()?; + if allow_network { + // Low 子进程写 Medium 匿名管道会被 NoWriteUp 挡住;尽力把继承来的 + // stdout/stderr 也标成 Low。没有 WRITE_OWNER 时只告警,多数匿名管道 + // 实际仍可写(与 AppContainer 启动子进程重定向 stdout 同款路径)。 + set_handle_low_integrity(h_in, "stdin"); + set_handle_low_integrity(h_out, "stdout"); + set_handle_low_integrity(h_err, "stderr"); + } + + // NT 对象命名空间:须在 CreateProcess* 之前,且 SID 缓冲仍活着。 + let mut namespace_sids: Vec = Vec::with_capacity(4); + if let Some(ref logon) = logon_sid { + namespace_sids.push(logon.as_ptr() as PSID); + } + if let Some(ref user) = user_sid { + namespace_sids.push(user.as_ptr() as PSID); + } + namespace_sids.push(fence_sid); + let isolation_prefix = format!("LiveAgent.Sandbox.{dir_key}"); + let _held_namespace = + ensure_object_namespace_write_surface(&namespace_sids, &resolved, &isolation_prefix); + + let program_str = program.to_string_lossy(); // argv[0] 保留原始名(对齐非沙箱路径) + let app_wide = to_wide(&resolved.to_string_lossy()); // lpApplicationName = 解析出的绝对路径 + let mut cmdline = build_command_line(&program_str, args); // 已含结尾 NUL + + // --- STARTUPINFOEXW:显式桌面 + 白名单句柄继承(+ AC capabilities) --- + // Low IL token / AC 启动必须显式指定桌面:NULL 交由系统推断,在沙箱上下文下解析 + // 歧义甚至失败(Codex 同款修复)。 + let mut desktop = to_wide("winsta0\\default"); + + // 句柄白名单:去重 + 滤掉 NULL/INVALID(列表含无效或重复句柄会让 CreateProcess* + // 直接 ERROR_INVALID_PARAMETER)。取代旧 bInheritHandles=TRUE 的全句柄表继承, + // 收敛句柄泄漏面。 + let invalid: HANDLE = usize::MAX as HANDLE; + let mut handle_list: Vec = Vec::with_capacity(3); + for h in [h_in, h_out, h_err] { + if !h.is_null() && h != invalid && !handle_list.contains(&h) { + handle_list.push(h); + } + } + let inherit = !handle_list.is_empty(); + + // AC capabilities:声明须早于 attrs(局部量逆序析构 ⇒ attrs 先亡),满足 + // UpdateProcThreadAttribute 的 value 存活契约(见 AttrList 文档)。 + let mut capability_attrs: Vec = workspace_capability + .as_ref() + .map(|sid| SID_AND_ATTRIBUTES { + Sid: sid.as_ptr() as PSID, + Attributes: SE_GROUP_ENABLED, + }) + .into_iter() + .collect(); + let sec_caps = SECURITY_CAPABILITIES { + AppContainerSid: appcontainer_sid.0, + Capabilities: capability_attrs.as_mut_ptr(), + CapabilityCount: capability_attrs.len() as u32, + Reserved: 0, + }; + + // AppContainer 已有独立命名空间,Windows 不支持再叠加 BNO isolation + // (CreateProcessW error 50);联网 Low-IL 进程继续用 BNO 隔离命名对象。 + let attr_count = 2; + let mut bno_attr = ProcessBnoIsolationAttribute { + isolation_enabled: 1, + isolation_prefix: [0u16; 136], + }; + { + for (i, unit) in isolation_prefix.encode_utf16().take(135).enumerate() { + bno_attr.isolation_prefix[i] = unit; + } + } + let mut attrs = AttrList::new(attr_count)?; + if inherit { + attrs.set( + PROC_THREAD_ATTRIBUTE_HANDLE_LIST, + handle_list.as_ptr() as *const c_void, + handle_list.len() * std::mem::size_of::(), + "UpdateProcThreadAttribute(handle list)", + )?; + } + if !allow_network { + attrs.set( + PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES, + &sec_caps as *const _ as *const c_void, + std::mem::size_of::(), + "UpdateProcThreadAttribute(security capabilities)", + )?; + } + if allow_network { + attrs.set( + PROC_THREAD_ATTRIBUTE_BNO_ISOLATION, + &bno_attr as *const _ as *const c_void, + std::mem::size_of::(), + "UpdateProcThreadAttribute(bno isolation)", + )?; + } + + // --- 启动子进程(挂起态,便于先入 Job 再放行) --- + let result = unsafe { + let mut si: STARTUPINFOEXW = std::mem::zeroed(); + si.StartupInfo.cb = std::mem::size_of::() as u32; + si.StartupInfo.dwFlags = STARTF_USESTDHANDLES; + si.StartupInfo.lpDesktop = desktop.as_mut_ptr(); + si.StartupInfo.hStdInput = h_in; + si.StartupInfo.hStdOutput = h_out; + si.StartupInfo.hStdError = h_err; + si.lpAttributeList = attrs.ptr(); + + let flags = CREATE_NO_WINDOW | CREATE_SUSPENDED | EXTENDED_STARTUPINFO_PRESENT; + let mut pi: PROCESS_INFORMATION = std::mem::zeroed(); + let created = if let Some(rt) = &network_token { + CreateProcessAsUserW( + rt.0, + app_wide.as_ptr(), + cmdline.as_mut_ptr(), + null(), + null(), + i32::from(inherit), + flags, + null(), // lpEnvironment = NULL ⇒ 继承本启动器环境(含 temp 重定向) + null(), // lpCurrentDirectory = NULL ⇒ 继承本启动器 cwd(= 实际工作目录) + &si as *const _ as *const STARTUPINFOW, + &mut pi, + ) + } else { + // AC:普通 CreateProcessW,内核按 SECURITY_CAPABILITIES 生成 lowbox 令牌; + // 环境额外携带 set_offline_env 的断网叠加。 + CreateProcessW( + app_wide.as_ptr(), + cmdline.as_mut_ptr(), + null(), + null(), + i32::from(inherit), + flags, + null(), + null(), + &si as *const _ as *const STARTUPINFOW, + &mut pi, + ) + }; + if !ok(created) { + return Err(last_error(if network_token.is_some() { + "CreateProcessAsUserW(low-integrity token)" + } else { + "CreateProcessW(AppContainer)" + })); + } + + // Job Object(KILL_ON_JOB_CLOSE):启动器意外死亡时连带杀子进程,为 + // taskkill /T 之外的兜底。尽力而为,失败仅告警。isolated 常驻进程刻意 + // 不入 Job:它必须在启动器/LiveAgent 亡后继续存活(对齐 Linux bwrap + // 省略 --die-with-parent)。 + let job = if isolated { + null_mut() + } else { + CreateJobObjectW(null(), null()) + }; + if !job.is_null() { + let mut limits: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = std::mem::zeroed(); + limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + SetInformationJobObject( + job, + JOB_OBJECT_EXTENDED_LIMIT_INFO_CLASS, + &limits as *const _ as *const c_void, + std::mem::size_of::() as u32, + ); + if !ok(AssignProcessToJobObject(job, pi.hProcess)) { + sandbox_diag(format!( + "liveagent sandbox: {}", + last_error( + "AssignProcessToJobObject (continuing; taskkill /T still cascades)" + ) + )); + } + } + + ResumeThread(pi.hThread); + CloseHandle(pi.hThread); + + WaitForSingleObject(pi.hProcess, INFINITE); + let mut exit_code: u32 = 0; + let got = GetExitCodeProcess(pi.hProcess, &mut exit_code); + CloseHandle(pi.hProcess); + // job 句柄须保持打开直到子进程退出;此刻关闭即可(KILL_ON_JOB_CLOSE 无害)。 + if !job.is_null() { + CloseHandle(job); + } + if !ok(got) { + return Err(last_error("GetExitCodeProcess")); + } + // loader 早期死亡(0xC0000142 等)只体现为裸退出码;补一条可读诊断,经 + // stderr 走既有管道上传(shell_runner 的候选探测回退也依赖这个退出码)。 + if let Some(hint) = loader_failure_hint(exit_code) { + eprintln!("liveagent sandbox: process exited with {exit_code:#010X}: {hint}"); + } + exit_code as i32 + }; + Ok(result) + } +} + +// AC profile 名的确定性与硬约束校验只在 Windows 有意义(win 模块整体 cfg(windows)), +// 但公式本身平台无关——为了让 mac/Linux 的开发机与 CI 也能守住它,这里用一份独立的 +// 纯逻辑镜像测试(与 win::appcontainer_profile_name 的实现保持字面一致)。 +#[cfg(test)] +mod tests { + /// 镜像 `win::appcontainer_profile_name` + `execute` 里的 dir_key 推导: + /// AppContainer profile 名硬限制 64 字符,字符集须落在 [0-9A-Za-z._]。 + fn profile_name_for(synthetic_sid: &str) -> String { + let dir_key = synthetic_sid + .trim_start_matches("S-1-5-21-") + .replace('-', "_"); + format!("LiveAgent.Sandbox.{dir_key}") + } + + #[test] + fn appcontainer_profile_name_is_deterministic_and_within_limits() { + // 合成 SID 是 4 段 u32(Codex 形式 S-1-5-21-{4×u32}),取各段极值验证最坏长度。 + let worst = profile_name_for("S-1-5-21-4294967295-4294967295-4294967295-4294967295"); + assert_eq!( + worst, + "LiveAgent.Sandbox.4294967295_4294967295_4294967295_4294967295" + ); + assert!( + worst.len() <= 64, + "profile name exceeds AC 64-char limit: {}", + worst.len() + ); + assert!(worst + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_')); + // 同一 SID 恒得同一名(确定性 ⇒ profile 可跨次运行复用)。 + assert_eq!( + profile_name_for("S-1-5-21-1-2-3-4"), + profile_name_for("S-1-5-21-1-2-3-4") + ); + assert_eq!( + profile_name_for("S-1-5-21-1-2-3-4"), + "LiveAgent.Sandbox.1_2_3_4" + ); + } + + #[cfg(windows)] + mod win_only { + use super::super::win; + + static SANDBOX_EXEC_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + const SANDBOX_ENV_VARS: &[&str] = &[ + "TEMP", + "TMP", + "TMPDIR", + "HTTP_PROXY", + "HTTPS_PROXY", + "ALL_PROXY", + "NO_PROXY", + "CARGO_NET_OFFLINE", + "PIP_NO_INDEX", + "NPM_CONFIG_OFFLINE", + ]; + + struct SandboxTestGuard { + _lock: std::sync::MutexGuard<'static, ()>, + saved: Vec<(&'static str, Option)>, + } + + impl SandboxTestGuard { + fn acquire() -> Self { + let lock = SANDBOX_EXEC_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let saved = SANDBOX_ENV_VARS + .iter() + .map(|name| (*name, std::env::var_os(name))) + .collect(); + Self { _lock: lock, saved } + } + + fn restore_env(&self) { + for (name, value) in &self.saved { + if let Some(value) = value { + std::env::set_var(name, value); + } else { + std::env::remove_var(name); + } + } + } + } + + impl Drop for SandboxTestGuard { + fn drop(&mut self) { + self.restore_env(); + } + } + + // 真机(Windows)校验:实际 API 派生的 AC SID 确定性 —— 同名两次派生须相等。 + // 该测试不创建 profile(仅 Derive 纯计算),无系统副作用。 + #[test] + fn derive_appcontainer_sid_is_deterministic() { + let a = win::appcontainer_profile_sid_for_test("LiveAgent.Sandbox.test_1_2_3_4"); + let b = win::appcontainer_profile_sid_for_test("LiveAgent.Sandbox.test_1_2_3_4"); + assert!( + a.is_some(), + "DeriveAppContainerSidFromAppContainerName failed" + ); + assert_eq!(a, b); + // AC SID 固定以 S-1-15-2- 开头(APPLICATION PACKAGE AUTHORITY)。 + assert!(a.unwrap().starts_with("S-1-15-2-")); + } + + #[test] + fn derive_workspace_capability_sid_is_deterministic() { + let a = win::workspace_capability_sid_for_test("test_1_2_3_4") + .expect("derive workspace capability"); + let b = win::workspace_capability_sid_for_test("test_1_2_3_4") + .expect("derive workspace capability again"); + assert_eq!(a, b); + assert!( + a.starts_with("S-1-15-3-1024-"), + "custom capability SID has unexpected form: {a}" + ); + } + + // 真机(Windows)校验 0xC0000142 修复:append 后受限令牌的 default DACL 必须 + // 含登录 SID(修复的可断言后置条件;是否“原本就含”因环境而异,不作断言,仅 + // 打印供诊断)。只动测试自建的令牌副本,无系统副作用。 + #[test] + fn default_dacl_append_adds_logon_sid() { + let (before, after) = + win::default_dacl_fix_roundtrip_for_test().expect("roundtrip failed"); + println!("default DACL contained logon SID before append: {before}"); + assert!( + after, + "append_sid_to_default_dacl did not add the logon SID" + ); + } + + fn sandbox_exec(program: &std::path::Path, args: &[String]) -> Result { + let _guard = SandboxTestGuard::acquire(); + let dir = tempfile::tempdir().expect("workspace"); + win::execute(dir.path(), true, false, program, args) + } + + /// 真机:cmd 不走 CLR,联网 Low IL token 下必须能作为沙箱 shell 兜底。 + #[test] + fn networked_sandbox_cmd_exit_zero() { + let code = sandbox_exec( + std::path::Path::new("cmd.exe"), + &["/D".into(), "/C".into(), "exit 0".into()], + ) + .expect("networked sandbox cmd execute"); + assert_eq!( + code as u32, 0, + "cmd under networked sandbox exited {code:#010X}" + ); + } + + #[test] + fn networked_sandbox_token_is_not_restricted() { + win::probe_networked_token() + .expect("networked sandbox must preserve the user's full logon context"); + } + + #[test] + fn appcontainer_cmd_exit_zero() { + let _guard = SandboxTestGuard::acquire(); + let workspace = tempfile::tempdir().expect("workspace"); + let result = win::execute( + workspace.path(), + false, + false, + std::path::Path::new("cmd.exe"), + &["/D".into(), "/C".into(), "exit 0".into()], + ); + let code = result.expect("AppContainer cmd execute"); + assert_eq!( + code as u32, 0, + "cmd under AppContainer sandbox exited {code:#010X}" + ); + } + + #[test] + fn legacy_appcontainer_acl_migrates_across_sandbox_modes() { + let guard = SandboxTestGuard::acquire(); + let workspace = tempfile::tempdir().expect("workspace"); + let read_path = workspace.path().join("read.txt"); + let offline_path = workspace.path().join("offline.txt"); + let online_path = workspace.path().join("online.txt"); + let outside_path = unique_probe( + workspace.path().parent().expect("workspace parent"), + "offline-outside", + ); + let _ = std::fs::remove_file(&outside_path); + std::fs::write(&read_path, "read-ok").expect("seed readable file"); + + let synthetic = crate::runtime::sandbox::synthetic_workspace_sid(workspace.path()); + let dir_key = synthetic.trim_start_matches("S-1-5-21-").replace('-', "_"); + win::seed_legacy_appcontainer_ace_for_test(workspace.path(), &dir_key) + .expect("seed legacy package SID ACE"); + + let online_script = format!( + "dir /a /b {} >nul && type {} >nul && echo online>{}", + workspace.path().display(), + read_path.display(), + online_path.display(), + ); + let first_online = win::execute( + workspace.path(), + true, + false, + std::path::Path::new("cmd.exe"), + &["/D".into(), "/C".into(), online_script.clone()], + ) + .expect("online sandbox should migrate the legacy package SID ACE"); + assert_eq!(first_online as u32, 0); + assert!(online_path.is_file()); + guard.restore_env(); + + let offline_script = format!( + "echo outside>{} && exit /b 42 || echo offline>{}", + outside_path.display(), + offline_path.display(), + ); + let offline = win::execute( + workspace.path(), + false, + false, + std::path::Path::new("cmd.exe"), + &["/D".into(), "/C".into(), offline_script], + ); + assert_eq!(offline.expect("offline capability sandbox") as u32, 0); + assert!(offline_path.is_file()); + assert!( + !outside_path.exists(), + "offline capability sandbox wrote outside workspace: {outside_path:?}" + ); + guard.restore_env(); + + std::fs::remove_file(&online_path).expect("reset online probe"); + let second_online = win::execute( + workspace.path(), + true, + false, + std::path::Path::new("cmd.exe"), + &["/D".into(), "/C".into(), online_script], + ) + .expect("online sandbox after offline capability sandbox"); + assert_eq!(second_online as u32, 0); + assert!(online_path.is_file()); + let _ = std::fs::remove_file(outside_path); + } + + /// 真机:从 Git for Windows 的 msys-2.0.dll 算出的对象目录名必须是 + /// `\BaseNamedObjects\msys-2.0S5-` + 16 位 hex(与 cygwin hash_path_name 对齐)。 + #[test] + fn msys_object_dir_name_from_git_bash() { + let bash = std::path::Path::new(r"C:\Program Files\Git\bin\bash.exe"); + if !bash.is_file() { + return; + } + let names = win::msys_object_dir_names_for_test(bash); + println!("msys object dirs: {names:?}"); + assert!( + names + .iter() + .any(|n| n.starts_with(r"\BaseNamedObjects\msys-2.0S5-")), + "missing hashed msys BNO directory: {names:?}" + ); + let key = names[0].rsplit('-').next().expect("hash suffix"); + assert_eq!(key.len(), 16, "install key should be 16 hex chars: {key}"); + assert!( + key.chars().all(|c| c.is_ascii_hexdigit()), + "install key should be hex: {key}" + ); + } + + #[test] + fn restricted_token_can_open_stamped_msys_dir() { + let bash = std::path::Path::new(r"C:\Program Files\Git\bin\bash.exe"); + if !bash.is_file() { + return; + } + win::restricted_token_can_open_msys_dir(bash) + .expect("WRITE_RESTRICTED token should open stamped msys directory"); + } + + /// 真机:Git Bash 在联网 Low IL token 沙箱内必须能启动。 + #[test] + fn networked_sandbox_git_bash_exit_zero() { + let bash = std::path::Path::new(r"C:\Program Files\Git\bin\bash.exe"); + if !bash.is_file() { + return; + } + let code = sandbox_exec(bash, &["-c".into(), "exit 0".into()]) + .expect("networked sandbox Git Bash execute"); + assert_eq!( + code as u32, 0, + "Git Bash under networked sandbox exited {code:#010X}" + ); + } + + #[test] + fn networked_sandbox_git_bash_echo() { + let bash = std::path::Path::new(r"C:\Program Files\Git\bin\bash.exe"); + if !bash.is_file() { + return; + } + let code = sandbox_exec(bash, &["-c".into(), "echo sandbox-ok".into()]) + .expect("networked sandbox Git Bash echo"); + assert_eq!( + code as u32, 0, + "Git Bash echo under networked sandbox exited {code:#010X}" + ); + } + + #[test] + fn low_integrity_label_recovers_from_modify_only_owner_dacl() { + let bash = std::path::Path::new(r"C:\Program Files\Git\bin\bash.exe"); + if !bash.is_file() { + return; + } + let _guard = SandboxTestGuard::acquire(); + let workspace = tempfile::tempdir().expect("workspace"); + win::prepare_modify_only_low_il_probe(workspace.path()) + .expect("modify-only ACL should reproduce ERROR_ACCESS_DENIED"); + let code = win::execute( + workspace.path(), + true, + false, + bash, + &["-c".into(), "echo low-il-acl-recovered".into()], + ) + .expect("launcher should grant minimal WRITE_OWNER and retry"); + assert_eq!( + code as u32, 0, + "git bash under modify-only workspace ACL exited {code:#010X}" + ); + } + + /// 真机:Windows PowerShell 5.1 在联网 Low IL token 沙箱内必须能启动 + /// (CLR/CNG 用户面 + 会话 BNO / BNO isolation)。 + #[test] + fn networked_sandbox_powershell_exit_zero() { + let powershell = + std::path::Path::new(r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe"); + if !powershell.is_file() { + return; + } + let code = sandbox_exec( + powershell, + &[ + "-NoLogo".into(), + "-NoProfile".into(), + "-NonInteractive".into(), + "-ExecutionPolicy".into(), + "Bypass".into(), + "-Command".into(), + "exit 0".into(), + ], + ) + .expect("networked sandbox PowerShell execute"); + assert_eq!( + code as u32, 0, + "PowerShell under networked sandbox exited {code:#010X}" + ); + } + + fn posix_win_path(path: &std::path::Path) -> String { + let raw = path.to_string_lossy(); + let trimmed = raw.trim_start_matches(r"\\?\"); + let bytes = trimmed.as_bytes(); + if bytes.len() >= 2 && bytes[1] == b':' { + let drive = (bytes[0] as char).to_ascii_lowercase(); + let rest = trimmed[2..].replace('\\', "/"); + format!("/{drive}{rest}") + } else { + trimmed.replace('\\', "/") + } + } + + fn unique_probe(dir: &std::path::Path, name: &str) -> std::path::PathBuf { + dir.join(format!( + "liveagent-sandbox-fence-{name}-{}.txt", + std::process::id() + )) + } + + /// 真机:Git Bash 必须能写工作区,但不能写工作区父目录 / 用户 home / 盘符根。 + /// 回归的是 TokenUser 限制性 SID 把“用户可写”路径全部重开的洞。 + #[test] + fn networked_sandbox_git_bash_write_fence() { + let bash = std::path::Path::new(r"C:\Program Files\Git\bin\bash.exe"); + if !bash.is_file() { + return; + } + let _guard = SandboxTestGuard::acquire(); + let outer = tempfile::tempdir().expect("outer"); + let workspace = outer.path().join("workspace"); + let sibling = outer.path().join("sibling"); + std::fs::create_dir(&workspace).expect("workspace"); + std::fs::create_dir(&sibling).expect("sibling"); + + let inside = unique_probe(&workspace, "inside"); + let outside = unique_probe(&sibling, "outside"); + let home_dir = dirs::home_dir().expect("home"); + let home = unique_probe(&home_dir, "home"); + let drive_root = unique_probe(std::path::Path::new(r"D:\"), "drive"); + let _ = std::fs::remove_file(&home); + let _ = std::fs::remove_file(&drive_root); + + let script = format!( + "echo inside > '{inside}' || exit 1; \ + if echo outside > '{outside}'; then exit 42; fi; \ + if echo home > '{home}'; then exit 43; fi; \ + if echo drive > '{drive}'; then exit 44; fi; \ + exit 0", + inside = posix_win_path(&inside), + outside = posix_win_path(&outside), + home = posix_win_path(&home), + drive = posix_win_path(&drive_root), + ); + let code = win::execute(&workspace, true, false, bash, &["-c".into(), script]) + .expect("networked sandbox Git Bash write fence"); + let _ = std::fs::remove_file(&home); + let _ = std::fs::remove_file(&drive_root); + assert_eq!( + code as u32, 0, + "git bash write-fence script exited {code:#010X} \ + (42=sibling writable, 43=home writable, 44=drive root writable)" + ); + assert_eq!( + std::fs::read_to_string(&inside).unwrap_or_default().trim(), + "inside" + ); + assert!( + !outside.exists(), + "git bash sandbox wrote outside the workspace: {outside:?}" + ); + assert!( + !home.exists(), + "git bash sandbox wrote to the user profile: {home:?}" + ); + assert!( + !drive_root.exists(), + "git bash sandbox wrote to the drive root: {drive_root:?}" + ); + } + + /// 真机:cmd 的三 SID 严令牌本来就应挡住工作区外写入。 + #[test] + fn networked_sandbox_cmd_write_fence() { + let _guard = SandboxTestGuard::acquire(); + let outer = tempfile::tempdir().expect("outer"); + let workspace = outer.path().join("workspace"); + let sibling = outer.path().join("sibling"); + std::fs::create_dir(&workspace).expect("workspace"); + std::fs::create_dir(&sibling).expect("sibling"); + let inside = unique_probe(&workspace, "cmd-inside"); + let outside = unique_probe(&sibling, "cmd-outside"); + let script = format!( + "echo inside>{0}&echo outside>{1}&exit /b 0", + inside.display(), + outside.display() + ); + win::execute( + &workspace, + true, + false, + std::path::Path::new("cmd.exe"), + &["/D".into(), "/C".into(), script], + ) + .expect("networked sandbox cmd write fence"); + assert_eq!( + std::fs::read_to_string(&inside).unwrap_or_default().trim(), + "inside" + ); + assert!( + !outside.exists(), + "cmd sandbox wrote outside the workspace: {outside:?}" + ); + } + } +} diff --git a/crates/agent-gui/src-tauri/src/services/automation/scheduler.rs b/crates/agent-gui/src-tauri/src/services/automation/scheduler.rs index fe64cd143..87048b964 100644 --- a/crates/agent-gui/src-tauri/src/services/automation/scheduler.rs +++ b/crates/agent-gui/src-tauri/src/services/automation/scheduler.rs @@ -8,7 +8,8 @@ use tokio::sync::{Mutex as AsyncMutex, Notify}; use tokio_cron_scheduler::{Job, JobScheduler}; use uuid::Uuid; -use crate::runtime::shell_runner::{run_shell_script, ShellRunResponse}; +use crate::runtime::sandbox; +use crate::runtime::shell_runner::{run_shell_script_with_envs, ShellRunResponse}; use crate::runtime::task_runner::{ build_http_client, resolve_workdir, run_single_http_request, HttpExecutionFailure, HttpExecutionResult, HttpRequestInput, @@ -516,7 +517,15 @@ fn execute_bash(task: &CronTask, workdir: String) -> CompletedRun { Ok(cwd) => cwd, Err(error) => return failed_run(&task.id, error, true), }; - let result = match run_shell_script( + // P1#2:Cron bash 脚本此前恒以 sandbox_options=None 执行,于是"模型建一个 bash + // 任务 → 调度器无沙箱触发"成为绕过沙箱围栏的持久化通道(还能跨应用重启存活)。 + // 现在与 Bash / ManagedProcess 共用同一个后端下限:回查持久化的 + // commandSafetyMode,读不出来则直接失败,绝不无沙箱执行。 + let sandbox_options = match sandbox::resolve_effective_options(None) { + Ok(options) => options, + Err(error) => return failed_run(&task.id, error, true), + }; + let result = match run_shell_script_with_envs( cwd.display().to_string(), script.clone(), None, @@ -524,6 +533,8 @@ fn execute_bash(task: &CronTask, workdir: String) -> CompletedRun { None, None, None, + &[], + sandbox_options, ) { Ok(result) => result, Err(error) => return failed_run(&task.id, error, true), diff --git a/crates/agent-gui/src-tauri/src/services/gateway/chat.rs b/crates/agent-gui/src-tauri/src/services/gateway/chat.rs index ae538d1ee..579068460 100644 --- a/crates/agent-gui/src-tauri/src/services/gateway/chat.rs +++ b/crates/agent-gui/src-tauri/src/services/gateway/chat.rs @@ -170,6 +170,7 @@ impl GatewayController { runtime_controls, execution_mode, workdir, + command_safety_mode, uploaded_files, queue_policy, } = request; @@ -204,6 +205,7 @@ impl GatewayController { runtime_controls, execution_mode, workdir, + command_safety_mode, uploaded_files: uploaded_files .into_iter() .map(|file| GatewayUploadedFileEvent { diff --git a/crates/agent-gui/src-tauri/src/services/gateway/tests.rs b/crates/agent-gui/src-tauri/src/services/gateway/tests.rs index 1b049294f..22d4ab343 100644 --- a/crates/agent-gui/src-tauri/src/services/gateway/tests.rs +++ b/crates/agent-gui/src-tauri/src/services/gateway/tests.rs @@ -80,6 +80,7 @@ fn gateway_chat_request( runtime_controls: None, execution_mode: String::new(), workdir: String::new(), + command_safety_mode: String::new(), uploaded_files: Vec::new(), queue_policy: String::new(), } @@ -112,6 +113,7 @@ fn gateway_chat_command_mapping_preserves_rebase_signal() { message: "edited".to_string(), execution_mode: "tools".to_string(), workdir: "/workspace".to_string(), + command_safety_mode: "sandbox".to_string(), ..Default::default() }; @@ -146,6 +148,7 @@ fn gateway_chat_command_mapping_preserves_rebase_signal() { assert_eq!(base_message_ref.content_hash, "fnv1a32:00000000"); assert_eq!(event.execution_mode, "tools"); assert_eq!(event.workdir, "/workspace"); + assert_eq!(event.command_safety_mode, "sandbox"); } #[test] diff --git a/crates/agent-gui/src-tauri/src/services/gateway/types.rs b/crates/agent-gui/src-tauri/src/services/gateway/types.rs index 655ced1d6..1d1513591 100644 --- a/crates/agent-gui/src-tauri/src/services/gateway/types.rs +++ b/crates/agent-gui/src-tauri/src/services/gateway/types.rs @@ -68,6 +68,9 @@ pub struct GatewayChatRequestEvent { pub runtime_controls: Option, pub execution_mode: String, pub workdir: String, + /// 远端 WebUI 直带的命令安全模式(ask/auto/sandbox/sandboxOffline);空串表示 + /// 未指定,桌面端回落到本地 settings.system.commandSafetyMode。 + pub command_safety_mode: String, pub uploaded_files: Vec, pub queue_policy: String, } diff --git a/crates/agent-gui/src/agent-ui-adapters/sandboxCapability.ts b/crates/agent-gui/src/agent-ui-adapters/sandboxCapability.ts new file mode 100644 index 000000000..d6c8c5417 --- /dev/null +++ b/crates/agent-gui/src/agent-ui-adapters/sandboxCapability.ts @@ -0,0 +1,39 @@ +import { invoke } from "@tauri-apps/api/core"; +import { useEffect, useState } from "react"; + +export type SandboxCapability = { + supported: boolean; + mechanism: string; + platform: string; + /** + * 是否支持断网变体(sandboxOffline)。macOS/Linux 在 supported 时为 true; + * Windows 取决于运行时探测(能否派生 AppContainer SID)。 + */ + network_control: boolean; + reason?: string; +}; + +let cachedCapability: SandboxCapability | null = null; + +/** 桌面端:探测本机 OS 沙箱可用性(macOS Seatbelt / Linux bwrap / Windows 受限令牌写围栏)。 */ +export function useSandboxCapability(): SandboxCapability | null { + const [capability, setCapability] = useState(cachedCapability); + + useEffect(() => { + if (cachedCapability) return; + let disposed = false; + invoke("system_sandbox_capability") + .then((result) => { + cachedCapability = result; + if (!disposed) setCapability(result); + }) + .catch((error) => { + console.warn("system_sandbox_capability failed", error); + }); + return () => { + disposed = true; + }; + }, []); + + return capability; +} diff --git a/crates/agent-gui/src/components/cron/CronPromptRunner.tsx b/crates/agent-gui/src/components/cron/CronPromptRunner.tsx index 15530a177..a88708e1d 100644 --- a/crates/agent-gui/src/components/cron/CronPromptRunner.tsx +++ b/crates/agent-gui/src/components/cron/CronPromptRunner.tsx @@ -24,6 +24,7 @@ import { } from "../../lib/settings"; import { buildBuiltinToolRegistry } from "../../lib/tools/builtinRegistry"; import { createFileToolState } from "../../lib/tools/fileToolState"; +import { resolveShellSandboxSettings } from "../../lib/tools/sandboxPolicy"; import type { SkillAccessPolicy } from "../../lib/tools/skillAccessPolicy"; import { appendSystemPrompt } from "../../pages/chat"; import { @@ -174,6 +175,7 @@ async function executeCronPromptRun( skillsEnabled: skillsContext.enabled, skillsRootDir: skillsContext.rootDir, skillAccessPolicy: skillsContext.accessPolicy, + sandbox: resolveShellSandboxSettings(settings.system.commandSafetyMode), runtimeScope: "cron_auto_prompt", currentChatModel: { customProviderId: request.providerId, diff --git a/crates/agent-gui/src/index.css b/crates/agent-gui/src/index.css index bad39f478..354e27fcf 100644 --- a/crates/agent-gui/src/index.css +++ b/crates/agent-gui/src/index.css @@ -148,7 +148,8 @@ inset 0 0 0 1px rgba(167, 139, 250, 0.12); } - .composer-branch-dropdown { + .composer-branch-dropdown, + .composer-safety-dropdown { transform-origin: var(--transform-origin); transition: opacity 0.18s cubic-bezier(0.16, 1, 0.3, 1), @@ -156,12 +157,15 @@ } .composer-branch-dropdown[data-starting-style], - .composer-branch-dropdown[data-ending-style] { + .composer-branch-dropdown[data-ending-style], + .composer-safety-dropdown[data-starting-style], + .composer-safety-dropdown[data-ending-style] { opacity: 0; transform: translateY(6px) scale(0.97); } - .composer-branch-dropdown[data-ending-style] { + .composer-branch-dropdown[data-ending-style], + .composer-safety-dropdown[data-ending-style] { transition-duration: 0.12s; transition-timing-function: cubic-bezier(0.4, 0, 1, 1); transform: translateY(5px) scale(0.975); @@ -170,7 +174,8 @@ @media (prefers-reduced-motion: reduce) { .composer-reasoning-trigger, .composer-reasoning-trigger svg:last-child, - .composer-branch-dropdown { + .composer-branch-dropdown, + .composer-safety-dropdown { transition: none !important; } } diff --git a/crates/agent-gui/src/lib/chat/runner/toolExecutionPrompt.ts b/crates/agent-gui/src/lib/chat/runner/toolExecutionPrompt.ts index 37b4c5b08..a5cc2fe68 100644 --- a/crates/agent-gui/src/lib/chat/runner/toolExecutionPrompt.ts +++ b/crates/agent-gui/src/lib/chat/runner/toolExecutionPrompt.ts @@ -215,6 +215,7 @@ export function buildToolsSuffix( "- Session responses use completed, failed, cancelled, or timed_out as terminal statuses. The reported session_duration_ms is cumulative from the original Bash start; never add values from multiple responses.", "- Omit Bash.timeout_ms when the command should run until completion. Set it only when a real hard runtime limit is desired; ProcessWait does not restart or extend that limit.", "- Use ProcessStop with session_id to terminate the complete process tree when the command is no longer needed.", + "- ProcessWait session_id values come from Bash/ProcessWait, not from ManagedProcess.process_id.", ] : []), "- Use ManagedProcess instead of Bash for dev servers, watchers, preview servers, or anything that should keep running.", @@ -257,7 +258,8 @@ export function buildToolsSuffix( "## ManagedProcess", '- Use ManagedProcess(action="start") for dev servers, preview servers, watchers, or other long-running foreground commands that should continue while you run tests.', "- Do not append `&` to ManagedProcess.command. It starts the process in the background, redirects stdout/stderr to a log file, and returns process_id/pid/log_path.", - '- Use ManagedProcess(action="status") to inspect running processes, action="read_log" to inspect recent output, and action="stop" to terminate the process tree.', + '- Use ManagedProcess(action="status") to inspect running processes, action="wait" to block until new log output or exit (not ProcessWait), action="read_log" to inspect recent output, and action="stop" to terminate the process tree.', + "- ProcessWait/ProcessStop only accept Bash session_id values. A ManagedProcess process_id is a UUID and must stay on the ManagedProcess tool.", managedProcessPreference, ].join("\n"), ); diff --git a/crates/agent-gui/src/lib/tools/builtinRegistry.ts b/crates/agent-gui/src/lib/tools/builtinRegistry.ts index 4c94949a6..17ce31634 100644 --- a/crates/agent-gui/src/lib/tools/builtinRegistry.ts +++ b/crates/agent-gui/src/lib/tools/builtinRegistry.ts @@ -28,7 +28,7 @@ import { createFsTools } from "./fsTools"; import { createMcpManagerTools } from "./mcpManagerTools"; import { createMcpTools } from "./mcpTools"; import { createMemoryTools } from "./memoryTools"; -import { createShellTools } from "./shellTools"; +import { createShellTools, type ShellSandboxSettings } from "./shellTools"; import type { SkillAccessPolicy } from "./skillAccessPolicy"; import { createSkillTools } from "./skillTools"; import { createSSHManagerTools, type SshManagerSessionChange } from "./sshManagerTools"; @@ -157,6 +157,8 @@ type BuildBuiltinBaseToolRegistryParams = { providerId: ProviderId; runtimePlatform?: RuntimePlatform; fileState: FileToolState; + /** OS 级沙箱设置;透传给 Bash / ManagedProcess 执行层。 */ + sandbox?: ShellSandboxSettings; skillsEnabled: boolean; skillsRootDir?: string; skillAccessPolicy?: SkillAccessPolicy; @@ -214,6 +216,7 @@ async function buildBaseBuiltinToolBundles(params: BuildBuiltinBaseToolRegistryP managedProcessEnabled: params.runtimeScope === "chat", resumableShellEnabled: params.runtimeScope === "chat", resolveHomeDir, + sandbox: params.sandbox, }), ...(params.skillsEnabled ? [ @@ -233,6 +236,9 @@ async function buildBaseBuiltinToolBundles(params: BuildBuiltinBaseToolRegistryP getMcpSettings: params.getMcpSettings, applyMcpOps: params.applyMcpOps, runtimeScope: params.runtimeScope, + // 沙箱模式下 McpManager 不得成为无围栏的 stdio spawn 入口(P1#1): + // 运行时探测与 create/update/enable 写入路径一律拒绝 stdio。 + sandbox: params.sandbox, resolveHomeDir, }), createMemoryTools({ diff --git a/crates/agent-gui/src/lib/tools/mcpManagerTools.ts b/crates/agent-gui/src/lib/tools/mcpManagerTools.ts index 57da89b7f..5926c6bb3 100644 --- a/crates/agent-gui/src/lib/tools/mcpManagerTools.ts +++ b/crates/agent-gui/src/lib/tools/mcpManagerTools.ts @@ -15,6 +15,7 @@ import { type McpManagerResultDetails, } from "./builtinTypes"; import { ToolPathResolver } from "./pathUtils"; +import type { ShellSandboxSettings } from "./shellTools"; type McpManagerAction = | "list" @@ -100,6 +101,17 @@ const WRITE_ACTIONS = new Set([ // sharing the process-wide runtime manager, so it is gated like a write. const RUNTIME_MUTATING_ACTIONS = new Set(["restart", "stop"]); +// Actions that connect to a server runtime. For `transport: "stdio"` that means +// spawning a local child process from a free-form `command`/`args` pair, i.e. a +// general-purpose process spawn primitive that the OS sandbox does not cover +// (MCP runtimes are pooled process-wide and started outside the shell funnel). +const RUNTIME_CONNECTING_ACTIONS = new Set([ + "test", + "tools", + "restart", + "diagnose", +]); + const MCP_STRING_MAP_SCHEMA = Type.Record(Type.String(), Type.String()); const MCP_SERVER_PARAMETERS = Type.Object( @@ -630,6 +642,11 @@ export function createMcpManagerTools(params: { /** Id-keyed merge commit; absent means this scope cannot modify settings. */ applyMcpOps?: (ops: McpSettingsOp[]) => void; runtimeScope: SystemToolRuntimeScope; + /** + * 本轮的 OS 沙箱设置(与 Bash / ManagedProcess 同一份契约)。启用时,本工具的 + * stdio 运行时探测一律拒绝(P1#1)。 + */ + sandbox?: ShellSandboxSettings; resolveHomeDir?: () => Promise; }): BuiltinToolBundle { const pathResolver = new ToolPathResolver({ @@ -671,6 +688,35 @@ export function createMcpManagerTools(params: { return params.applyMcpOps; } + /** + * 沙箱围栏的对称性守卫(P1#1)。 + * + * `transport: "stdio"` 的运行时探测最终走到 Rust `build_stdio_command` 的裸 + * `Command::new`,不带任何 SandboxSpec;而 `action: "test"` 允许模型传入自由格式的 + * `command`/`args`,既不写配置也不持久化 —— 等于在"选了最严格模式"的会话里留下一个 + * 与 Bash 同样通用、却完全无围栏的进程 spawn 入口(sandboxOffline 下尤其矛盾: + * 该模式的全部意义就是内核级断网)。MCP 运行时是进程级共享池,且 http/sse 传输根本 + * 不落到 shell funnel 上,无法复用同一套沙箱包装,故在沙箱模式下一律 fail-closed 拒绝。 + * + * create / update / enable 同样会把 stdio `command`/`args` 写入设置,下一轮 + * registry 构建(或同轮 subagent)经 `createMcpTools` → `mcp_list_tools` 自动拉起 + * 该进程,所以配置写入路径必须走同一守卫,不能只拦运行时探测。 + * + * 非 stdio 传输不 spawn 进程,不在本守卫范围内;用户在设置界面里手动测试 MCP 服务器 + * 也不受影响(那是显式用户操作,与 hooks / 用户自建 Cron 脚本同一豁免)。 + */ + function assertRuntimeSpawnAllowed(action: McpManagerAction, server: McpServerConfig) { + if (!params.sandbox?.enabled) return; + if (server.transport !== "stdio") return; + throw new Error( + `McpManager action=${action} would spawn a local stdio process for "${server.id}" ` + + `(command: ${server.command || ""}), but the current command mode runs model-driven ` + + `processes inside the OS sandbox and MCP runtimes cannot be fenced. Refused. ` + + `Use Bash for sandboxed commands; if this MCP server really needs probing, ask the user ` + + `to switch the command mode in the composer, or to test it from Settings → MCP.`, + ); + } + // Write commits are deliberately synchronous: each one re-reads the live // settings, validates, and applies its ops without any await in between, so // the single-threaded JS runtime guarantees the read-modify-write is atomic @@ -785,6 +831,7 @@ export function createMcpManagerTools(params: { } const conflict = args.conflict === "overwrite" ? "overwrite" : "fail"; + assertRuntimeSpawnAllowed(action, server); const { existed } = commitCreate(server, conflict); const runtimeWarnings: string[] = []; const stopped = existed @@ -805,6 +852,9 @@ export function createMcpManagerTools(params: { const serverId = requireServerId(args.server_id); const patch = await resolvePatchCwd(normalizePatch(args.patch), "McpManager.patch.cwd"); throwIfAborted(signal); + const existing = requireExistingServer(currentSettings(), serverId); + const updatedPreview = normalizeMcpServerConfig({ ...existing, ...patch, id: existing.id }); + assertRuntimeSpawnAllowed(action, updatedPreview); const { server, validation, changed } = commitUpdate(serverId, patch); if (!changed) { return { @@ -839,6 +889,12 @@ export function createMcpManagerTools(params: { if (action === "enable" || action === "disable") { const ids = targetServerIds(args); const enable = action === "enable"; + if (enable) { + const settings = currentSettings(); + for (const id of ids) { + assertRuntimeSpawnAllowed(action, requireExistingServer(settings, id)); + } + } commitSetEnabled(ids, enable); const runtimeWarnings: string[] = []; const stopped = enable ? false : await stopRuntimeAfterCommit(ids, runtimeWarnings, signal); @@ -867,12 +923,13 @@ export function createMcpManagerTools(params: { return { action, serverId, stopped: stopped.stopped, changed: false }; } - if (action === "test" || action === "tools" || action === "restart" || action === "diagnose") { + if (RUNTIME_CONNECTING_ACTIONS.has(action)) { const hasInlineServer = Boolean(args.server); const server = hasInlineServer ? await resolveServerCwd(normalizeServerInput(args.server), "McpManager.server.cwd") : requireExistingServer(currentSettings(), requireServerId(args.server_id)); throwIfAborted(signal); + assertRuntimeSpawnAllowed(action, server); const validation = validateServer(server, hasInlineServer ? undefined : currentSettings()); let runtime: McpRuntimeStatus | null = null; if (!hasInlineServer) { diff --git a/crates/agent-gui/src/lib/tools/sandboxPolicy.ts b/crates/agent-gui/src/lib/tools/sandboxPolicy.ts new file mode 100644 index 000000000..443f25f18 --- /dev/null +++ b/crates/agent-gui/src/lib/tools/sandboxPolicy.ts @@ -0,0 +1,18 @@ +import type { CommandSafetyMode } from "../settings"; +import type { ShellSandboxSettings } from "./shellTools"; + +/** + * Resolve the turn-level command safety mode into the one sandbox contract + * shared by every model-driven shell registry (chat, cron, and subagents). + */ +export function resolveShellSandboxSettings( + mode: CommandSafetyMode | null | undefined, +): ShellSandboxSettings | undefined { + if (mode !== "sandbox" && mode !== "sandboxOffline") { + return undefined; + } + return { + enabled: true, + allowNetwork: mode === "sandbox", + }; +} diff --git a/crates/agent-gui/src/lib/tools/shellTools.ts b/crates/agent-gui/src/lib/tools/shellTools.ts index 862bc7c81..01e2d4ca8 100644 --- a/crates/agent-gui/src/lib/tools/shellTools.ts +++ b/crates/agent-gui/src/lib/tools/shellTools.ts @@ -31,6 +31,7 @@ type ShellRunResponse = { platform?: string; profile?: string; shell_family?: string; + sandbox?: string; stdout: string; stderr: string; stdout_truncated: boolean; @@ -62,6 +63,8 @@ type ShellSessionResponse = { platform?: string; profile?: string; shell_family?: string; + /** Effective OS sandbox mechanism (for example seatbelt/bubblewrap/low-integrity-token). */ + sandbox?: string; timeout_ms?: number | null; }; @@ -102,6 +105,16 @@ type ManagedProcessLogResponse = { bytes: number; }; +type ManagedProcessWaitResponse = { + process: ManagedProcessRecord; + log_path: string; + content: string; + truncated: boolean; + bytes: number; + cursor: number; + timed_out: boolean; +}; + type SystemListSkillFilesResponse = { rootDir?: string | null; }; @@ -350,6 +363,7 @@ function buildShellSessionToolResult(params: { response.platform ? `platform: ${response.platform}` : null, response.profile ? `profile: ${response.profile}` : null, response.shell_family ? `shell_family: ${response.shell_family}` : null, + response.sandbox ? `sandbox: ${response.sandbox}` : null, params.cwd ? `cwd: ${params.cwd}` : null, response.exit_code !== null && response.exit_code !== undefined ? `exit_code: ${response.exit_code}` @@ -392,10 +406,16 @@ function buildShellSessionToolResult(params: { function normalizeProcessAction(input: unknown) { const action = typeof input === "string" ? input.trim().toLowerCase() : ""; - if (action === "start" || action === "status" || action === "read_log" || action === "stop") { + if ( + action === "start" || + action === "status" || + action === "read_log" || + action === "wait" || + action === "stop" + ) { return action; } - throw new Error("ManagedProcess.action must be one of: start, status, read_log, stop"); + throw new Error("ManagedProcess.action must be one of: start, status, read_log, wait, stop"); } function formatManagedProcessRecord(process: ManagedProcessRecord) { @@ -486,6 +506,11 @@ function buildCancelledResult(params: { }; } +export type ShellSandboxSettings = { + enabled: boolean; + allowNetwork: boolean; +}; + export function createShellTools(params: { workdir: string; providerId: ProviderId; @@ -496,11 +521,24 @@ export function createShellTools(params: { managedProcessEnabled?: boolean; resumableShellEnabled?: boolean; resolveHomeDir?: () => Promise; + /** OS 级沙箱;undefined 或 enabled=false 时直跑。Windows 联网后端不掩蔽凭据目录。 */ + sandbox?: ShellSandboxSettings; }): BuiltinToolBundle { const timeoutPolicy = resolveBashTimeoutPolicy(params.providerId); const runtimePlatform = normalizeRuntimePlatform(params.runtimePlatform) ?? inferRuntimePlatform(); const platformLabel = runtimePlatformLabel(runtimePlatform); + const sandboxEnabled = params.sandbox?.enabled === true; + const sandboxAllowNetwork = params.sandbox?.allowNetwork !== false; + // Windows 联网沙箱是 Low IL token:只围写、不掩读。模型描述不得承诺 ~/.ssh + // 被掩蔽;断网(AppContainer)以及 macOS/Linux 才有读掩蔽。 + const sandboxMasksCredentials = + sandboxEnabled && !(runtimePlatform === "windows" && sandboxAllowNetwork); + const sandboxPolicy = sandboxEnabled + ? ` Sandbox mode is ON: commands run inside an OS-level sandbox — writes are limited to the workspace and temp dirs${ + sandboxMasksCredentials ? ", credential dirs (~/.ssh etc.) are masked" : "" + }${sandboxAllowNetwork ? "" : ", and network access is blocked"}. "Operation not permitted" outside the workspace means the sandbox blocked it; do not retry with sudo — ask the user instead.` + : ""; const shellPolicy = runtimePlatform === "windows" ? "Windows runs Bash commands with Git Bash (POSIX semantics) when available, falling back to pwsh, then Windows PowerShell, then cmd only if Git Bash is not installed. Write POSIX/bash syntax by default: `export NAME=value`, `&&`, `/dev/null`, forward-slash paths. If the result header reports `shell_family: powershell` or `shell_family: cmd`, Git Bash is missing on this machine — switch to PowerShell syntax and suggest installing Git for Windows or setting LIVEAGENT_GIT_BASH_PATH." @@ -755,7 +793,7 @@ export function createShellTools(params: { const toolBash: Tool = { name: "Bash", - description: `Execute a non-interactive shell command on the local machine for builds, tests, package managers, external CLIs, curl/API calls, running Skill scripts, or explicitly requested shell work. Runtime platform: ${platformLabel}. ${shellPolicy} Reserve it for commands that truly require a shell — do NOT use Bash for file operations the dedicated tools handle: use Read/List/Glob/Grep instead of cat/ls/find/grep/rg for any workspace or Skill content; always use Delete for intentional workspace or Skill deletions instead of Bash, scripts, or deletion-oriented CLIs such as rm/rmdir/unlink/find -delete/git rm/git clean/PowerShell Remove-Item/cmd del, erase, or rd, because only structured Delete calls make deletions visible in Edited Files and file-ledger tracking; use Image instead of open/xdg-open/file paths to show pictures. Use curl with an explicit timeout such as \`--max-time 30\` for endpoint tests. ${backgroundPolicy} Running a Skill script: set cwd to \`skill:///scripts\` and run a relative command, or execute the absolute script path directly when that Skill is enabled. Use / as the path separator; Windows \\ is auto-normalized. Returns stdout, stderr, exit_code, platform, profile, and shell_family. ${allowResumableShell ? `Bash waits up to yield_time_ms (default 10000ms), then returns a session_id while the same command continues; use ProcessWait to continue waiting and ProcessStop to terminate it. Session responses report session_duration_ms as cumulative elapsed time from the original Bash start, so never add it across responses. Terminal statuses are completed, failed, cancelled, and timed_out. timeout_ms is an optional hard runtime limit and is capped at ${GLOBAL_BASH_MAX_TIMEOUT_MS}ms; omit it for no hard limit.` : `For ${timeoutPolicy.providerLabel}, timeout defaults to ${timeoutPolicy.defaultTimeoutMs}ms and is capped at ${timeoutPolicy.maxTimeoutMs}ms; larger timeout_ms values are accepted by the schema but clamped before execution.`} High risk: use carefully.`, + description: `Execute a non-interactive shell command on the local machine for builds, tests, package managers, external CLIs, curl/API calls, running Skill scripts, or explicitly requested shell work. Runtime platform: ${platformLabel}. ${shellPolicy}${sandboxPolicy} Reserve it for commands that truly require a shell — do NOT use Bash for file operations the dedicated tools handle: use Read/List/Glob/Grep instead of cat/ls/find/grep/rg for any workspace or Skill content; always use Delete for intentional workspace or Skill deletions instead of Bash, scripts, or deletion-oriented CLIs such as rm/rmdir/unlink/find -delete/git rm/git clean/PowerShell Remove-Item/cmd del, erase, or rd, because only structured Delete calls make deletions visible in Edited Files and file-ledger tracking; use Image instead of open/xdg-open/file paths to show pictures. Use curl with an explicit timeout such as \`--max-time 30\` for endpoint tests. ${backgroundPolicy} Running a Skill script: set cwd to \`skill:///scripts\` and run a relative command, or execute the absolute script path directly when that Skill is enabled. Use / as the path separator; Windows \\ is auto-normalized. Returns stdout, stderr, exit_code, platform, profile, shell_family, and the effective sandbox mechanism. ${allowResumableShell ? `Bash waits up to yield_time_ms (default 10000ms), then returns a session_id while the same command continues; use ProcessWait to continue waiting and ProcessStop to terminate it. Session responses report session_duration_ms as cumulative elapsed time from the original Bash start, so never add it across responses. Terminal statuses are completed, failed, cancelled, and timed_out. timeout_ms is an optional hard runtime limit and is capped at ${GLOBAL_BASH_MAX_TIMEOUT_MS}ms; omit it for no hard limit.` : `For ${timeoutPolicy.providerLabel}, timeout defaults to ${timeoutPolicy.defaultTimeoutMs}ms and is capped at ${timeoutPolicy.maxTimeoutMs}ms; larger timeout_ms values are accepted by the schema but clamped before execution.`} High risk: use carefully.`, parameters: strictToolParameters({ command: Type.String({ description: "Shell command to execute (prefer non-interactive, idempotent commands).", @@ -793,7 +831,7 @@ export function createShellTools(params: { const toolProcessWait: Tool = { name: "ProcessWait", description: - "Wait for an existing Bash session and read its next output page without starting another shell command. Use the latest cursor returned by Bash or ProcessWait. The wait is event-driven and returns when the command finishes, 64KiB of output is ready, or yield_time_ms elapses. session_duration_ms is cumulative from the original Bash start and must not be added across responses. Terminal statuses are completed, failed, cancelled, and timed_out.", + "Wait for an existing Bash session and read its next output page without starting another shell command. Use the latest cursor returned by Bash or ProcessWait. session_id must be a Bash session_id (typically prefixed with bash-), not a ManagedProcess process_id. The wait is event-driven and returns when the command finishes, 64KiB of output is ready, or yield_time_ms elapses. session_duration_ms is cumulative from the original Bash start and must not be added across responses. Terminal statuses are completed, failed, cancelled, and timed_out.", parameters: strictToolParameters({ session_id: Type.String({ description: "Bash session_id to continue waiting for." }), cursor: Type.Optional( @@ -823,13 +861,14 @@ export function createShellTools(params: { const toolManagedProcess: Tool = { name: "ManagedProcess", - description: `Start, inspect, read logs for, or stop a long-running local process such as a dev server, watcher, or preview server. Runtime platform: ${platformLabel}; commands use the same platform shell policy as Bash. Use this instead of detached shell/background syntax, but never use it to intentionally delete workspace or enabled Skill paths; use Delete so LiveAgent can track the deletion. action="start" runs a foreground command under LiveAgent process management, redirects stdout/stderr to a log file, and returns immediately with process_id, pid, and log_path. By default managed processes are terminated automatically when LiveAgent exits; pass isolated=true only when the user explicitly wants the service to outlive LiveAgent. Use action="status" to list or inspect processes, action="read_log" to read recent log output, and action="stop" to terminate the process tree.`, + description: `Start, inspect, wait for, read logs for, or stop a long-running local process such as a dev server, watcher, or preview server. Runtime platform: ${platformLabel}; commands use the same platform shell policy as Bash. Use this instead of detached shell/background syntax, but never use it to intentionally delete workspace or enabled Skill paths; use Delete so LiveAgent can track the deletion. action="start" runs a foreground command under LiveAgent process management, redirects stdout/stderr to a log file (line-buffered), and returns immediately with process_id, pid, and log_path. Do not use ProcessWait with that process_id — ProcessWait only accepts Bash session_id values. Use action="wait" to block until new log output arrives, the process exits, or yield_time_ms elapses. By default managed processes are terminated automatically when LiveAgent exits; pass isolated=true only when the user explicitly wants the service to outlive LiveAgent. Use action="status" to list or inspect processes, action="read_log" to read recent log output, and action="stop" to terminate the process tree.`, parameters: strictToolParameters({ action: Type.Union( [ Type.Literal("start"), Type.Literal("status"), Type.Literal("read_log"), + Type.Literal("wait"), Type.Literal("stop"), ], { @@ -863,7 +902,22 @@ export function createShellTools(params: { process_id: Type.Optional( Type.String({ description: - 'Required for action="read_log" and action="stop"; optional filter for action="status".', + 'Required for action="read_log", action="wait", and action="stop"; optional filter for action="status".', + }), + ), + cursor: Type.Optional( + Type.Number({ + minimum: 0, + description: + 'Latest absolute log-byte offset already consumed, for action="wait". Omit to start from 0. Pass the cursor returned by the previous wait.', + }), + ), + yield_time_ms: Type.Optional( + Type.Number({ + minimum: 5_000, + maximum: 300_000, + description: + 'Maximum wait duration for action="wait" (default 30000ms, range 5000-300000ms). Returns earlier when new log output arrives or the process exits.', }), ), max_bytes: Type.Optional( @@ -871,7 +925,7 @@ export function createShellTools(params: { minimum: 1, maximum: 512 * 1024, description: - 'Maximum recent log bytes to return for action="read_log" (default 65536, maximum 524288).', + 'Maximum recent log bytes to return for action="read_log" and action="wait" (default 65536, maximum 524288).', }), ), }), @@ -888,7 +942,17 @@ export function createShellTools(params: { : ["command", "cwd", "timeout_ms"], ProcessWait: ["session_id", "cursor", "yield_time_ms"], ProcessStop: ["session_id", "cursor"], - ManagedProcess: ["action", "command", "cwd", "label", "isolated", "process_id", "max_bytes"], + ManagedProcess: [ + "action", + "command", + "cwd", + "label", + "isolated", + "process_id", + "max_bytes", + "cursor", + "yield_time_ms", + ], }; const sessionAbortHandlers = new Map void }>(); @@ -1074,6 +1138,10 @@ export function createShellTools(params: { cwd: cwd || undefined, label: label || undefined, isolated: isolated || undefined, + // 跟随所选模式:sandboxOffline 下常驻进程同样断网(无法对外提供服务, + // 需要 dev server 时应切回"沙箱"模式)。 + sandbox: sandboxEnabled, + sandbox_allow_network: sandboxAllowNetwork, }, signal, { @@ -1091,6 +1159,7 @@ export function createShellTools(params: { formatManagedProcessRecord(response.process), "", `Read logs with ManagedProcess(action="read_log", process_id="${response.process.id}")`, + `Wait for output with ManagedProcess(action="wait", process_id="${response.process.id}", cursor=0). Do not use ProcessWait; that tool only accepts Bash session_id values.`, `Stop it with ManagedProcess(action="stop", process_id="${response.process.id}")`, ].join("\n"), }); @@ -1145,6 +1214,50 @@ export function createShellTools(params: { }); } + if (action === "wait") { + const cursor = normalizeCursor(toolCall.arguments?.cursor) ?? 0; + const yieldTimeMs = normalizeIntegerInRange( + toolCall.arguments?.yield_time_ms, + 30_000, + 5_000, + 300_000, + ); + const maxBytes = + typeof toolCall.arguments?.max_bytes === "number" + ? Math.floor(toolCall.arguments.max_bytes) + : undefined; + const response = await invokeWithAbort( + "managed_process_wait", + { + process_id: processId, + cursor, + yield_time_ms: yieldTimeMs, + max_bytes: maxBytes, + }, + signal, + ); + const lines = [ + `ManagedProcess wait id=${response.process.id}`, + formatManagedProcessRecord(response.process), + `timed_out=${response.timed_out}`, + `cursor=${response.cursor}`, + `bytes=${response.bytes}${response.truncated ? " truncated=true" : ""}`, + "", + response.content || "(no new log output)", + ]; + if (response.process.running) { + lines.push( + "", + `Continue with ManagedProcess(action="wait", process_id="${response.process.id}", cursor=${response.cursor}).`, + ); + } + return buildManagedProcessToolResult({ + toolCall, + details: response, + text: lines.join("\n"), + }); + } + const response = await invokeWithAbort( "managed_process_stop", { process_id: processId }, @@ -1346,6 +1459,8 @@ export function createShellTools(params: { yield_time_ms, timeout_ms, max_timeout_ms: GLOBAL_BASH_MAX_TIMEOUT_MS, + sandbox: sandboxEnabled, + sandbox_allow_network: sandboxAllowNetwork, }, signal, { @@ -1423,6 +1538,8 @@ export function createShellTools(params: { max_timeout_ms: timeoutPolicy.maxTimeoutMs, provider_id: params.providerId, run_id, + sandbox: sandboxEnabled, + sandbox_allow_network: sandboxAllowNetwork, } as any); const header = [ @@ -1431,6 +1548,7 @@ export function createShellTools(params: { res.platform ? `platform: ${res.platform}` : null, res.profile ? `profile: ${res.profile}` : null, res.shell_family ? `shell_family: ${res.shell_family}` : null, + res.sandbox ? `sandbox: ${res.sandbox}` : null, `cwd: ${formatResolvedTarget(cwdResolved)}`, `exit_code: ${res.exit_code}`, res.timed_out ? `timed_out: true` : null, diff --git a/crates/agent-gui/src/pages/ChatPage.tsx b/crates/agent-gui/src/pages/ChatPage.tsx index e5f84da78..404e4d46f 100644 --- a/crates/agent-gui/src/pages/ChatPage.tsx +++ b/crates/agent-gui/src/pages/ChatPage.tsx @@ -93,6 +93,7 @@ import { resolveWorkspaceResources, type SelectedModel, updateExecutionModeFromChatSelection, + updateSystem, updateWorkspaceResourceSettings, type WorkspaceProject, workspaceProjectPathKey, @@ -1999,6 +2000,14 @@ export function ChatPage(props: ChatPageProps) { modelOptions={modelOptions} selectedValue={selectedValue} chatRuntimeControls={chatRuntimeControlsForCurrentProvider} + commandSafetyMode={settings.system.commandSafetyMode} + onCommandSafetyModeChange={(mode) => + setSettings((prev) => + prev.system.commandSafetyMode === mode + ? prev + : updateSystem(prev, { commandSafetyMode: mode }), + ) + } reasoningOptions={chatRuntimeReasoningOptions} thinkingAlwaysOn={chatRuntimeThinkingAlwaysOn} contextUsageTokensSource={contextUsageTokensSource} diff --git a/crates/agent-gui/src/pages/chat/gateway/gatewayBridgeTypes.ts b/crates/agent-gui/src/pages/chat/gateway/gatewayBridgeTypes.ts index 9272a0315..49cadb1ca 100644 --- a/crates/agent-gui/src/pages/chat/gateway/gatewayBridgeTypes.ts +++ b/crates/agent-gui/src/pages/chat/gateway/gatewayBridgeTypes.ts @@ -2,7 +2,12 @@ import type { MentionComposerDraft } from "@liveagent/ui/components/chat/Mention import type { PendingUploadedFile } from "@liveagent/ui/lib/chat/uploadedFiles"; import type { MutableRefObject } from "react"; import type { HistoryMessageRef } from "../../../lib/chat/conversation/conversationState"; -import type { ChatRuntimeControls, ExecutionMode, ProviderId } from "../../../lib/settings"; +import type { + ChatRuntimeControls, + CommandSafetyMode, + ExecutionMode, + ProviderId, +} from "../../../lib/settings"; import type { ConversationRuntimeEntry } from "../runtime/chatPageRuntime"; export type GatewaySelectedModelEvent = { @@ -27,6 +32,7 @@ export type GatewayChatRequestEvent = { runtimeControls?: GatewayChatRuntimeControlsEvent; executionMode?: string; workdir?: string; + commandSafetyMode?: string; uploadedFiles?: PendingUploadedFile[]; queuePolicy?: "auto" | "append" | "interrupt" | string; }; @@ -65,6 +71,7 @@ export type ActiveGatewayBridgeRequest = { runtimeControlsOverride?: ChatRuntimeControls; executionModeOverride?: ExecutionMode; workdirOverride?: string; + commandSafetyModeOverride?: CommandSafetyMode; }; export type SendChatAction = (overrides?: { @@ -74,6 +81,7 @@ export type SendChatAction = (overrides?: { conversationIdOverride?: string; executionModeOverride?: ExecutionMode; workdirOverride?: string; + commandSafetyModeOverride?: CommandSafetyMode; runtimeControlsOverride?: ChatRuntimeControls; gatewayBridgeRequestOverride?: ActiveGatewayBridgeRequest | null; preserveComposerOnStart?: boolean; @@ -121,6 +129,23 @@ export function normalizeGatewayExecutionMode( } } +// 命令安全模式的网关归一化。返回 undefined(而非某个默认模式)表示"远端未指定", +// 让 useSendChatTurn 的优先级链回落到本地 settings.system.commandSafetyMode;绝不 +// 默认成 "auto",以免静默下调桌面端已选的更严格模式(fail-closed)。 +export function normalizeGatewayCommandSafetyMode( + value: string | null | undefined, +): CommandSafetyMode | undefined { + switch (value?.trim()) { + case "ask": + case "auto": + case "sandbox": + case "sandboxOffline": + return value.trim() as CommandSafetyMode; + default: + return undefined; + } +} + export function normalizeGatewayWorkdir(value: string | null | undefined): string | undefined { const normalized = value?.trim() ?? ""; return normalized || undefined; diff --git a/crates/agent-gui/src/pages/chat/gateway/useGatewayBridgeListeners.ts b/crates/agent-gui/src/pages/chat/gateway/useGatewayBridgeListeners.ts index 393bef7c3..e0630abc5 100644 --- a/crates/agent-gui/src/pages/chat/gateway/useGatewayBridgeListeners.ts +++ b/crates/agent-gui/src/pages/chat/gateway/useGatewayBridgeListeners.ts @@ -10,6 +10,7 @@ import { type GatewayChatCancelEvent, type GatewayChatClaimedRequest, type GatewayChatRequestReadyEvent, + normalizeGatewayCommandSafetyMode, normalizeGatewayExecutionMode, normalizeGatewayWorkdir, } from "./gatewayBridgeTypes"; @@ -482,6 +483,7 @@ export function useGatewayBridgeListeners(params: UseGatewayBridgeListenersParam : undefined, executionModeOverride: normalizeGatewayExecutionMode(payload.executionMode), workdirOverride: normalizeGatewayWorkdir(payload.workdir), + commandSafetyModeOverride: normalizeGatewayCommandSafetyMode(payload.commandSafetyMode), }); const markRuntimeStarted = async () => { await invoke("gateway_chat_mark_started", { @@ -496,6 +498,7 @@ export function useGatewayBridgeListeners(params: UseGatewayBridgeListenersParam conversationIdOverride: resolvedConversationId, executionModeOverride: gatewayBridgeRequest.executionModeOverride, workdirOverride: gatewayBridgeRequest.workdirOverride, + commandSafetyModeOverride: gatewayBridgeRequest.commandSafetyModeOverride, runtimeControlsOverride: gatewayBridgeRequest.runtimeControlsOverride, gatewayBridgeRequestOverride: gatewayBridgeRequest, editResendBaseMessageRef: baseMessageRef, diff --git a/crates/agent-gui/src/pages/chat/queue/chatTurnQueue.ts b/crates/agent-gui/src/pages/chat/queue/chatTurnQueue.ts index c92d583e9..77c7fddfc 100644 --- a/crates/agent-gui/src/pages/chat/queue/chatTurnQueue.ts +++ b/crates/agent-gui/src/pages/chat/queue/chatTurnQueue.ts @@ -7,7 +7,7 @@ export type { } from "@liveagent/ui/contracts/chatQueue"; import type { PendingUploadedFile } from "@liveagent/ui/lib/chat/uploadedFiles"; -import type { ChatRuntimeControls, ExecutionMode } from "../../../lib/settings"; +import type { ChatRuntimeControls, CommandSafetyMode, ExecutionMode } from "../../../lib/settings"; import type { GatewayChatRuntimeControlsEvent, GatewaySelectedModelEvent, @@ -31,6 +31,7 @@ export type QueuedChatTurn = { uploadedFiles: PendingUploadedFile[]; executionMode: ExecutionMode; workdir: string; + commandSafetyMode: CommandSafetyMode; runtimeControls: ChatRuntimeControls; createdAt: number; gatewayRequest?: QueuedGatewayChatRequest; @@ -57,6 +58,7 @@ export function createQueuedChatTurn(input: QueuedChatTurnInput): QueuedChatTurn uploadedFiles: input.uploadedFiles.slice(), executionMode: input.executionMode, workdir: input.workdir.trim(), + commandSafetyMode: input.commandSafetyMode, runtimeControls: { ...input.runtimeControls }, createdAt, gatewayRequest: input.gatewayRequest ? { ...input.gatewayRequest } : undefined, diff --git a/crates/agent-gui/src/pages/chat/queue/useChatTurnQueue.ts b/crates/agent-gui/src/pages/chat/queue/useChatTurnQueue.ts index 8f47de2cc..3cf2babea 100644 --- a/crates/agent-gui/src/pages/chat/queue/useChatTurnQueue.ts +++ b/crates/agent-gui/src/pages/chat/queue/useChatTurnQueue.ts @@ -11,6 +11,7 @@ import type { LiveTranscriptStore } from "../../../lib/chat/conversation/liveTra import { type AppSettings, type ChatRuntimeControls, + type CommandSafetyMode, type ExecutionMode, isAgentExecutionMode, normalizeChatRuntimeControls, @@ -21,6 +22,7 @@ import { createTextComposerDraft } from "../composer/composerDraftText"; import type { ActiveGatewayBridgeRequest, SendChatAction } from "../gateway/gatewayBridgeTypes"; import { type GatewayChatClaimedRequest, + normalizeGatewayCommandSafetyMode, normalizeGatewayExecutionMode, normalizeGatewayWorkdir, } from "../gateway/gatewayBridgeTypes"; @@ -147,6 +149,7 @@ export function useChatTurnQueue(params: UseChatTurnQueueParams) { createdAt: number; executionMode: ExecutionMode; workdir: string; + commandSafetyMode: CommandSafetyMode; runtimeControls: ChatRuntimeControls; gatewayRequest?: QueuedChatTurn["gatewayRequest"]; }) @@ -399,6 +402,7 @@ export function useChatTurnQueue(params: UseChatTurnQueueParams) { ? queuedChatTurnEditSlotRef.current : null; const executionMode = editSlot?.executionMode ?? settings.system.executionMode; + const commandSafetyMode = editSlot?.commandSafetyMode ?? settings.system.commandSafetyMode; const workdirForTurn = isAgentExecutionMode(executionMode) ? ( editSlot?.workdir ?? @@ -414,6 +418,7 @@ export function useChatTurnQueue(params: UseChatTurnQueueParams) { uploadedFiles, executionMode, workdir: workdirForTurn, + commandSafetyMode, runtimeControls: editSlot?.runtimeControls ?? settings.chatRuntimeControls, createdAt: editSlot?.createdAt, gatewayRequest: editSlot?.gatewayRequest, @@ -500,6 +505,7 @@ export function useChatTurnQueue(params: UseChatTurnQueueParams) { : queuedTurn.runtimeControls, executionModeOverride: queuedTurn.executionMode, workdirOverride: queuedTurn.workdir, + commandSafetyModeOverride: queuedTurn.commandSafetyMode, } : null; const markGatewayStarted = @@ -518,6 +524,7 @@ export function useChatTurnQueue(params: UseChatTurnQueueParams) { conversationIdOverride: targetConversationId, executionModeOverride: queuedTurn.executionMode, workdirOverride: queuedTurn.workdir, + commandSafetyModeOverride: queuedTurn.commandSafetyMode, runtimeControlsOverride: queuedTurn.runtimeControls, gatewayBridgeRequestOverride: gatewayBridgeRequest, preserveComposerOnStart: true, @@ -677,6 +684,7 @@ export function useChatTurnQueue(params: UseChatTurnQueueParams) { createdAt: queuedTurn.createdAt, executionMode: queuedTurn.executionMode, workdir: queuedTurn.workdir, + commandSafetyMode: queuedTurn.commandSafetyMode, runtimeControls: { ...queuedTurn.runtimeControls }, gatewayRequest: queuedTurn.gatewayRequest ? { ...queuedTurn.gatewayRequest } : undefined, }; @@ -722,6 +730,9 @@ export function useChatTurnQueue(params: UseChatTurnQueueParams) { const executionMode = normalizeGatewayExecutionMode(payload.executionMode) ?? settings.system.executionMode; + const commandSafetyMode = + normalizeGatewayCommandSafetyMode(payload.commandSafetyMode) ?? + settings.system.commandSafetyMode; const workdir = normalizeGatewayWorkdir(payload.workdir) ?? conversationRuntimeCacheRef.current.get(targetConversationId)?.workdir ?? @@ -737,6 +748,7 @@ export function useChatTurnQueue(params: UseChatTurnQueueParams) { uploadedFiles, executionMode, workdir: isAgentExecutionMode(executionMode) ? workdir : "", + commandSafetyMode, runtimeControls, gatewayRequest: { requestId, diff --git a/crates/agent-gui/src/pages/chat/runtime/useSendChatTurn.ts b/crates/agent-gui/src/pages/chat/runtime/useSendChatTurn.ts index 968234118..f40414786 100644 --- a/crates/agent-gui/src/pages/chat/runtime/useSendChatTurn.ts +++ b/crates/agent-gui/src/pages/chat/runtime/useSendChatTurn.ts @@ -60,6 +60,7 @@ import { type AppSettings, applyMcpOpsToAppSettings, type ChatRuntimeControls, + type CommandSafetyMode, type ExecutionMode, filterMcpSettingsForWorkspace, getSshProjectHostIds, @@ -68,6 +69,7 @@ import { removeWorkspaceResourceReferences, resolveWorkspaceResources, type SelectedModel, + strictestCommandSafetyMode, updateMemorySettings, updateSkills, type WorkspaceProject, @@ -321,6 +323,7 @@ export function useSendChatTurn(params: UseSendChatTurnParams) { conversationIdOverride?: string; executionModeOverride?: ExecutionMode; workdirOverride?: string; + commandSafetyModeOverride?: CommandSafetyMode; runtimeControlsOverride?: ChatRuntimeControls; gatewayBridgeRequestOverride?: ActiveGatewayBridgeRequest | null; preserveComposerOnStart?: boolean; @@ -345,6 +348,14 @@ export function useSendChatTurn(params: UseSendChatTurnParams) { overrides?.executionModeOverride ?? gatewayBridgeRequest?.executionModeOverride ?? settings.system.executionMode; + // 命令安全模式:远端 WebUI / 网关 / 排队快照带来的模式只能“收紧”,不能放宽 + // (P3#9)。桌面端是工具唯一执行处,一份陈旧的浏览器快照不得把本地刻意选定的 + // sandboxOffline 静默降级成 auto —— 故与本地 settings.system 取更严格者。 + const requestedCommandSafetyMode = + overrides?.commandSafetyModeOverride ?? gatewayBridgeRequest?.commandSafetyModeOverride; + const effectiveCommandSafetyMode = requestedCommandSafetyMode + ? strictestCommandSafetyMode(requestedCommandSafetyMode, settings.system.commandSafetyMode) + : settings.system.commandSafetyMode; const effectiveIsAgentMode = isAgentExecutionMode(effectiveExecutionMode); const effectiveWorkdir = resolveEffectiveConversationWorkdir({ isAgentMode: effectiveIsAgentMode, @@ -1674,6 +1685,7 @@ export function useSendChatTurn(params: UseSendChatTurnParams) { agentTemplates: settings.agents, getMcpSettings: getEffectiveMcpSettings, getToolPolicies, + commandSafetyMode: effectiveCommandSafetyMode, applyMcpOps: (ops) => { const removedIds = ops.filter((op) => op.kind === "remove").map((op) => op.serverId); setSettings((prev) => diff --git a/crates/agent-gui/src/pages/chat/turns/runAgentConversationTurn.ts b/crates/agent-gui/src/pages/chat/turns/runAgentConversationTurn.ts index c0722ddd4..b9f87ae9a 100644 --- a/crates/agent-gui/src/pages/chat/turns/runAgentConversationTurn.ts +++ b/crates/agent-gui/src/pages/chat/turns/runAgentConversationTurn.ts @@ -82,6 +82,7 @@ import type { AdditionalProjectRoot } from "../../../lib/tools/additionalProject import { buildBuiltinToolRegistry } from "../../../lib/tools/builtinRegistry"; import type { BuiltinToolExecutionContext } from "../../../lib/tools/builtinTypes"; import { createFileToolState } from "../../../lib/tools/fileToolState"; +import { resolveShellSandboxSettings } from "../../../lib/tools/sandboxPolicy"; import type { SkillAccessPolicy } from "../../../lib/tools/skillAccessPolicy"; import type { SshManagerSessionChange } from "../../../lib/tools/sshManagerTools"; import { formatTaskListRuntimeContext, type TaskStateStore } from "../../../lib/tools/taskTools"; @@ -284,6 +285,8 @@ export type RunAgentConversationTurnParams = { getMcpSettings: () => AppSettings["mcp"]; /** 工具审批策略的实时读取(权威 settingsRef,非 turn 级快照),缺省视为空表。 */ getToolPolicies?: () => AppSettings["system"]["toolPolicies"]; + /** 命令执行方式(turn 级快照):ask 全量审批 / auto 按策略 / sandbox(±断网)。 */ + commandSafetyMode?: AppSettings["system"]["commandSafetyMode"]; applyMcpOps?: (ops: McpSettingsOp[]) => void; remoteWebTunnelsEnabled?: boolean; tunnelPublicBaseUrl?: string; @@ -371,6 +374,7 @@ export async function runAgentConversationTurn(params: RunAgentConversationTurnP agentTemplates, getMcpSettings, getToolPolicies, + commandSafetyMode, applyMcpOps, remoteWebTunnelsEnabled, tunnelPublicBaseUrl, @@ -579,12 +583,14 @@ export async function runAgentConversationTurn(params: RunAgentConversationTurnP const subagentScheduler = createSubagentScheduler(); const runtimePlatform = await resolveRuntimePlatform(); const buildRegistryStartedAt = perfNowMs(); + const safetyMode = commandSafetyMode ?? "auto"; const builtinRegistry = await buildBuiltinToolRegistry({ workdir: effectiveWorkdir, additionalRoots, providerId, runtimePlatform, fileState, + sandbox: resolveShellSandboxSettings(safetyMode), taskStateStore, askUserQuestionConversationId: conversationId, checkpoint: { @@ -672,22 +678,21 @@ export async function runAgentConversationTurn(params: RunAgentConversationTurnP // 工具审批门:按实时策略裁决每次调用。deny → 直接拦;ask → 挂起等用户在 // 聊天审批卡片作决定(本会话已“记住”的工具免审);allow → 放行。 + // 命令执行方式为 ask 时,非只读工具无论策略如何都升级为 ask(deny 仍拦)。 const resolveToolGate = async ( toolCall: ToolCall, signal?: AbortSignal, ): Promise<{ allow: true } | { allow: false; reason: string }> => { - const policy = resolveToolPolicy( - toolCall.name, - builtinRegistry.metadataByName.get(toolCall.name), - getToolPolicies?.(), - ); + const metadata = builtinRegistry.metadataByName.get(toolCall.name); + const policy = resolveToolPolicy(toolCall.name, metadata, getToolPolicies?.()); if (policy === "deny") { return { allow: false, reason: `工具 ${toolCall.name} 已被用户的权限策略禁止(deny)。不要重试;如确需使用,请让用户在设置的工具权限中放行。`, }; } - if (policy !== "ask") { + const effectivePolicy = safetyMode === "ask" && !metadata?.isReadOnly ? "ask" : policy; + if (effectivePolicy !== "ask") { return { allow: true }; } if (isSessionApproved(conversationId, toolCall.name)) { diff --git a/crates/agent-gui/test/chat/agent-turn-task-list-freeze.test.mjs b/crates/agent-gui/test/chat/agent-turn-task-list-freeze.test.mjs index 59866f7dd..047d40d4a 100644 --- a/crates/agent-gui/test/chat/agent-turn-task-list-freeze.test.mjs +++ b/crates/agent-gui/test/chat/agent-turn-task-list-freeze.test.mjs @@ -31,6 +31,7 @@ const fileToolStatePath = fileURLToPath( let runAssistantWithToolsScenario = async () => { throw new Error("scenario was not installed"); }; +const registryBuildCalls = []; const loader = createTsModuleLoader({ mocks: { @@ -40,7 +41,8 @@ const loader = createTsModuleLoader({ }, }, [builtinRegistryPath]: { - async buildBuiltinToolRegistry() { + async buildBuiltinToolRegistry(params) { + registryBuildCalls.push(params); return { tools: [], async executeToolCall() { @@ -377,3 +379,34 @@ test("run 内压缩之后快照刷新为当前任务状态", async () => { `${BASE_SYSTEM_PROMPT}\n\n${formatTaskListRuntimeContext(PENDING_TASKS)}`, ); }); + +test("主 Agent turn 将每种命令安全模式传给工具 registry", async () => { + const cases = [ + ["sandbox", { enabled: true, allowNetwork: true }], + ["sandboxOffline", { enabled: true, allowNetwork: false }], + ["ask", undefined], + ["auto", undefined], + [undefined, undefined], + ]; + + registryBuildCalls.length = 0; + for (const [commandSafetyMode, expectedSandbox] of cases) { + const harness = createHarness(); + harness.params.commandSafetyMode = commandSafetyMode; + await runWithScenario( + async (params) => { + params.onTurnStart?.(1); + params.onAssistantMessage?.(finalAssistant, 1); + return { + assistant: finalAssistant, + messages: [finalAssistant], + emittedMessages: [finalAssistant], + }; + }, + harness.params, + ); + const call = registryBuildCalls.at(-1); + assert.ok(call, `registry was not built for mode ${commandSafetyMode ?? "undefined"}`); + assert.deepEqual(call.sandbox, expectedSandbox, `sandbox mapping for ${commandSafetyMode}`); + } +}); diff --git a/crates/agent-gui/test/chat/chat-stop-timing.test.mjs b/crates/agent-gui/test/chat/chat-stop-timing.test.mjs index 3d5c42d14..2511fd0d3 100644 --- a/crates/agent-gui/test/chat/chat-stop-timing.test.mjs +++ b/crates/agent-gui/test/chat/chat-stop-timing.test.mjs @@ -229,6 +229,9 @@ test("a direct queue stop pauses processing until composer Stop resumes it", asy }, }, "../gateway/gatewayBridgeTypes": { + normalizeGatewayCommandSafetyMode(value) { + return value === "sandboxOffline" ? value : undefined; + }, normalizeGatewayExecutionMode(value) { return value; }, @@ -248,6 +251,7 @@ test("a direct queue stop pauses processing until composer Stop resumes it", asy system: { executionMode: "chat", workdir: "", + commandSafetyMode: "sandboxOffline", }, chatRuntimeControls: {}, }, @@ -314,6 +318,11 @@ test("a direct queue stop pauses processing until composer Stop resumes it", asy queue.requestQueuedChatTurnProcessing("conversation-1"); await flushPromises(); assert.equal(sendCalls.length, 1); + assert.equal( + sendCalls[0].commandSafetyModeOverride, + "sandboxOffline", + "a queued local turn must snapshot the selected sandbox mode", + ); queue.stopConversation("conversation-1"); queue.stopConversation("conversation-1"); @@ -336,7 +345,39 @@ test("a direct queue stop pauses processing until composer Stop resumes it", asy await flushPromises(); assert.equal(sendCalls.length, 2, "composer Stop must continue with the queued turn"); + assert.equal(sendCalls[1].commandSafetyModeOverride, "sandboxOffline"); assert.equal(queue.queuedChatTurnsRef.current.length, 0); + + // A gateway request parked in the GUI queue must retain the remote mode on + // both the queued send and the reconstructed bridge request. + assert.equal( + await queue.enqueueGatewayChatRequest( + { + requestId: "gateway-request-sandbox", + clientRequestId: "gateway-client-sandbox", + request: { + requestId: "gateway-request-sandbox", + clientRequestId: "gateway-client-sandbox", + conversationId: "conversation-1", + message: "remote queued turn", + executionMode: "tools", + commandSafetyMode: "sandboxOffline", + queuePolicy: "append", + }, + }, + "conversation-1", + ), + true, + ); + queue.requestQueuedChatTurnProcessing("conversation-1"); + await flushPromises(); + await flushPromises(); + const gatewaySend = sendCalls.at(-1); + assert.equal(gatewaySend.commandSafetyModeOverride, "sandboxOffline"); + assert.equal( + gatewaySend.gatewayBridgeRequestOverride.commandSafetyModeOverride, + "sandboxOffline", + ); hookHarness.cleanup(); }); diff --git a/crates/agent-gui/test/chat/gateway-bridge-listeners.test.mjs b/crates/agent-gui/test/chat/gateway-bridge-listeners.test.mjs index 44ed7b03f..0e67c8966 100644 --- a/crates/agent-gui/test/chat/gateway-bridge-listeners.test.mjs +++ b/crates/agent-gui/test/chat/gateway-bridge-listeners.test.mjs @@ -274,3 +274,150 @@ test("gateway bridge listener keeps one worker across renders and handles native else globalThis.document = previousDocument; } }); + +test("gateway bridge forwards sandboxOffline on a directly claimed agent turn", async () => { + const hookHarness = createHookHarness(); + const invokeCalls = []; + const sendCalls = []; + const windowEvents = createEventTarget(); + const documentEvents = createEventTarget(); + let nextTimerId = 1; + const timers = new Map(); + const claimed = { + requestId: "request-sandbox-direct", + clientRequestId: "client-sandbox-direct", + conversationId: "conversation-sandbox-direct", + state: "claimed", + attempt: 1, + leaseMs: 15_000, + request: { + requestId: "request-sandbox-direct", + clientRequestId: "client-sandbox-direct", + conversationId: "conversation-sandbox-direct", + message: "run inside the offline sandbox", + executionMode: "tools", + workdir: "/workspace/project", + commandSafetyMode: "sandboxOffline", + queuePolicy: "auto", + }, + }; + const claims = [claimed, null]; + + const previousWindow = globalThis.window; + const previousDocument = globalThis.document; + globalThis.window = { + ...windowEvents, + setInterval(callback) { + const id = nextTimerId++; + timers.set(id, callback); + return id; + }, + clearInterval(id) { + timers.delete(id); + }, + setTimeout, + clearTimeout, + }; + globalThis.document = { + ...documentEvents, + visibilityState: "visible", + }; + + try { + const registry = globalThis.__LIVEAGENT_GATEWAY_BRIDGE_REQUESTS__; + registry?.activeRequests?.clear(); + registry?.pendingRequestIds?.clear(); + registry?.pendingClientRequestIds?.clear(); + registry?.pendingConversationIds?.clear(); + + const loader = createTsModuleLoader({ + mocks: { + react: hookHarness.react, + "@tauri-apps/api/core": { + async invoke(command, payload) { + invokeCalls.push({ command, payload }); + if (command === "gateway_chat_claim_next") { + return claims.shift() ?? null; + } + return undefined; + }, + }, + "@tauri-apps/api/event": { + async listen() { + return () => undefined; + }, + }, + "../../../lib/settings": { + normalizeChatRuntimeControls(value) { + return value; + }, + }, + }, + }); + const { useGatewayBridgeListeners } = loader.loadModule( + "src/pages/chat/gateway/useGatewayBridgeListeners.ts", + ); + + hookHarness.render(() => + useGatewayBridgeListeners({ + currentConversationIdRef: { current: "conversation-sandbox-direct" }, + conversationRuntimeCacheRef: { current: new Map() }, + ensureGatewayBridgeConversationReadyRef: { + current: async (conversationId) => conversationId, + }, + sendActionRef: { + current: async (overrides) => { + sendCalls.push(overrides); + return true; + }, + }, + queueGatewayBridgeEventForRequest() {}, + shouldQueueGatewayChatRequest() { + return false; + }, + async enqueueGatewayChatRequest() { + return false; + }, + isConversationRunning() { + return false; + }, + getConversationAbortController() { + return null; + }, + requestConversationStop() { + return false; + }, + requestActiveConversationStop() { + return false; + }, + consumeConversationStop() { + return false; + }, + }), + ); + + for (let attempt = 0; attempt < 8 && sendCalls.length === 0; attempt += 1) { + await flushPromises(); + } + + assert.equal(sendCalls.length, 1); + const overrides = sendCalls[0]; + assert.equal(overrides.commandSafetyModeOverride, "sandboxOffline"); + assert.equal( + overrides.gatewayBridgeRequestOverride.commandSafetyModeOverride, + "sandboxOffline", + ); + assert.equal(overrides.executionModeOverride, "tools"); + assert.equal(overrides.workdirOverride, "/workspace/project"); + assert.ok( + invokeCalls.some((call) => call.command === "gateway_chat_complete"), + "the directly claimed request should complete after the sandbox mode is forwarded", + ); + hookHarness.cleanup(); + } finally { + if (previousWindow === undefined) delete globalThis.window; + else globalThis.window = previousWindow; + if (previousDocument === undefined) delete globalThis.document; + else globalThis.document = previousDocument; + } +}); diff --git a/crates/agent-gui/test/settings/automation-prompt-runner.test.mjs b/crates/agent-gui/test/settings/automation-prompt-runner.test.mjs index b5ce5f1eb..e4e1c37ec 100644 --- a/crates/agent-gui/test/settings/automation-prompt-runner.test.mjs +++ b/crates/agent-gui/test/settings/automation-prompt-runner.test.mjs @@ -176,6 +176,13 @@ test("Auto Prompt run prefers the queue-time workdir with a global fallback", () ); }); +test("Auto Prompt applies the system command safety mode to its shell registry", () => { + assert.match( + runnerSource, + /sandbox:\s*resolveShellSandboxSettings\(settings\.system\.commandSafetyMode\)/, + ); +}); + test("Cron workspace pin stays wired in shared UI", () => { for (const source of [cronModalSource]) { // Radix SelectItem rejects empty-string values, so "follow active" must diff --git a/crates/agent-gui/test/tools/builtin-registry-subagent-mcp.test.mjs b/crates/agent-gui/test/tools/builtin-registry-subagent-mcp.test.mjs index 898da9710..3ec4c4af2 100644 --- a/crates/agent-gui/test/tools/builtin-registry-subagent-mcp.test.mjs +++ b/crates/agent-gui/test/tools/builtin-registry-subagent-mcp.test.mjs @@ -33,16 +33,18 @@ const DOCS_SERVER = { env: {}, }; -function createRegistryHarness() { +function createRegistryHarness({ onRun } = {}) { const runnerCalls = []; const listedServerIds = []; const listedServerCommands = []; + const invokeCalls = []; const loader = createTsModuleLoader({ mocks: { [agentRunnerModulePath]: { async runAssistantWithTools(params) { runnerCalls.push(params); params.onTurnStart?.(1); + if (onRun) return onRun(params); const assistant = createAssistant("subagent done"); return { assistant, messages: [assistant], emittedMessages: [assistant] }; }, @@ -54,6 +56,7 @@ function createRegistryHarness() { }, "@tauri-apps/api/core": { async invoke(command, args) { + invokeCalls.push({ command, args }); if (command === "mcp_list_tools") { listedServerIds.push((args.servers ?? []).map((server) => server.id)); listedServerCommands.push((args.servers ?? []).map((server) => server.command)); @@ -93,15 +96,33 @@ function createRegistryHarness() { branchDeleted: true, }; } + if (command === "shell_session_start") { + return { + status: "completed", + session_id: args.session_id, + cursor: 0, + output: [], + output_truncated: false, + has_more: false, + exit_code: 0, + duration_ms: 1, + shell: "bash", + platform: "linux", + profile: "posix-bash", + shell_family: "posix", + sandbox: "bubblewrap", + timeout_ms: null, + }; + } throw new Error(`Unexpected invoke: ${command}`); }, }, }, }); - return { loader, runnerCalls, listedServerIds, listedServerCommands }; + return { loader, runnerCalls, listedServerIds, listedServerCommands, invokeCalls }; } -async function buildRegistry(harness, { withSubagentRuntime, storeIpc } = {}) { +async function buildRegistry(harness, { withSubagentRuntime, storeIpc, sandbox } = {}) { const { loader } = harness; const { buildBuiltinToolRegistry } = loader.loadModule("src/lib/tools/builtinRegistry.ts"); const { createFileToolState } = loader.loadModule("src/lib/tools/fileToolState.ts"); @@ -113,6 +134,7 @@ async function buildRegistry(harness, { withSubagentRuntime, storeIpc } = {}) { skillsEnabled: true, runtimeScope: "chat", getMcpSettings: () => mcpSettingsHolder.value, + sandbox, }; if (!withSubagentRuntime) { return { registry: await buildBuiltinToolRegistry(baseParams), mcpSettingsHolder }; @@ -266,6 +288,50 @@ test("worktree children get fs/shell/ro-memory/MCP tools but no skills, system, assert.equal(harness.runnerCalls[0].workdir, "/tmp/liveagent-subagents/agent-a"); }); +test("worktree children inherit the parent offline sandbox when Bash executes", async () => { + const harness = createRegistryHarness({ + onRun: async (params) => { + const toolCall = { + type: "toolCall", + id: "child-bash", + name: "Bash", + arguments: { command: "printf child" }, + }; + params.onToolExecutionStart?.(toolCall); + const toolResult = await params.executeToolCall(toolCall); + assert.equal(toolResult.isError, false); + const assistant = createAssistant("subagent done"); + return { + assistant, + messages: [toolCall, toolResult, assistant], + emittedMessages: [toolCall, toolResult, assistant], + }; + }, + }); + const { registry } = await buildRegistry(harness, { + withSubagentRuntime: true, + sandbox: { enabled: true, allowNetwork: false }, + }); + + const bash = registry.tools.find((tool) => tool.name === "Bash"); + assert.match(bash.description, /Sandbox mode is ON/); + + const result = await registry.executeToolCall( + createAgentToolCall({ + agents: [{ id: "agent-a", prompt: "Run the probe.", mode: "worktree" }], + }), + ); + assert.equal(result.isError, false); + assert.equal(harness.runnerCalls.length, 1); + assert.equal(harness.runnerCalls[0].workdir, "/tmp/liveagent-subagents/agent-a"); + + const sessionStart = harness.invokeCalls.find((call) => call.command === "shell_session_start"); + assert.ok(sessionStart); + assert.equal(sessionStart.args.workdir, "/tmp/liveagent-subagents/agent-a"); + assert.equal(sessionStart.args.sandbox, true); + assert.equal(sessionStart.args.sandbox_allow_network, false); +}); + test("subagent registries list MCP servers from live settings, not turn-start snapshots", async () => { const harness = createRegistryHarness(); const { registry, mcpSettingsHolder } = await buildRegistry(harness, { diff --git a/crates/agent-gui/test/tools/mcp-manager-tools.test.mjs b/crates/agent-gui/test/tools/mcp-manager-tools.test.mjs index c94ee5711..0d6080cf7 100644 --- a/crates/agent-gui/test/tools/mcp-manager-tools.test.mjs +++ b/crates/agent-gui/test/tools/mcp-manager-tools.test.mjs @@ -23,6 +23,7 @@ function createMcpBundle({ invokeImpl, writable = true, resolveHomeDir, + sandbox, } = {}) { const invocations = []; const updates = []; @@ -57,6 +58,7 @@ function createMcpBundle({ } : undefined, runtimeScope, + sandbox, resolveHomeDir, }); @@ -839,3 +841,138 @@ test("McpManager abort after commit skips remaining runtime cleanup with a warni ); assert.match(result.content[0].text, /cancelled before runtime cleanup for other/); }); +// P1#1:沙箱模式下 McpManager 不得成为无围栏的 stdio 进程 spawn 入口。 +test("McpManager refuses stdio runtime probes while the OS sandbox is active", async () => { + for (const action of ["test", "tools", "restart", "diagnose"]) { + const { bundle, invocations } = createMcpBundle({ + settings: { servers: [baseServer], selected: [] }, + sandbox: { enabled: true, allowNetwork: false }, + }); + + const inline = await callMcpManager(bundle, { + action, + server: { id: "inline", command: "curl", args: ["https://example.com"] }, + }); + assert.equal(inline.isError, true, `${action} with an inline server must be refused`); + assert.match(inline.content[0].text, /would spawn a local stdio process/); + + const persisted = await callMcpManager(bundle, { action, server_id: "demo" }); + assert.equal(persisted.isError, true, `${action} on a persisted stdio server must be refused`); + + // 拒绝发生在任何 IPC 之前:没有任何进程被拉起。 + assert.deepEqual( + invocations.filter((call) => call.command !== "mcp_runtime_status"), + [], + ); + } +}); + +test("McpManager still probes non-stdio transports under the OS sandbox", async () => { + const httpServer = { + ...baseServer, + id: "http-demo", + transport: "http", + command: "", + url: "https://mcp.example.com/rpc", + }; + const { bundle } = createMcpBundle({ + settings: { servers: [httpServer], selected: [] }, + sandbox: { enabled: true, allowNetwork: true }, + invokeImpl(command, args) { + if (command === "mcp_runtime_status") { + return { + serverId: args.server_id, + running: false, + initialized: false, + transport: "http", + lastError: null, + }; + } + assert.equal(command, "mcp_test_server"); + return { + serverId: "http-demo", + ok: true, + phase: "initialize", + transport: "http", + durationMs: 1, + running: true, + initialized: true, + toolsCount: 0, + tools: [], + }; + }, + }); + + const result = await callMcpManager(bundle, { action: "test", server_id: "http-demo" }); + assert.equal(result.isError, false); +}); + +test("McpManager refuses stdio create/update/enable while the OS sandbox is active", async () => { + const created = createMcpBundle({ + sandbox: { enabled: true, allowNetwork: false }, + }); + const createResult = await callMcpManager(created.bundle, { + action: "create", + server: { id: "planted", transport: "stdio", command: "curl", args: ["https://example.com"] }, + }); + assert.equal(createResult.isError, true); + assert.match(createResult.content[0].text, /would spawn a local stdio process/); + assert.deepEqual(created.updates, []); + + const updated = createMcpBundle({ + settings: { servers: [baseServer], selected: [] }, + sandbox: { enabled: true, allowNetwork: true }, + }); + const updateResult = await callMcpManager(updated.bundle, { + action: "update", + server_id: "demo", + patch: { command: "curl", args: ["https://example.com"] }, + }); + assert.equal(updateResult.isError, true); + assert.match(updateResult.content[0].text, /would spawn a local stdio process/); + assert.deepEqual(updated.updates, []); + + const disabledStdio = { ...baseServer, enabled: false }; + const enabled = createMcpBundle({ + settings: { servers: [disabledStdio], selected: [] }, + sandbox: { enabled: true, allowNetwork: false }, + }); + const enableResult = await callMcpManager(enabled.bundle, { + action: "enable", + server_ids: ["demo"], + }); + assert.equal(enableResult.isError, true); + assert.match(enableResult.content[0].text, /would spawn a local stdio process/); + assert.deepEqual(enabled.updates, []); +}); + +test("McpManager still creates http servers and disables stdio under the OS sandbox", async () => { + const created = createMcpBundle({ + sandbox: { enabled: true, allowNetwork: true }, + }); + const createResult = await callMcpManager(created.bundle, { + action: "create", + server: { + id: "http-demo", + transport: "http", + url: "https://mcp.example.com/rpc", + }, + }); + assert.equal(createResult.isError, false); + assert.equal(created.updates.at(-1).servers[0].id, "http-demo"); + + const disabled = createMcpBundle({ + settings: { servers: [baseServer], selected: [] }, + sandbox: { enabled: true, allowNetwork: false }, + invokeImpl(command, args) { + assert.equal(command, "mcp_stop_server"); + return { serverId: args.server_id, stopped: true }; + }, + }); + const disableResult = await callMcpManager(disabled.bundle, { + action: "disable", + server_ids: ["demo"], + }); + assert.equal(disableResult.isError, false); + assert.equal(disabled.updates.at(-1).servers[0].enabled, false); +}); diff --git a/crates/agent-gui/test/tools/sandbox-policy.test.mjs b/crates/agent-gui/test/tools/sandbox-policy.test.mjs new file mode 100644 index 000000000..f207ee70f --- /dev/null +++ b/crates/agent-gui/test/tools/sandbox-policy.test.mjs @@ -0,0 +1,51 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { createTsModuleLoader } from "../helpers/load-ts-module.mjs"; + +const loader = createTsModuleLoader(); +const { resolveShellSandboxSettings } = loader.loadModule("src/lib/tools/sandboxPolicy.ts"); +const { normalizeCommandSafetyMode, strictestCommandSafetyMode } = loader.loadModule( + "src/lib/settings/index.ts", +); + +test("command safety modes resolve to one explicit shell sandbox contract", () => { + assert.equal(resolveShellSandboxSettings("ask"), undefined); + assert.equal(resolveShellSandboxSettings("auto"), undefined); + assert.deepEqual(resolveShellSandboxSettings("sandbox"), { + enabled: true, + allowNetwork: true, + }); + assert.deepEqual(resolveShellSandboxSettings("sandboxOffline"), { + enabled: true, + allowNetwork: false, + }); +}); + +test("missing safety modes preserve the existing unsandboxed default", () => { + assert.equal(resolveShellSandboxSettings(undefined), undefined); + assert.equal(resolveShellSandboxSettings(null), undefined); +}); + +// P2#6:未识别的模式值必须向严格侧收敛,不能静默降级成最宽松的非 ask 值。 +test("unrecognized command safety modes fail closed to ask", () => { + assert.equal(normalizeCommandSafetyMode(undefined), "auto"); + assert.equal(normalizeCommandSafetyMode(null), "auto"); + assert.equal(normalizeCommandSafetyMode(" "), "auto"); + for (const mode of ["ask", "auto", "sandbox", "sandboxOffline"]) { + assert.equal(normalizeCommandSafetyMode(` ${mode} `), mode); + } + assert.equal(normalizeCommandSafetyMode("sandboxStrictest"), "ask"); + assert.equal(normalizeCommandSafetyMode("Auto"), "ask"); + assert.equal(normalizeCommandSafetyMode(1), "ask"); +}); + +// P3#9:远端/排队快照只能收紧本地设置,绝不能放宽。 +test("command safety mode clamping always keeps the stricter side", () => { + assert.equal(strictestCommandSafetyMode("auto", "sandboxOffline"), "sandboxOffline"); + assert.equal(strictestCommandSafetyMode("sandboxOffline", "auto"), "sandboxOffline"); + assert.equal(strictestCommandSafetyMode("sandbox", "sandboxOffline"), "sandboxOffline"); + assert.equal(strictestCommandSafetyMode("auto", "sandbox"), "sandbox"); + assert.equal(strictestCommandSafetyMode("sandbox", "ask"), "ask"); + assert.equal(strictestCommandSafetyMode("auto", "auto"), "auto"); +}); diff --git a/crates/agent-gui/test/tools/shell-tools.test.mjs b/crates/agent-gui/test/tools/shell-tools.test.mjs index a4bf33b65..e8f6e6a71 100644 --- a/crates/agent-gui/test/tools/shell-tools.test.mjs +++ b/crates/agent-gui/test/tools/shell-tools.test.mjs @@ -67,6 +67,8 @@ test("Bash tool keeps one Bash entry and uses Git Bash-first policy for Claude C assert.equal(calls.length, 1); assert.equal(calls[0].args.provider_id, "claude_code"); assert.equal(calls[0].args.max_timeout_ms, 600_000); + assert.equal(calls[0].args.sandbox, false); + assert.equal(calls[0].args.sandbox_allow_network, true); assert.match(result.content[0].text, /platform: windows/); assert.match(result.content[0].text, /profile: windows-git-bash/); }); @@ -116,6 +118,100 @@ test("Bash tool uses the same Git Bash-first policy for Codex", async () => { assert.equal(calls.length, 1); assert.equal(calls[0].args.provider_id, "codex"); assert.equal(calls[0].args.max_timeout_ms, 30_000); + assert.equal(calls[0].args.sandbox, false); + assert.equal(calls[0].args.sandbox_allow_network, true); +}); + +test("sandboxed one-shot Bash forwards the offline sandbox contract", async () => { + const calls = []; + const loader = createTsModuleLoader({ + mocks: { + "@tauri-apps/api/core": { + async invoke(command, args) { + calls.push({ command, args }); + assert.equal(command, "shell_run"); + return { + exit_code: 0, + shell: "zsh", + platform: "macos", + profile: "posix-zsh", + shell_family: "posix", + sandbox: "seatbelt", + stdout: "ready\n", + stderr: "", + stdout_truncated: false, + stderr_truncated: false, + timed_out: false, + cancelled: false, + effective_timeout_ms: args.timeout_ms, + duration_ms: 12, + }; + }, + }, + }, + }); + + const { createShellTools } = loader.loadModule("src/lib/tools/shellTools.ts"); + const bundle = createShellTools({ + workdir: "/repo", + providerId: "codex", + runtimePlatform: "macos", + managedProcessEnabled: false, + resumableShellEnabled: false, + sandbox: { enabled: true, allowNetwork: false }, + }); + + const result = await bundle.executeToolCall(createBashCall()); + + assert.equal(result.isError, false); + assert.equal(calls.length, 1); + assert.equal(calls[0].args.sandbox, true); + assert.equal(calls[0].args.sandbox_allow_network, false); + assert.match(result.content[0].text, /sandbox: seatbelt/); +}); + +test("sandboxed Bash describes credential masking only when the backend can mask reads", async () => { + const loader = createTsModuleLoader({ + mocks: { + "@tauri-apps/api/core": { + async invoke() { + throw new Error("should not invoke"); + }, + }, + }, + }); + const { createShellTools } = loader.loadModule("src/lib/tools/shellTools.ts"); + + const windowsOnline = createShellTools({ + workdir: "/repo", + providerId: "codex", + runtimePlatform: "windows", + managedProcessEnabled: false, + resumableShellEnabled: false, + sandbox: { enabled: true, allowNetwork: true }, + }); + assert.match(windowsOnline.tools[0].description, /Sandbox mode is ON/); + assert.doesNotMatch(windowsOnline.tools[0].description, /credential dirs/); + + const windowsOffline = createShellTools({ + workdir: "/repo", + providerId: "codex", + runtimePlatform: "windows", + managedProcessEnabled: false, + resumableShellEnabled: false, + sandbox: { enabled: true, allowNetwork: false }, + }); + assert.match(windowsOffline.tools[0].description, /credential dirs \(~\/\.ssh etc\.\) are masked/); + + const macosOnline = createShellTools({ + workdir: "/repo", + providerId: "codex", + runtimePlatform: "macos", + managedProcessEnabled: false, + resumableShellEnabled: false, + sandbox: { enabled: true, allowNetwork: true }, + }); + assert.match(macosOnline.tools[0].description, /credential dirs \(~\/\.ssh etc\.\) are masked/); }); test("Bash tool schema allows larger timeout values but clamps for Codex", async () => { @@ -536,9 +632,122 @@ test("ManagedProcess starts foreground commands through process manager", async assert.equal(result.isError, false); assert.match(result.content[0].text, /ManagedProcess started/); assert.match(result.content[0].text, /id=proc-1/); + assert.match(result.content[0].text, /action="wait"/); + assert.match(result.content[0].text, /Do not use ProcessWait/); assert.equal(calls.length, 1); assert.equal(calls[0].args.workdir, "/repo"); assert.equal(calls[0].args.cwd, "app"); + assert.equal(calls[0].args.sandbox, false); + assert.equal(calls[0].args.sandbox_allow_network, true); +}); + +test("ManagedProcess wait blocks through process manager", async () => { + const calls = []; + const loader = createTsModuleLoader({ + mocks: { + "@tauri-apps/api/core": { + async invoke(command, args) { + calls.push({ command, args }); + assert.equal(command, "managed_process_wait"); + return { + process: { + id: "proc-1", + label: "dev", + command: "echo ready; sleep 30", + cwd: "/repo", + shell: "zsh", + pid: 123, + log_path: "/Users/me/.liveagent/process-logs/proc-1.log", + started_at: 10, + finished_at: null, + exit_code: null, + running: true, + }, + log_path: "/Users/me/.liveagent/process-logs/proc-1.log", + content: "ready\n", + truncated: false, + bytes: 6, + cursor: 6, + timed_out: false, + }; + }, + }, + }, + }); + + const { createShellTools } = loader.loadModule("src/lib/tools/shellTools.ts"); + const bundle = createShellTools({ + workdir: "/repo", + providerId: "claude_code", + }); + + const result = await bundle.executeToolCall({ + type: "toolCall", + id: "managed-wait", + name: "ManagedProcess", + arguments: { + action: "wait", + process_id: "proc-1", + cursor: 0, + }, + }); + + assert.equal(result.isError, false); + assert.match(result.content[0].text, /ManagedProcess wait/); + assert.match(result.content[0].text, /cursor=6/); + assert.match(result.content[0].text, /ready/); + assert.match(result.content[0].text, /action="wait".*cursor=6/); + assert.equal(calls[0].args.process_id, "proc-1"); + assert.equal(calls[0].args.cursor, 0); + assert.equal(calls[0].args.yield_time_ms, 30_000); +}); + +test("sandboxed ManagedProcess forwards the offline sandbox contract", async () => { + const calls = []; + const loader = createTsModuleLoader({ + mocks: { + "@tauri-apps/api/core": { + async invoke(command, args) { + calls.push({ command, args }); + assert.equal(command, "managed_process_start"); + return { + process: { + id: "proc-sandboxed", + label: null, + command: args.command, + cwd: "/repo", + shell: "zsh", + pid: 124, + log_path: "/tmp/proc-sandboxed.log", + started_at: 10, + finished_at: null, + exit_code: null, + running: true, + }, + }; + }, + }, + }, + }); + + const { createShellTools } = loader.loadModule("src/lib/tools/shellTools.ts"); + const bundle = createShellTools({ + workdir: "/repo", + providerId: "codex", + sandbox: { enabled: true, allowNetwork: false }, + }); + + const result = await bundle.executeToolCall({ + type: "toolCall", + id: "managed-sandboxed", + name: "ManagedProcess", + arguments: { action: "start", command: "pnpm dev" }, + }); + + assert.equal(result.isError, false); + assert.equal(calls.length, 1); + assert.equal(calls[0].args.sandbox, true); + assert.equal(calls[0].args.sandbox_allow_network, false); }); test("ManagedProcess abort stops a process returned after cancellation", async () => { @@ -1054,6 +1263,8 @@ test("resumable Bash yields a session without applying an implicit hard timeout" // 都按全局上限(600s)收敛,避免 codex 系 30s cap 误杀长任务。 assert.equal(calls[0].args.max_timeout_ms, 600_000); assert.equal(calls[0].args.provider_id, undefined); + assert.equal(calls[0].args.sandbox, false); + assert.equal(calls[0].args.sandbox_allow_network, true); assert.match(result.content[0].text, /status: running/); assert.match(result.content[0].text, /session_duration_ms: 10003/); assert.doesNotMatch(result.content[0].text, /^duration_ms:/m); @@ -1062,6 +1273,58 @@ test("resumable Bash yields a session without applying an implicit hard timeout" assert.doesNotMatch(result.content[0].text, /Bash sleep 10/); }); +test("sandboxed resumable Bash forwards the sandbox contract to the session", async () => { + const calls = []; + const loader = createTsModuleLoader({ + mocks: { + "@tauri-apps/api/core": { + async invoke(command, args) { + calls.push({ command, args }); + assert.equal(command, "shell_session_start"); + return { + status: "completed", + session_id: args.session_id, + cursor: 0, + output: [], + output_truncated: false, + has_more: false, + exit_code: 0, + duration_ms: 5, + shell: "bash", + platform: "windows", + profile: "windows-git-bash", + shell_family: "posix", + sandbox: "low-integrity-token", + timeout_ms: null, + }; + }, + }, + }, + }); + const { createShellTools } = loader.loadModule("src/lib/tools/shellTools.ts"); + const bundle = createShellTools({ + workdir: "D:/workspace/project", + providerId: "codex", + runtimePlatform: "windows", + managedProcessEnabled: false, + resumableShellEnabled: true, + sandbox: { enabled: true, allowNetwork: true }, + }); + + const result = await bundle.executeToolCall({ + type: "toolCall", + id: "sandboxed-session", + name: "Bash", + arguments: { command: "echo probe" }, + }); + + assert.equal(result.isError, false); + assert.equal(calls.length, 1); + assert.equal(calls[0].args.sandbox, true); + assert.equal(calls[0].args.sandbox_allow_network, true); + assert.match(result.content[0].text, /sandbox: low-integrity-token/); +}); + test("resumable Bash stops a running session returned after cancellation", async () => { let resolveStart; let startResolved = false; diff --git a/crates/agent-ui/src/components/IconSet.tsx b/crates/agent-ui/src/components/IconSet.tsx index 62b0ef1e5..e8d22268a 100644 --- a/crates/agent-ui/src/components/IconSet.tsx +++ b/crates/agent-ui/src/components/IconSet.tsx @@ -56,6 +56,7 @@ import GithubSource from "~icons/lucide/github"; import GlobeSource from "~icons/lucide/globe"; import GlobeOffSource from "~icons/lucide/globe-off"; import GripVerticalSource from "~icons/lucide/grip-vertical"; +import HandSource from "~icons/lucide/hand"; import HardDriveSource from "~icons/lucide/hard-drive"; import HistorySource from "~icons/lucide/history"; import HouseSource from "~icons/lucide/house"; @@ -115,6 +116,7 @@ import SettingsSource from "~icons/lucide/settings"; import Settings2Source from "~icons/lucide/settings-2"; import Share2Source from "~icons/lucide/share-2"; import ShieldSource from "~icons/lucide/shield"; +import ShieldOffSource from "~icons/lucide/shield-off"; import SparkleSource from "~icons/lucide/sparkle"; import SparklesSource from "~icons/lucide/sparkles"; import SquareSource from "~icons/lucide/square"; @@ -526,6 +528,7 @@ export const GitBranch = createIcon(GitBranchSource); export const GitCommitHorizontal = createIcon(GitCommitHorizontalSource); export const Github = createIcon(GithubSource); export const GripVertical = createIcon(GripVerticalSource); +export const Hand = createIcon(HandSource); export const HardDrive = createIcon(HardDriveSource); export const History = createIcon(HistorySource); export const House = createIcon(HouseSource); @@ -588,6 +591,7 @@ export const Settings = createIcon(SettingsSource); export const Settings2 = createIcon(Settings2Source); export const Share2 = createIcon(Share2Source); export const Shield = createIcon(ShieldSource); +export const ShieldOff = createIcon(ShieldOffSource); export const SkillIcon = createIcon(SkillIconSource); export const Sparkle = createIcon(SparkleSource); export const Sparkles = createIcon(SparklesSource); diff --git a/crates/agent-ui/src/components/chat/CommandSafetyModeSelector.tsx b/crates/agent-ui/src/components/chat/CommandSafetyModeSelector.tsx new file mode 100644 index 000000000..b0de0455c --- /dev/null +++ b/crates/agent-ui/src/components/chat/CommandSafetyModeSelector.tsx @@ -0,0 +1,167 @@ +import { useSandboxCapability } from "@liveagent/adapters/sandboxCapability"; +import type { CommandSafetyMode } from "@liveagent/app/lib/settings"; +import { + Check, + ChevronDown, + Hand, + Shield, + ShieldOff, + Zap, +} from "@liveagent/ui/components/IconSet"; +import { Button } from "@liveagent/ui/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@liveagent/ui/components/ui/dropdown-menu"; +import { useLocale } from "@liveagent/ui/i18n/index"; +import { + COMPOSER_CONTROL_CHEVRON_CLASS, + COMPOSER_CONTROL_LABEL_CLASS, + COMPOSER_CONTROL_TRIGGER_CLASS, +} from "@liveagent/ui/lib/chat/composerControlStyles"; +import { cn } from "@liveagent/ui/lib/shared/utils"; +import { useState } from "react"; + +const MODE_I18N_KEYS: Record = { + ask: "chat.safety.ask", + auto: "chat.safety.auto", + sandbox: "chat.safety.sandbox", + sandboxOffline: "chat.safety.sandboxOffline", +}; + +const MODE_DESC_I18N_KEYS: Record = { + ask: "chat.safety.askDesc", + auto: "chat.safety.autoDesc", + sandbox: "chat.safety.sandboxDesc", + sandboxOffline: "chat.safety.sandboxOfflineDesc", +}; + +const SAFETY_MODES = ["ask", "auto", "sandbox", "sandboxOffline"] as const; + +/** + * 读掩蔽是平台/后端相关的能力,不能作为跨平台的肯定承诺(P2#7)。 + * macOS 用 `(deny file-read* (subpath …))`、Linux 用 `--tmpfs` 掩蔽,文案成立; + * Windows 的联网后端是 WRITE_RESTRICTED 受限令牌 —— 限制性 SID 只参与"写"判定, + * 读/执行跳过第二遍,且写 ACE 只授不撤,**没有任何读掩蔽**。若沿用同一句"敏感目录 + * 不可读",Windows 用户会据此认为凭据受保护而在沙箱模式下跑不可信代码,而这恰恰是 + * 可联网的后端(~/.ssh、%USERPROFILE%\.aws\credentials、config.sqlite 里的 provider + * key 都可读并外传)。故该平台改用不含读掩蔽承诺的文案。 + * 断网后端(AppContainer)默认拒读,顺带获得掩蔽,sandboxOffline 文案不受影响。 + */ +const MECHANISMS_WITHOUT_READ_MASKING: ReadonlySet = new Set(["restricted-token"]); + +function isCommandSafetyMode(value: unknown): value is CommandSafetyMode { + return value === "ask" || value === "auto" || value === "sandbox" || value === "sandboxOffline"; +} + +function modeIcon(mode: CommandSafetyMode, className: string) { + if (mode === "ask") return ; + if (mode === "auto") return ; + if (mode === "sandboxOffline") return ; + return ; +} + +function triggerIconClass(mode: CommandSafetyMode) { + if (mode === "sandbox" || mode === "sandboxOffline") { + return "h-4 w-4 shrink-0 text-emerald-600 dark:text-emerald-400"; + } + if (mode === "ask") return "h-4 w-4 shrink-0 text-sky-600 dark:text-sky-400"; + return "h-4 w-4 shrink-0 text-muted-foreground"; +} + +export function CommandSafetyModeSelector(props: { + value: CommandSafetyMode; + disabled?: boolean; + onChange: (mode: CommandSafetyMode) => void; +}) { + const { value, disabled, onChange } = props; + const { t } = useLocale(); + const [menuOpen, setMenuOpen] = useState(false); + const capability = useSandboxCapability(); + // 写围栏(sandbox):平台不支持时禁用。桌面端探测返回前(null)乐观启用,由执行层 + // fail-closed 兜底;WebUI 执行端平台未知,同样交由 fail-closed。 + const sandboxUnavailable = capability !== null && !capability.supported; + // 断网(sandboxOffline):额外要求平台可断网。探测判定 network_control=false 时 + // (如 Windows 派生不出 AppContainer SID)仅此项禁用,sandbox 仍可用。 + const offlineUnavailable = + sandboxUnavailable || (capability !== null && !capability.network_control); + // 禁用时的说明文案:整体不可用优先,否则为“仅断网不可用”。 + const disabledHint = sandboxUnavailable + ? t("chat.safety.sandboxUnavailable") + : t("chat.safety.sandboxOfflineUnavailable"); + // 联网写围栏后端是否缺失读掩蔽(Windows 受限令牌);缺失时 sandbox 项换用不承诺 + // “敏感目录不可读”的文案。探测返回前(null)以及 WebUI(永远拿不到桌面 mechanism) + // 保守按缺失处理,避免先给出过强承诺。 + const sandboxLacksReadMasking = + capability === null ? true : MECHANISMS_WITHOUT_READ_MASKING.has(capability.mechanism); + const modeDescKey = (mode: CommandSafetyMode) => + mode === "sandbox" && sandboxLacksReadMasking + ? "chat.safety.sandboxDescNoReadMask" + : MODE_DESC_I18N_KEYS[mode]; + // 当前值本身不可用(如设置同步自 macOS,本机是 Windows)时仍显示,但标红提示 + // 由执行层报错兜底;这里不做静默改写,避免设置回写抖动。 + const selected = isCommandSafetyMode(value) ? value : "auto"; + const selectedLabel = t(MODE_I18N_KEYS[selected]); + + return ( + + + } + title={selectedLabel} + aria-label={`${t("chat.safety.label")}: ${selectedLabel}`} + > + {modeIcon(selected, triggerIconClass(selected))} + {selectedLabel} + + + + {SAFETY_MODES.map((mode) => { + const entryDisabled = + mode === "sandbox" + ? sandboxUnavailable + : mode === "sandboxOffline" + ? offlineUnavailable + : false; + const isSelected = mode === selected; + return ( + onChange(mode)} + className={cn( + "composer-safety-item items-start gap-2 whitespace-normal rounded-md py-1.5 text-xs", + isSelected && + "bg-foreground/[0.07] font-medium data-[highlighted]:bg-foreground/[0.09]", + )} + > + {modeIcon(mode, "mt-0.5 h-3.5 w-3.5 shrink-0 text-muted-foreground")} + + {t(MODE_I18N_KEYS[mode])} + + {entryDisabled ? disabledHint : t(modeDescKey(mode))} + + + {isSelected ? ( + + ) : ( + + )} + + ); + })} + + + ); +} diff --git a/crates/agent-ui/src/i18n/translations/enUSCommon.ts b/crates/agent-ui/src/i18n/translations/enUSCommon.ts index c6e100a31..e062f40b9 100644 --- a/crates/agent-ui/src/i18n/translations/enUSCommon.ts +++ b/crates/agent-ui/src/i18n/translations/enUSCommon.ts @@ -363,6 +363,23 @@ export const EN_US_COMMON_TRANSLATIONS = { "chat.runtime.tunnelRemoteOffline": "Remote Gateway is offline", "chat.runtime.tunnelSettingsSyncing": "Syncing desktop settings", "chat.runtime.reasoning": "Thinking effort", + "chat.safety.label": "Command execution mode", + "chat.safety.ask": "Approve each", + "chat.safety.askDesc": "Ask before every command or write; read-only tools run freely", + "chat.safety.auto": "Auto", + "chat.safety.autoDesc": "Run directly per the tool permission policies in Settings", + "chat.safety.sandbox": "Sandbox", + "chat.safety.sandboxDesc": + "Commands run in an OS sandbox: writes limited to the project folder, sensitive dirs like ~/.ssh unreadable", + // The Windows networked backend (WRITE_RESTRICTED token) fences writes only; reads stay + // broadly allowed, so this platform must not promise credential read masking. Masking is + // only real on the offline (AppContainer) backend. + "chat.safety.sandboxDescNoReadMask": + "Commands run in an OS sandbox: writes limited to the project folder. Reads are not masked on this platform, so ~/.ssh and other sensitive dirs stay readable", + "chat.safety.sandboxOffline": "Sandbox · offline", + "chat.safety.sandboxOfflineDesc": "Sandbox plus no network access for commands", + "chat.safety.sandboxUnavailable": "Sandbox mechanism unavailable on this platform", + "chat.safety.sandboxOfflineUnavailable": "Offline sandbox unavailable on this platform", "chat.emptyRound": "(No reply)", "chat.inputHint": "Type a message, @ to reference files, prompts can be queued...", "chat.inputHintWithSkills": diff --git a/crates/agent-ui/src/i18n/translations/zhCNCommon.ts b/crates/agent-ui/src/i18n/translations/zhCNCommon.ts index 6b818b9ac..2b42fd9c2 100644 --- a/crates/agent-ui/src/i18n/translations/zhCNCommon.ts +++ b/crates/agent-ui/src/i18n/translations/zhCNCommon.ts @@ -332,6 +332,21 @@ export const ZH_CN_COMMON_TRANSLATIONS = { "chat.runtime.tunnelRemoteOffline": "Remote Gateway 未连接", "chat.runtime.tunnelSettingsSyncing": "正在同步桌面端设置", "chat.runtime.reasoning": "思考程度", + "chat.safety.label": "命令执行方式", + "chat.safety.ask": "逐条审批", + "chat.safety.askDesc": "每次执行命令或写入前都请求批准,只读操作不打断", + "chat.safety.auto": "自动执行", + "chat.safety.autoDesc": "按设置中的工具权限策略直接执行", + "chat.safety.sandbox": "沙箱", + "chat.safety.sandboxDesc": "命令在 OS 沙箱内执行:写入限项目文件夹,~/.ssh 等敏感目录不可读", + // Windows 联网后端(受限令牌 WRITE_RESTRICTED)只围栏“写”,读广泛放行 ⇒ 该平台不得 + // 承诺敏感目录不可读;读掩蔽只在断网后端(AppContainer)成立。 + "chat.safety.sandboxDescNoReadMask": + "命令在 OS 沙箱内执行:写入限项目文件夹;本平台(免管理员)不屏蔽读取,~/.ssh 等敏感目录仍可读", + "chat.safety.sandboxOffline": "沙箱·断网", + "chat.safety.sandboxOfflineDesc": "在沙箱基础上进一步禁止命令联网", + "chat.safety.sandboxUnavailable": "当前平台沙箱机制不可用", + "chat.safety.sandboxOfflineUnavailable": "当前平台不支持断网沙箱", "chat.emptyRound": "(无回复)", "chat.inputHint": "输入消息,@ 引用文件,提示词可队列发送...", "chat.inputHintWithSkills": "输入消息,@ 引用文件,/ 引用Skills,提示词可队列发送...", diff --git a/crates/agent-ui/src/lib/settings/index.ts b/crates/agent-ui/src/lib/settings/index.ts index 9d83e7657..bfde8bde5 100644 --- a/crates/agent-ui/src/lib/settings/index.ts +++ b/crates/agent-ui/src/lib/settings/index.ts @@ -66,6 +66,7 @@ import type { ChatRuntimeReasoningProviderKey, CloseWindowBehavior, CodexRequestFormat, + CommandSafetyMode, CustomProvider, CustomSettings, EffectiveWorkspaceResources, @@ -107,6 +108,7 @@ import type { WorkspaceResourceSettings, } from "./types"; import { + COMMAND_SAFETY_MODES, DEFAULT_CHAT_RUNTIME_CONTROLS, getDefaultUsageQueryConfig, PROMPT_CACHE_HINT_MODES, @@ -1283,12 +1285,59 @@ export function normalizeSystemProxyConfig(input: unknown): SystemProxyConfig { }; } +/** + * 命令安全模式归一。 + * + * - 缺失 / null / 空串:沿用默认 `auto`(全新配置与旧快照的正常形态)。 + * - **存在但无法识别:收敛到最严格的 `ask`**(P2#6)。该设置全部意义在于约束, + * 未来新增的模式值、回退到旧版本、手改配置的笔误若静默降级成最宽松的非 ask 值, + * 等于悄悄放宽用户的约束选择;Rust 侧 normalize_command_safety_mode_value 同语义。 + */ +export function normalizeCommandSafetyMode(input: unknown): CommandSafetyMode { + if (input === undefined || input === null) return "auto"; + if (typeof input !== "string") { + console.warn(`[settings] non-string commandSafetyMode; failing closed to "ask"`, input); + return "ask"; + } + const mode = input.trim(); + if (mode === "") return "auto"; + if ((COMMAND_SAFETY_MODES as readonly string[]).includes(mode)) { + return mode as CommandSafetyMode; + } + console.warn(`[settings] unrecognized commandSafetyMode "${mode}"; failing closed to "ask"`); + return "ask"; +} + +/** + * 严格度序:`auto` < `sandbox` < `sandboxOffline` < `ask`(逐次人工放行最严)。 + * 供“取更严格者”的钳制使用,不参与持久化。 + */ +const COMMAND_SAFETY_MODE_STRICTNESS: Record = { + auto: 0, + sandbox: 1, + sandboxOffline: 2, + ask: 3, +}; + +/** + * 取更严格的一方(P3#9)。远端(WebUI / 网关)与排队快照携带的模式只允许“收紧”本地 + * 设置,绝不允许用一份陈旧快照把桌面用户刻意选择的 `sandboxOffline` 放宽成 `auto` + * —— 桌面端是工具唯一执行处,约束强度不能由远端取值决定。 + */ +export function strictestCommandSafetyMode( + a: CommandSafetyMode, + b: CommandSafetyMode, +): CommandSafetyMode { + return COMMAND_SAFETY_MODE_STRICTNESS[a] >= COMMAND_SAFETY_MODE_STRICTNESS[b] ? a : b; +} + export function normalizeSystemSettings(input: unknown): SystemSettings { const obj = (input && typeof input === "object" ? input : {}) as Record; return { executionMode: normalizeExecutionMode(obj.executionMode), workdir: normalizeWorkdir(obj.workdir), toolPolicies: normalizeToolPolicies(obj.toolPolicies), + commandSafetyMode: normalizeCommandSafetyMode(obj.commandSafetyMode), workspaceProjects: normalizeWorkspaceProjects(obj.workspaceProjects), workspaceProjectGroups: normalizeWorkspaceProjectGroups(obj.workspaceProjectGroups), activeWorkspaceProjectId: @@ -1491,6 +1540,7 @@ export function getDefaultSettings(): AppSettings { system: { executionMode: "tools", workdir: "", + commandSafetyMode: "auto", workspaceProjects: [], workspaceProjectGroups: [], activeWorkspaceProjectId: undefined, diff --git a/crates/agent-ui/src/lib/settings/types.ts b/crates/agent-ui/src/lib/settings/types.ts index b75ba95bb..09b2ddcc6 100644 --- a/crates/agent-ui/src/lib/settings/types.ts +++ b/crates/agent-ui/src/lib/settings/types.ts @@ -232,6 +232,23 @@ export type SystemProxyConfig = { /** 工具审批策略:allow 直接执行、ask 执行前请求用户批准、deny 直接拒绝。 */ export type ToolPolicy = "allow" | "ask" | "deny"; +// 命令执行方式(对话框内切换,单一互斥维度): +// - ask:每次带副作用的工具调用都请求用户批准(只读工具不拦)。 +// - auto:按工具审批策略直接执行(既有默认行为)。 +// - sandbox / sandboxOffline:Bash 与常驻进程在 OS 级沙箱内执行(macOS +// Seatbelt / Linux bubblewrap / Windows 受限令牌 WRITE_RESTRICTED),写入限 +// 工作区+临时目录;offline 变体额外断网。Windows 免管理员双后端:sandbox=受限 +// 令牌(只围栏写,读放行);sandboxOffline=AppContainer(WFP 内核级全断网含 +// loopback,默认拒读 ⇒ 系统目录/工作区可读、用户主目录等敏感目录读掩蔽)。 +export type CommandSafetyMode = "ask" | "auto" | "sandbox" | "sandboxOffline"; + +export const COMMAND_SAFETY_MODES: readonly CommandSafetyMode[] = [ + "ask", + "auto", + "sandbox", + "sandboxOffline", +]; + export type SystemSettings = { executionMode: ExecutionMode; workdir: string; @@ -241,6 +258,7 @@ export type SystemSettings = { * 可选:旧快照缺失该字段时视为空表(全部走默认),保证零回归。 */ toolPolicies?: Record; + commandSafetyMode: CommandSafetyMode; workspaceProjects: WorkspaceProject[]; workspaceProjectGroups: WorkspaceProjectGroup[]; activeWorkspaceProjectId?: string; diff --git a/crates/agent-ui/src/pages/chat/ChatComposerBar.tsx b/crates/agent-ui/src/pages/chat/ChatComposerBar.tsx index 812042add..faca6e6a9 100644 --- a/crates/agent-ui/src/pages/chat/ChatComposerBar.tsx +++ b/crates/agent-ui/src/pages/chat/ChatComposerBar.tsx @@ -1,5 +1,6 @@ import { type ChatRuntimeControls, + type CommandSafetyMode, type ExecutionMode, isAgentExecutionMode, type ProviderId, @@ -7,6 +8,7 @@ import { type SelectedModel, type SttProviderId, } from "@liveagent/app/lib/settings"; +import { CommandSafetyModeSelector } from "@liveagent/ui/components/chat/CommandSafetyModeSelector"; import { ComposerAttachmentCard } from "@liveagent/ui/components/chat/ComposerAttachmentCard"; import { ComposerModelControls } from "@liveagent/ui/components/chat/ComposerModelControls"; import { ContextUsageRing } from "@liveagent/ui/components/chat/ContextUsageRing"; @@ -241,6 +243,9 @@ export type ChatComposerBarProps = { modelOptions: SharedModelOption[]; selectedValue?: string; chatRuntimeControls: ChatRuntimeControls; + /** 命令执行方式(ask/auto/sandbox/sandboxOffline);缺省不渲染选择器。 */ + commandSafetyMode?: CommandSafetyMode; + onCommandSafetyModeChange?: (mode: CommandSafetyMode) => void; reasoningOptions: ReasoningLevel[]; thinkingAlwaysOn: boolean; gitClient?: GitClient | null; @@ -314,6 +319,8 @@ export const ChatComposerBar = memo(function ChatComposerBar(props: ChatComposer modelOptions, selectedValue, chatRuntimeControls, + commandSafetyMode, + onCommandSafetyModeChange, reasoningOptions, thinkingAlwaysOn, gitClient, @@ -1060,6 +1067,14 @@ export const ChatComposerBar = memo(function ChatComposerBar(props: ChatComposer onChatRuntimeControlsChange={onChatRuntimeControlsChange} /> + {isAgentMode && commandSafetyMode && onCommandSafetyModeChange ? ( + + ) : null} +