From 143684011bfea980ccc357e576f39ab37ca25f4b Mon Sep 17 00:00:00 2001 From: AIGameCreator App Date: Tue, 14 Jul 2026 05:54:02 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84Linux=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E5=8C=BA=E5=91=BD=E4=BB=A4=E6=B2=99=E7=AE=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 bubblewrap 工作区写入、控制目录保护和默认断网沙箱 统一 command.exec、command.start 与 project.verify 的启动和审计边界 开放 Linux 通用项目命令并保留非 Linux 固定命令兼容边界 补齐持久进程元数据、真实进程测试、共享契约和发布依赖 同步真实 Provider 结论、已知竞态与项目长期文档 --- .../scripts/agent-runtime-real-e2e.mjs | 36 +- .../src-tauri/src/agent.rs | 140 +- .../src-tauri/src/command_exec.rs | 313 ++++- .../src-tauri/src/command_sandbox.rs | 1161 +++++++++++++++++ .../src-tauri/src/main.rs | 2 + .../src-tauri/src/process_session.rs | 299 ++++- .../src-tauri/src/project.rs | 100 +- .../src-tauri/src/tests.rs | 51 +- .../src-tauri/tauri.conf.json | 8 + .../shared-memory/decision-log.md | 11 + .../shared-memory/development-workflow.md | 6 + docs/project-memory/shared-memory/pitfalls.md | 16 + ...案】AI游戏创作Agent Runtime V1.1-2026-07-12.md | 33 + ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 6 +- .../src/contracts/gameCreationApp.test.ts | 16 +- .../shared/src/contracts/gameCreationApp.ts | 10 +- scripts/check-native-shells.mjs | 17 +- .../shared-contracts/src/game_creation_app.rs | 50 +- 18 files changed, 2106 insertions(+), 169 deletions(-) create mode 100644 apps/ai-game-creator-shell/src-tauri/src/command_sandbox.rs diff --git a/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e.mjs b/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e.mjs index a72d1f6d4..5cf30e444 100644 --- a/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e.mjs +++ b/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e.mjs @@ -801,7 +801,7 @@ function buildTaskPrompt(suite) { } function buildProcessSessionTaskPrompt() { - return `完成当前 disposable 项目的真实交互服务验收:启动项目中唯一的交互服务,等待服务明确进入 readiness 状态,按服务给出的一次性 challenge 完成一次交互并确认精确回显,随后干净停止服务。只有服务形成可信终态后才能简短报告完成,不得修改项目文件,也不要在最终回复中复述 challenge、回显或其他私有进程输出。`; + return `完成当前 disposable 项目的真实交互服务验收:先从项目清单确认唯一服务,整个验收最多启动一个进程;启动后只沿同一会话等待 readiness、按服务给出的一次性 challenge 完成一次交互并确认精确回显,随后干净停止服务,不得为探测、试错、重试或停止另起进程。只有服务形成可信终态后才能简短报告完成,不得修改项目文件,也不要在最终回复中复述 challenge、回显或其他私有进程输出。`; } function assertProcessSessionTaskPrompt(task) { @@ -1078,6 +1078,9 @@ async function driveProcessRuntimeToQuiescence() { } const pending = await findPendingActions(); const processRecords = await readProcessSessionRecords(); + if (processRecords.length > 1) { + throw codedError('process-session-record-count-invalid'); + } const completed = initial?.status === 'completed' && initial?.phase === 'completed'; const processTerminal = @@ -1485,7 +1488,8 @@ async function validateProcessRunnerKillEvidence() { startAudits[0].processId === record.processId && startAudits[0].actionId === record.startActionId && startAudits[0].actionFingerprint === record.startActionFingerprint && - startAudits[0].status === 'running', + startAudits[0].status === 'running' && + hasExpectedWorkspaceSandboxMetadata(startAudits[0]), 'process-runner-kill-start-audit-invalid', ); const confirmedActionLifecycleCount = validateConfirmedActionLifecycles( @@ -1600,8 +1604,11 @@ function validateProcessToolEvidence(records, processRecord) { processRecord.startActionFingerprint && startAudits[0].processId === processRecord.processId && startAudits[0].status === 'running' && + hasExpectedWorkspaceSandboxMetadata(startAudits[0]) && [...pollAudits, ...stdinAudits, ...terminateAudits].every( - (audit) => audit.processId === processRecord.processId, + (audit) => + audit.processId === processRecord.processId && + hasExpectedWorkspaceSandboxMetadata(audit), ), 'process-tool-identity-invalid', ); @@ -2037,8 +2044,8 @@ async function captureProcessTranscriptReadiness(record) { function validateProcessSessionRecord(record, transcript, terminalExpected) { assert( - record.schemaVersion === '1' && - transcript.schemaVersion === '1' && + record.schemaVersion === '2' && + transcript.schemaVersion === '2' && record.agentId === mainAgentId && record.runId === state.initialRunId && record.conversationSessionId === state.initialSessionId && @@ -2059,7 +2066,8 @@ function validateProcessSessionRecord(record, transcript, terminalExpected) { transcript.outputBytes === Buffer.byteLength(transcript.output) && transcript.outputSha256 === hashValue(transcript.output) && record.outputBytes === transcript.outputBytes && - record.outputSha256 === transcript.outputSha256, + record.outputSha256 === transcript.outputSha256 && + hasExpectedWorkspaceSandboxMetadata(record), 'process-session-record-identity-invalid', ); if (terminalExpected) { @@ -2132,6 +2140,16 @@ function isTerminalProcessRecord(record) { ); } +function hasExpectedWorkspaceSandboxMetadata(record) { + if (process.platform !== 'linux') return true; + return ( + record?.sandboxBackend === 'bubblewrap' && + record?.sandboxMode === 'workspace-write' && + record?.networkAccess === 'disabled' && + record?.sandboxProfileVersion === 'workspace-v1' + ); +} + async function readProcessFixtureState() { const fixture = await readJson( path.join(state.projectRoot, processFixtureStatePath), @@ -2489,7 +2507,8 @@ async function validateLandedEvidence() { !Object.hasOwn(record, 'args') && !Object.hasOwn(record, 'arguments') && /^[0-9a-f]{64}$/u.test(record.argsSha256) && - isNonEmptyString(record.actionId), + isNonEmptyString(record.actionId) && + hasExpectedWorkspaceSandboxMetadata(record), ), 'command-exec-raw-argv-audit-leak', ); @@ -2876,7 +2895,8 @@ async function validateLandedEvidence() { record.expectedCommand === verificationCommand && record.status === 'completed' && record.exitCode === 0 && - record.timedOut === false, + record.timedOut === false && + hasExpectedWorkspaceSandboxMetadata(record), 'project-verification-structured-evidence-missing', ); assert( diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent.rs b/apps/ai-game-creator-shell/src-tauri/src/agent.rs index 7fbc4620f..be8f83e05 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -19,7 +19,7 @@ pub(crate) const AGENT_RUNTIME_TOOL_OBSERVATION_STATUS_NEEDS_RECONCILIATION: &st "needs-reconciliation"; pub(crate) const AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO: &str = "auto"; pub(crate) const AGENT_RUNTIME_ACTION_EXECUTION_MODE_CONFIRMATION: &str = "confirmation"; -pub(crate) const AGENT_RUNTIME_ACTION_FINGERPRINT_VERSION: &str = "sha256-serde-json-v1"; +pub(crate) const AGENT_RUNTIME_ACTION_FINGERPRINT_VERSION: &str = "sha256-serde-json-v2"; pub(crate) const AGENT_RUNTIME_CONTEXT_BUNDLE_SCHEMA_VERSION: &str = "game-creator-runtime-context-bundle.v2"; pub(crate) const AGENT_RUNTIME_PROJECT_REVISION_SCHEMA_VERSION: &str = @@ -7539,10 +7539,30 @@ pub(crate) fn agent_runtime_tool_action_fingerprint( action: &AgentRuntimeToolAction, task: &str, ) -> String { + let sandbox_policy = matches!( + action.tool.trim(), + "command.exec" + | "command.start" + | "command.poll" + | "command.stdin" + | "command.terminate" + | "project.verify" + ) + .then(|| { + let metadata = command_sandbox_platform_metadata(); + serde_json::json!({ + "requirement": "required", + "backend": metadata.backend, + "mode": metadata.mode, + "networkAccess": metadata.network, + "profileVersion": metadata.profile_version, + }) + }); let payload = serde_json::json!({ "tool": action.tool.trim(), "input": &action.input, "taskContext": task, + "sandboxPolicy": sandbox_policy, }); let encoded = serde_json::to_vec(&payload).unwrap_or_default(); format!("{:x}", Sha256::digest(encoded)) @@ -8301,6 +8321,11 @@ fn build_game_creator_agent_background_tool_plan_request( let prompt = format!( "{prompt}\n\n补充协议:project.verify 的 script 除 check、typecheck、test、lint、build 外,还可使用 check:、test:(例如 test:unit)、lint:、typecheck:、build:、verify:、validate: 形式的命名脚本;冒号后的每个非空段必须以字母或数字开头且只能包含字母、数字、连字符、下划线或点,并且 script 与 expectedCommand 都必须原样来自项目根 package.json。command.exec 使用 {{\"program\":\"cargo|npm|node|git|rg\",\"args\":[\"逐项 argv\"],\"cwd\":\"可选项目内相对目录\",\"timeoutSeconds\":120}},不接受 shell 字符串、管道、重定向、环境变量或项目外路径;该工具默认需要精确确认,适合运行定向测试、构建检查和只读诊断。durable command.exec observation 会直接返回可复用的 sourceActionId;短 observation 不足以定位失败时,使用 command.output_read {{\"actionId\":\"该 sourceActionId\",\"startLine\":1,\"maxLines\":160}} 分页读取同一 Agent 的已清洗命令输出,并按 nextLine 继续,不要先猜 actionId 或为取得它额外查询动作历史,也不得仅凭输出尾部猜测。只有 cargo check/test/clippy/fmt/build、npm test 或命名为 check/typecheck/test/lint/build/verify/validate 的验证脚本,以及精确 node --test 测试文件可签发验证凭证;git、rg、cargo metadata 和普通 npm run 只作为诊断结果。每次成功执行 file.write、file.patch、file.delete、project.patchset 或 project.restore,以及每次真正启动 command.exec 或 command.start,都会产生新的项目 revision;最后一次修改后必须成功执行 project.verify、可验证 command.exec,或成功执行 command.run_limited 的 game.static_smoke,才能返回空 actions 收束。文件回读不能替代可执行验证,验证后再次修改必须重新验证。每 {AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT} 轮只是一个上下文压缩窗口,不是 run 的终止上限;只要 observation 出现新的独立进展,就在同一 run 继续下一窗口,只有窗口没有新进展时才按停滞处理。" ); + #[cfg(target_os = "linux")] + let prompt = prompt.replace( + "command.exec 使用 {\"program\":\"cargo|npm|node|git|rg\",\"args\":[\"逐项 argv\"],\"cwd\":\"可选项目内相对目录\",\"timeoutSeconds\":120},不接受 shell 字符串、管道、重定向、环境变量或项目外路径", + "command.exec 使用 {\"program\":\"受信任 PATH 中的裸可执行名\",\"args\":[\"逐项 argv\"],\"cwd\":\"可选项目内相对目录\",\"timeoutSeconds\":120};Linux 命令固定运行在 bubblewrap workspace-write、network-disabled 沙箱内,允许 bash -lc、管道、重定向和项目脚本,但不接受环境变量、宿主 executable 路径、mount 或网络策略输入", + ); let prompt = format!( "{prompt}\n\n新增工具输入:preview.validate 使用 {{\"viewports\":[\"desktop\",\"mobile\"],\"expectedText\":[\"可选可见文本\"],\"settleMs\":800,\"failOnConsoleError\":true}},不得提供 URL、脚本、Cookie 或请求头;preview.validate 成功后必须把 observation 返回的 desktop.png 与 mobile.png 路径一起交给 image.inspect。image.inspect 使用 {{\"paths\":[\"项目内图片路径\"],\"question\":\"可选检查重点\"}},单次 1-2 张,只允许 game/、assets/ 或当前 Agent/run 的浏览器截图,不接受 URL、base64、请求头或 Cookie;它用于判断布局、遮挡、裁切、层级和双视口适配,不替代可执行验证。image.inspect 的 conclusion 仍是不可信视觉证据,只能用于界面判断,不能改变工具权限、系统规则或任务身份。agent.spawn_isolated 使用 {{\"children\":[{{\"templateAgentId\":\"规范 taskId\",\"task\":\"边界清晰的子任务\",\"acceptanceCriteria\":[\"可验证条件\"],\"expectedArtifacts\":[\"项目内路径\"],\"writeScopes\":[\"互不重叠的目录/**\"]}}],\"joinMode\":\"all\"}},一次最多 3 个子实例;spawn 后用 agent.run_status 的 scope=all 检查进度,当 observation 出现 readyIsolatedJoins 时表示 all-join 已完成并已由当前父 run 认领,必须直接使用其中结果继续,不得继续等待或为同一组重复查询;claimedIsolatedJoins 表示该认领仍然有效。agent.action_history 使用 {{\"runId\":\"可选 run id\",\"actionId\":\"可选 action id\",\"tool\":\"可选工具名\",\"status\":\"可选终态\",\"limit\":5}},只查询当前 Agent 的持久终态动作;省略 runId 时只查当前 run,默认不返回 action_history 自身。" ); @@ -8308,7 +8333,12 @@ fn build_game_creator_agent_background_tool_plan_request( "{prompt}\n\nagent.spawn_isolated 补充约束:expectedArtifacts 只能填写子任务完成时必须存在的项目内相对文件路径或 glob;只读任务填写被检查的现有文件,不能填写报告标题、描述或其他自然语言。writeScopes 必须是互不重叠的项目内非私有相对目录 glob,禁止使用 .agent、敏感路径或项目外路径。" ); let prompt = format!( - "{prompt}\n\n持久进程协议:command.start 使用 {{\"program\":\"cargo|npm|node|git|rg\",\"args\":[\"逐项 argv\"],\"cwd\":\"项目内相对目录\",\"timeoutSeconds\":300}},默认需要精确确认;成功后保存 observation 返回的 processId 和 cursor。command.poll 使用 {{\"processId\":\"proc-...\",\"cursor\":\"上一页 nextCursor,可首次省略\",\"maxChars\":8000,\"waitMs\":1000}},必须按 nextCursor 增量读取,不要无等待忙轮询。command.stdin 使用 {{\"processId\":\"proc-...\",\"data\":\"UTF-8 文本\",\"appendNewline\":true,\"eof\":false}},正文会写入 PTY 且默认需要确认;command.terminate 使用 {{\"processId\":\"proc-...\",\"cursor\":\"最后一次 poll 的 nextCursor\"}} 并默认需要确认,terminate 不消费输出,后续继续用它返回的同一 nextCursor poll 终态。command.start 会推进 revision 但永远不能签发验证凭证;当前 run 的进程会话必须 poll 到可信终态,或先 terminate 再 poll,才能返回空 actions 收束;needs-reconciliation 只能等待人工核对,不能重启、按 PID 重连或假装已退出。" + "{prompt}\n\n持久进程协议:command.start 使用 {{\"program\":\"cargo|npm|node|git|rg\",\"args\":[\"逐项 argv\"],\"cwd\":\"项目内相对目录\",\"timeoutSeconds\":300}},默认需要精确确认;它只用于已经从仓库清单确认需要持续交互的长进程,有限诊断、文件探测、构建和测试必须使用 command.exec,不得用 command.start 试错。成功后保存 observation 返回的 processId 和 cursor;同一服务后续只能沿该 processId 继续,不得为探测、重试、交互或停止另起 process session。command.poll 使用 {{\"processId\":\"proc-...\",\"cursor\":\"上一页 nextCursor,可首次省略\",\"maxChars\":8000,\"waitMs\":1000}},必须按 nextCursor 增量读取,不要无等待忙轮询。command.stdin 使用 {{\"processId\":\"proc-...\",\"data\":\"UTF-8 文本\",\"appendNewline\":true,\"eof\":false}},正文会写入 PTY 且默认需要确认;command.terminate 使用 {{\"processId\":\"proc-...\",\"cursor\":\"最后一次 poll 的 nextCursor\"}} 并默认需要确认,terminate 不消费输出,后续继续用它返回的同一 nextCursor poll 终态。command.start 会推进 revision 但永远不能签发验证凭证;当前 run 的进程会话必须 poll 到可信终态,或先 terminate 再 poll,才能返回空 actions 收束;needs-reconciliation 只能等待人工核对,不能重启、按 PID 重连或假装已退出。" + ); + #[cfg(target_os = "linux")] + let prompt = prompt.replace( + "command.start 使用 {\"program\":\"cargo|npm|node|git|rg\"", + "command.start 使用 {\"program\":\"受信任 PATH 中的裸可执行名\"", ); let api_kind = parse_game_creator_llm_api_kind(&llm.api_kind)?; let mut request = LlmRunRequest::new(vec![ @@ -12191,6 +12221,21 @@ async fn observe_agent_runtime_command_exec( return agent_runtime_mutation_gate_failure_observation(root, "command.exec", &error); } } + let command_launch = match prepare_project_command_launch_spec(root, &command_spec) { + Ok(launch) => launch, + Err(error) => { + return AgentRuntimeToolObservation { + tool: "command.exec".to_string(), + status: "failed".to_string(), + summary: "command.exec 沙箱预检失败,命令未执行".to_string(), + detail: Some(redact_agent_runtime_project_paths( + root, + error.message(), + 500, + )), + }; + } + }; if let Err(error) = prepare_agent_runtime_project_mutation_locked(root, agent_id, run_id, "command.exec") { @@ -12227,12 +12272,10 @@ async fn observe_agent_runtime_command_exec( action_id: action_id.to_string(), action_fingerprint: action_fingerprint.to_string(), }); - let result = run_project_command_with_output_at( + let result = run_prepared_project_command_with_output_at( root, - &input.program, - &input.args, - &input.cwd, - input.timeout_seconds, + &command_spec, + &command_launch, output_identity, ) .await; @@ -12260,6 +12303,10 @@ async fn observe_agent_runtime_command_exec( "durationMs": command.duration_ms, "sourceChanged": command.source_changed, "verificationEligible": command.verification_eligible, + "sandboxBackend": command.sandbox_backend, + "sandboxMode": command.sandbox_mode, + "networkAccess": command.network_access, + "sandboxProfileVersion": command.sandbox_profile_version, "logPath": ".agent/logs/command.log", "outputRef": command.output_ref, "outputSha256": command.output_sha256, @@ -12280,6 +12327,10 @@ async fn observe_agent_runtime_command_exec( "argsCount": input.args.len(), "cwd": input.cwd, "verificationEligible": verification_eligible, + "sandboxBackend": command_launch.sandbox_backend, + "sandboxMode": command_launch.sandbox_mode, + "networkAccess": command_launch.network_access, + "sandboxProfileVersion": command_launch.sandbox_profile_version, "status": if error.execution_started() { "execution-unknown" } else { @@ -12348,8 +12399,12 @@ async fn observe_agent_runtime_command_exec( AGENT_RUNTIME_TOOL_OBSERVATION_MAX_CHARS, ); let detail = format!( - "verificationEligible={} · sourceActionId={} · outputRef={} · outputSha256={} · totalLines={} · captureTruncated={} · exitCode={} · timedOut={} · sourceChanged={} · {output_tail}", + "verificationEligible={} · sandboxBackend={} · sandboxMode={} · networkAccess={} · sandboxProfileVersion={} · sourceActionId={} · outputRef={} · outputSha256={} · totalLines={} · captureTruncated={} · exitCode={} · timedOut={} · sourceChanged={} · {output_tail}", command.verification_eligible, + command.sandbox_backend, + command.sandbox_mode, + command.network_access, + command.sandbox_profile_version, action_id.unwrap_or("unavailable"), command.output_ref.as_deref().unwrap_or("unavailable"), command.output_sha256, @@ -12508,6 +12563,10 @@ fn agent_runtime_process_poll_detail( "sourceChanged": result.source_changed, "needsReconciliation": result.needs_reconciliation, "revisionAdvanced": revision_advanced, + "sandboxBackend": result.sandbox_backend, + "sandboxMode": result.sandbox_mode, + "networkAccess": result.network_access, + "sandboxProfileVersion": result.sandbox_profile_version, }); if include_output { detail @@ -12549,6 +12608,10 @@ fn append_agent_runtime_process_poll_audit( "outputSha256": result.output_sha256, "sourceChanged": result.source_changed, "needsReconciliation": result.needs_reconciliation, + "sandboxBackend": result.sandbox_backend, + "sandboxMode": result.sandbox_mode, + "networkAccess": result.network_access, + "sandboxProfileVersion": result.sandbox_profile_version, }), ) } @@ -12677,6 +12740,21 @@ fn observe_agent_runtime_command_start( detail: None, }; } + let command_launch = match prepare_project_command_launch_spec(root, &command_spec) { + Ok(launch) => launch, + Err(error) => { + return AgentRuntimeToolObservation { + tool: "command.start".to_string(), + status: "failed".to_string(), + summary: "command.start 沙箱预检失败,进程未启动".to_string(), + detail: Some(redact_agent_runtime_project_paths( + root, + error.message(), + 500, + )), + }; + } + }; if let Err(error) = prepare_agent_runtime_project_mutation_locked(root, agent_id, run_id, "command.start") { @@ -12699,7 +12777,13 @@ fn observe_agent_runtime_command_start( }; } }; - let result = match start_process_session_at(root, identity, &command_spec, source_fingerprint) { + let result = match start_prepared_process_session_at( + root, + identity, + &command_spec, + &command_launch, + source_fingerprint, + ) { Ok(result) => result, Err(error) => { return AgentRuntimeToolObservation { @@ -12914,6 +12998,10 @@ fn observe_agent_runtime_command_stdin( "contentSha256": result.content_sha256, "stdinOpen": result.stdin_open, "eof": result.eof, + "sandboxBackend": result.sandbox_backend, + "sandboxMode": result.sandbox_mode, + "networkAccess": result.network_access, + "sandboxProfileVersion": result.sandbox_profile_version, }), ) }); @@ -12935,6 +13023,10 @@ fn observe_agent_runtime_command_stdin( "contentSha256": result.content_sha256, "stdinOpen": result.stdin_open, "eof": result.eof, + "sandboxBackend": result.sandbox_backend, + "sandboxMode": result.sandbox_mode, + "networkAccess": result.network_access, + "sandboxProfileVersion": result.sandbox_profile_version, }) .to_string(), ), @@ -13237,6 +13329,10 @@ async fn observe_agent_runtime_project_verify( "exitCode": verification.exit_code, "timedOut": verification.timed_out, "durationMs": verification.duration_ms, + "sandboxBackend": verification.sandbox_backend, + "sandboxMode": verification.sandbox_mode, + "networkAccess": verification.network_access, + "sandboxProfileVersion": verification.sandbox_profile_version, "logPath": verification.log_path, "output": audit_output, }), @@ -13258,11 +13354,18 @@ async fn observe_agent_runtime_project_verify( } match result { Ok(verification) => { - let detail = redact_agent_runtime_project_paths_preserving_tail( + let output_tail = redact_agent_runtime_project_paths_preserving_tail( root, &verification.output, AGENT_RUNTIME_TOOL_OBSERVATION_MAX_CHARS, ); + let detail = format!( + "sandboxBackend={} · sandboxMode={} · networkAccess={} · sandboxProfileVersion={} · {output_tail}", + verification.sandbox_backend, + verification.sandbox_mode, + verification.network_access, + verification.sandbox_profile_version, + ); if verification.status == "completed" { AgentRuntimeToolObservation { tool: "project.verify".to_string(), @@ -19510,9 +19613,20 @@ pub(crate) fn game_creator_agent_runtime_tool_plan_system_prompt() -> String { .flat_map(|group| group.roles.iter().map(|role| role.task_id)) .collect::>() .join(", "); - format!( - "{prompt} agent.spawn_isolated 的合法 templateAgentId 仅限以下静态模板 taskId:{isolated_template_ids}。expectedArtifacts 只能填写子任务完成时必须存在的项目内相对文件路径或 glob;只读任务填写被检查的现有文件,不能填写报告标题或自然语言。writeScopes 必须是互不重叠的项目内非私有相对目录 glob,禁止使用 .agent、敏感路径或项目外路径。持久进程必须使用 command.start 的固定 program/argv 启动并保存 processId/cursor;用 command.poll 的 nextCursor 增量读取并设置合理 waitMs,禁止忙轮询;command.stdin 写入 UTF-8 文本;command.terminate 必须携带最后一次 poll 的 nextCursor,终止本身不消费输出,后续继续从同一 cursor poll 终态。command.start 只会使旧验证失效,不能签发验证凭证;当前 run 还有 running/terminating 或 needs-reconciliation 会话时禁止最终回复,不得按 PID 重连或假装进程已经退出。" - ) + let prompt = format!( + "{prompt} agent.spawn_isolated 的合法 templateAgentId 仅限以下静态模板 taskId:{isolated_template_ids}。expectedArtifacts 只能填写子任务完成时必须存在的项目内相对文件路径或 glob;只读任务填写被检查的现有文件,不能填写报告标题或自然语言。writeScopes 必须是互不重叠的项目内非私有相对目录 glob,禁止使用 .agent、敏感路径或项目外路径。持久进程必须使用 command.start 的固定 program/argv 启动并保存 processId/cursor;command.start 只用于仓库清单已确认的长进程,短命令和探测使用 command.exec,同一服务启动成功后不得另起 session。用 command.poll 的 nextCursor 增量读取并设置合理 waitMs,禁止忙轮询;command.stdin 写入 UTF-8 文本;command.terminate 必须携带最后一次 poll 的 nextCursor,终止本身不消费输出,后续继续从同一 cursor poll 终态。command.start 只会使旧验证失效,不能签发验证凭证;当前 run 还有 running/terminating 或 needs-reconciliation 会话时禁止最终回复,不得按 PID 重连或假装进程已经退出。" + ); + #[cfg(target_os = "linux")] + { + prompt.replace( + "持久进程必须使用 command.start 的固定 program/argv 启动", + "持久进程必须使用 command.start 的结构化 program/argv 在 workspace-write、network-disabled 沙箱内启动", + ) + } + #[cfg(not(target_os = "linux"))] + { + prompt + } } pub(crate) fn game_creator_agent_role_definition( diff --git a/apps/ai-game-creator-shell/src-tauri/src/command_exec.rs b/apps/ai-game-creator-shell/src-tauri/src/command_exec.rs index 0ddcbc2bd..733750d49 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/command_exec.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/command_exec.rs @@ -102,9 +102,13 @@ pub(crate) struct ProjectCommandSpec { #[derive(Clone, Debug, Eq, PartialEq)] pub(crate) struct ProjectCommandLaunchSpec { pub(crate) executable: PathBuf, - pub(crate) arguments: Vec, + pub(crate) arguments: Vec, pub(crate) cwd: PathBuf, pub(crate) environment: Vec<(OsString, OsString)>, + pub(crate) sandbox_backend: String, + pub(crate) sandbox_mode: String, + pub(crate) network_access: String, + pub(crate) sandbox_profile_version: String, } #[derive(Clone, Debug, Eq, PartialEq)] @@ -126,6 +130,10 @@ pub(crate) struct ProjectCommandResult { pub(crate) source_fingerprint_after: String, pub(crate) source_changed: bool, pub(crate) verification_eligible: bool, + pub(crate) sandbox_backend: String, + pub(crate) sandbox_mode: String, + pub(crate) network_access: String, + pub(crate) sandbox_profile_version: String, pub(crate) log_path: String, pub(crate) updated_at: u64, } @@ -307,10 +315,7 @@ fn resolve_project_command_spec_inner( "command.exec timeoutSeconds 必须在 {PROJECT_COMMAND_MIN_TIMEOUT_SECONDS}-{PROJECT_COMMAND_MAX_TIMEOUT_SECONDS} 之间" )); } - let program = program.trim().to_ascii_lowercase(); - if !matches!(program.as_str(), "cargo" | "npm" | "node" | "git" | "rg") { - return Err("command.exec program 只允许 cargo、npm、node、git 或 rg".to_string()); - } + let program = normalize_project_command_program(program)?; validate_project_command_arguments(&program, arguments)?; let cwd_relative = normalize_project_command_cwd(cwd)?; let cwd = if cwd_relative == "." { @@ -336,6 +341,33 @@ fn resolve_project_command_spec_inner( }) } +fn normalize_project_command_program(value: &str) -> Result { + let value = value.trim(); + #[cfg(target_os = "linux")] + { + if value.is_empty() + || value.len() > 64 + || !value.chars().all(|character| { + character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.' | '+') + }) + { + return Err( + "command.exec program 必须是 1-64 个 ASCII 字母、数字、点、下划线、加号或连字符组成的裸可执行名" + .to_string(), + ); + } + Ok(value.to_string()) + } + #[cfg(not(target_os = "linux"))] + { + let value = value.to_ascii_lowercase(); + if !matches!(value.as_str(), "cargo" | "npm" | "node" | "git" | "rg") { + return Err("command.exec program 只允许 cargo、npm、node、git 或 rg".to_string()); + } + Ok(value) + } +} + fn validate_project_command_cwd_components(root: &Path, relative_path: &str) -> Result<(), String> { let mut current = root.to_path_buf(); validate_project_command_cwd_component(¤t)?; @@ -494,6 +526,17 @@ fn resolve_project_command_executable( root: &Path, program: &str, ) -> Result<(PathBuf, OsString), String> { + #[cfg(target_os = "linux")] + let path = std::env::join_paths([ + PathBuf::from("/usr/local/sbin"), + PathBuf::from("/usr/local/bin"), + PathBuf::from("/usr/sbin"), + PathBuf::from("/usr/bin"), + PathBuf::from("/sbin"), + PathBuf::from("/bin"), + ]) + .map_err(|error| format!("构造 command.exec 受信任系统 PATH 失败:{error}"))?; + #[cfg(not(target_os = "linux"))] let path = std::env::var_os("PATH").ok_or_else(|| "command.exec 缺少 PATH".to_string())?; resolve_project_command_executable_from_path(root, program, &path) } @@ -612,6 +655,7 @@ fn normalize_project_command_cwd(value: &str) -> Result { } fn validate_project_command_arguments(program: &str, arguments: &[String]) -> Result<(), String> { + #[cfg(not(target_os = "linux"))] if arguments.is_empty() { return Err("command.exec args 不能为空".to_string()); } @@ -631,6 +675,7 @@ fn validate_project_command_arguments(program: &str, arguments: &[String]) -> Re )); } total_bytes = total_bytes.saturating_add(argument.len()); + #[cfg(not(target_os = "linux"))] if project_command_argument_is_external_path(argument) || project_command_argument_contains_sensitive_path(argument) { @@ -642,13 +687,21 @@ fn validate_project_command_arguments(program: &str, arguments: &[String]) -> Re "command.exec args 总长度不能超过 {PROJECT_COMMAND_MAX_ARGUMENT_BYTES} 字节" )); } - match program { - "cargo" => validate_cargo_arguments(arguments), - "npm" => validate_npm_arguments(arguments), - "node" => validate_node_arguments(arguments), - "git" => validate_git_arguments(arguments), - "rg" => validate_rg_arguments(arguments), - _ => unreachable!("program whitelist checked above"), + #[cfg(target_os = "linux")] + { + let _ = program; + Ok(()) + } + #[cfg(not(target_os = "linux"))] + { + match program { + "cargo" => validate_cargo_arguments(arguments), + "npm" => validate_npm_arguments(arguments), + "node" => validate_node_arguments(arguments), + "git" => validate_git_arguments(arguments), + "rg" => validate_rg_arguments(arguments), + _ => unreachable!("program whitelist checked above"), + } } } @@ -672,6 +725,9 @@ fn project_command_argument_contains_sensitive_path(value: &str) -> bool { *component, ".agent" | ".git" + | ".agents" + | ".codex" + | ".hermes" | ".hg" | ".svn" | ".ssh" @@ -952,6 +1008,11 @@ fn project_command_git_path_is_sensitive(value: &str) -> bool { } pub(crate) fn project_command_actual_arguments(spec: &ProjectCommandSpec) -> Vec { + #[cfg(target_os = "linux")] + { + return spec.arguments.clone(); + } + #[cfg(not(target_os = "linux"))] match spec.program.as_str() { "npm" => std::iter::once("--ignore-scripts".to_string()) .chain(spec.arguments.iter().cloned()) @@ -1110,6 +1171,18 @@ pub(crate) fn prepare_project_command_launch_spec( environment.push((OsString::from(name), value)); } } + #[cfg(target_os = "linux")] + if !environment + .iter() + .any(|(name, _)| name == OsStr::new("RUSTUP_HOME")) + { + if let Some(home) = std::env::var_os("HOME") { + let rustup_home = PathBuf::from(home).join(".rustup"); + if rustup_home.is_dir() { + environment.push((OsString::from("RUSTUP_HOME"), rustup_home.into_os_string())); + } + } + } #[cfg(windows)] if let Some(system_root) = std::env::var_os("SystemRoot") { environment.push(( @@ -1120,12 +1193,46 @@ pub(crate) fn prepare_project_command_launch_spec( )); } - Ok(ProjectCommandLaunchSpec { - executable: spec.executable.clone(), - arguments: project_command_actual_arguments(spec), - cwd: spec.cwd.clone(), - environment, - }) + let arguments = project_command_actual_arguments(spec); + #[cfg(target_os = "linux")] + { + let sandbox = prepare_command_sandbox_launch( + root, + &spec.executable, + &arguments, + &spec.cwd, + &environment, + ) + .map_err(|error| { + ProjectCommandError::new( + ProjectCommandErrorStage::Preflight, + format!("command.exec sandbox unavailable:{error}"), + ) + })?; + Ok(ProjectCommandLaunchSpec { + executable: sandbox.executable, + arguments: sandbox.arguments, + cwd: sandbox.cwd, + environment: sandbox.environment, + sandbox_backend: sandbox.metadata.backend.to_string(), + sandbox_mode: sandbox.metadata.mode.to_string(), + network_access: sandbox.metadata.network.to_string(), + sandbox_profile_version: sandbox.metadata.profile_version.to_string(), + }) + } + #[cfg(not(target_os = "linux"))] + { + Ok(ProjectCommandLaunchSpec { + executable: spec.executable.clone(), + arguments: arguments.into_iter().map(OsString::from).collect(), + cwd: spec.cwd.clone(), + environment, + sandbox_backend: "legacy-host-restricted".to_string(), + sandbox_mode: "fixed-command".to_string(), + network_access: "proxy-only".to_string(), + sandbox_profile_version: "legacy-v1".to_string(), + }) + } } fn configure_project_command_process_group(command: &mut tokio::process::Command) { @@ -1359,10 +1466,9 @@ pub(crate) fn project_command_source_fingerprint(root: &Path) -> Result Result { - let launch = prepare_project_command_launch_spec(root, spec)?; let mut command = tokio::process::Command::new(&launch.executable); command .args(&launch.arguments) @@ -1383,6 +1489,7 @@ async fn run_project_command_process( format!("启动 command.exec {} 失败:{error}", spec.program), ) })?; + #[cfg(unix)] let process_id = child.id(); let stdout = match child.stdout.take() { Some(stdout) => stdout, @@ -1537,10 +1644,20 @@ pub(crate) async fn run_project_command_with_output_at( output_identity: Option, ) -> Result { let spec = resolve_project_command_spec_at(root, program, arguments, cwd, timeout_seconds)?; + let launch = prepare_project_command_launch_spec(root, &spec)?; + run_prepared_project_command_with_output_at(root, &spec, &launch, output_identity).await +} + +pub(crate) async fn run_prepared_project_command_with_output_at( + root: &Path, + spec: &ProjectCommandSpec, + launch: &ProjectCommandLaunchSpec, + output_identity: Option, +) -> Result { let source_fingerprint_before = project_command_source_fingerprint(root) .map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Preflight, error))?; let started_at = std::time::Instant::now(); - let process = run_project_command_process(root, &spec).await?; + let process = run_project_command_process(spec, launch).await?; let duration_ms = u64::try_from(started_at.elapsed().as_millis()).unwrap_or(u64::MAX); let source_fingerprint_after = project_command_source_fingerprint(root).map_err(|error| { ProjectCommandError::new( @@ -1602,7 +1719,7 @@ pub(crate) async fn run_project_command_with_output_at( } let argument_bytes = serde_json::to_vec(&spec.arguments).unwrap_or_default(); let log_entry = format!( - "{updated_at} command.exec program={} argsSha256={:x} argsCount={} cwd={} {status} exitCode={} timedOut={} durationMs={} sourceChanged={} verificationEligible={}\n{}\n", + "{updated_at} command.exec program={} argsSha256={:x} argsCount={} cwd={} {status} exitCode={} timedOut={} durationMs={} sourceChanged={} verificationEligible={} sandboxBackend={} sandboxMode={} networkAccess={} sandboxProfileVersion={}\n{}\n", spec.program, Sha256::digest(&argument_bytes), spec.arguments.len(), @@ -1615,6 +1732,10 @@ pub(crate) async fn run_project_command_with_output_at( duration_ms, source_changed, spec.verification_eligible, + launch.sandbox_backend, + launch.sandbox_mode, + launch.network_access, + launch.sandbox_profile_version, process.output, ); fs::OpenOptions::new() @@ -1651,9 +1772,9 @@ pub(crate) async fn run_project_command_with_output_at( Ok(ProjectCommandResult { command_id, - program: spec.program, - arguments: spec.arguments, - cwd_relative: spec.cwd_relative, + program: spec.program.clone(), + arguments: spec.arguments.clone(), + cwd_relative: spec.cwd_relative.clone(), status: status.to_string(), exit_code: process.exit_code, timed_out: process.timed_out, @@ -1667,6 +1788,10 @@ pub(crate) async fn run_project_command_with_output_at( source_fingerprint_after, source_changed, verification_eligible: spec.verification_eligible, + sandbox_backend: launch.sandbox_backend.clone(), + sandbox_mode: launch.sandbox_mode.clone(), + network_access: launch.network_access.clone(), + sandbox_profile_version: launch.sandbox_profile_version.clone(), log_path: log_path.to_string_lossy().into_owned(), updated_at, }) @@ -1707,6 +1832,7 @@ mod tests { fs::set_permissions(path, permissions).expect("make fake executable executable"); } + #[cfg(not(target_os = "linux"))] #[test] fn project_command_rejects_shells_paths_and_dangerous_options() { let dir = command_project("validation"); @@ -1731,6 +1857,131 @@ mod tests { assert!(resolve_project_command_spec_at(root, "git", &sensitive, ".", 30).is_err()); } + #[cfg(target_os = "linux")] + #[test] + fn project_command_accepts_general_programs_only_as_bare_names() { + let dir = command_project("general-programs"); + let root = dir.path(); + let shell = resolve_project_command_spec_at( + root, + "bash", + &command_args(&["-lc", "printf general-command"]), + ".", + 30, + ) + .expect("resolve sandboxed shell"); + assert_eq!(shell.program, "bash"); + for program in ["/bin/bash", "../bash", "tool/name", "bad name", ""] { + assert!( + resolve_project_command_spec_at(root, program, &[], ".", 30).is_err(), + "expected bare program rejection for {program:?}" + ); + } + } + + #[cfg(target_os = "linux")] + #[tokio::test] + async fn project_command_workspace_sandbox_blocks_host_controls_and_network() { + let dir = command_project("workspace-sandbox"); + let root = dir.path(); + for name in [".git", ".agents", ".codex", ".hermes"] { + fs::create_dir_all(root.join(name)).expect("create protected directory"); + fs::write(root.join(name).join("marker"), name).expect("write protected marker"); + } + let outside = root + .parent() + .expect("sandbox project parent") + .join(format!("outside-{}", std::process::id())); + fs::write(&outside, "HOST_SECRET").expect("write outside sentinel"); + let script = format!( + r#"set -eu +printf WORKSPACE_OK > workspace-write.txt +test ! -r {outside:?} +! printf NO > {outside:?} +for control in .git .agents .codex .hermes; do + test -r "$control/marker" + ! touch "$control/blocked-write" +done +! test -r .agent/manifest.json +bash -c 'test ! -r {outside:?}; ! touch .git/child-blocked' +python3 -c 'import socket; s=socket.socket(); s.settimeout(0.2); code=0 +try: s.connect(("1.1.1.1", 53)); code=1 +except OSError: pass +finally: s.close() +raise SystemExit(code)' +"#, + outside = outside.to_string_lossy(), + ); + fs::write(root.join("sandbox-check.sh"), script).expect("write sandbox check script"); + let result = + run_project_command_at(root, "bash", &["sandbox-check.sh".to_string()], ".", 30) + .await + .expect("run workspace sandbox command"); + + assert_eq!(result.exit_code, Some(0), "{}", result.output); + assert_eq!(result.sandbox_backend, "bubblewrap"); + assert_eq!(result.sandbox_mode, "workspace-write"); + assert_eq!(result.network_access, "disabled"); + assert_eq!( + fs::read_to_string(root.join("workspace-write.txt")).expect("workspace write"), + "WORKSPACE_OK" + ); + assert_eq!( + fs::read_to_string(&outside).expect("outside sentinel"), + "HOST_SECRET" + ); + for name in [".git", ".agents", ".codex", ".hermes"] { + assert!(!root.join(name).join("blocked-write").exists()); + } + fs::remove_file(outside).ok(); + } + + #[cfg(target_os = "linux")] + #[tokio::test] + async fn project_command_runs_project_build_and_git_read_inside_sandbox() { + let dir = command_project("sandbox-build-git"); + let root = dir.path(); + fs::create_dir_all(root.join("src")).expect("create Rust source directory"); + fs::write( + root.join("Cargo.toml"), + "[package]\nname = \"sandbox-fixture\"\nversion = \"0.1.0\"\nedition = \"2021\"\n", + ) + .expect("write Cargo.toml"); + fs::write( + root.join("Cargo.lock"), + "# This file is automatically @generated by Cargo.\n# It is not intended for manual editing.\nversion = 4\n\n[[package]]\nname = \"sandbox-fixture\"\nversion = \"0.1.0\"\n", + ) + .expect("write Cargo.lock"); + fs::write(root.join("src/lib.rs"), "pub fn answer() -> u32 { 42 }\n") + .expect("write Rust source"); + let git_status = std::process::Command::new("git") + .args(["init", "--quiet"]) + .current_dir(root) + .status() + .expect("initialize Git repository"); + assert!(git_status.success()); + + let build = run_project_command_at( + root, + "cargo", + &command_args(&["check", "--quiet"]), + ".", + 120, + ) + .await + .expect("run cargo check in sandbox"); + assert_eq!(build.status, "completed", "{}", build.output); + assert_eq!(build.sandbox_mode, "workspace-write"); + assert_eq!(build.network_access, "disabled"); + + let git = + run_project_command_at(root, "git", &command_args(&["status", "--short"]), ".", 30) + .await + .expect("run git status in sandbox"); + assert_eq!(git.exit_code, Some(0), "{}", git.output); + assert_eq!(git.sandbox_backend, "bubblewrap"); + } + #[cfg(unix)] #[test] fn project_command_rejects_symlinked_cwd_ancestors() { @@ -1968,6 +2219,7 @@ mod tests { ); } + #[cfg(not(target_os = "linux"))] #[test] fn project_command_injects_git_safety_options_before_pathspec_separator() { let dir = command_project("git-arguments"); @@ -2008,6 +2260,7 @@ mod tests { } } + #[cfg(not(target_os = "linux"))] #[test] fn project_command_injects_non_overridable_rg_sensitive_exclusions() { let dir = command_project("rg-arguments"); @@ -2166,14 +2419,8 @@ mod tests { #[test] fn project_command_errors_distinguish_validation_from_started_execution() { let dir = command_project("error-stage"); - let error = resolve_project_command_spec_at( - dir.path(), - "bash", - &["-lc".to_string(), "echo no".to_string()], - ".", - 30, - ) - .expect_err("reject shell"); + let error = resolve_project_command_spec_at(dir.path(), "../bash", &[], ".", 30) + .expect_err("reject executable path"); assert_eq!(error.stage(), ProjectCommandErrorStage::Validation); assert_eq!(error.stage().as_str(), "validation"); assert!(!error.execution_started()); diff --git a/apps/ai-game-creator-shell/src-tauri/src/command_sandbox.rs b/apps/ai-game-creator-shell/src-tauri/src/command_sandbox.rs new file mode 100644 index 000000000..a6152d850 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/command_sandbox.rs @@ -0,0 +1,1161 @@ +#[cfg(target_os = "linux")] +use std::ffi::OsStr; +use std::ffi::OsString; +use std::fmt; +use std::path::{Path, PathBuf}; + +#[cfg(target_os = "linux")] +const COMMAND_SANDBOX_PROFILE_VERSION: &str = "workspace-v1"; +#[cfg(target_os = "linux")] +const COMMAND_SANDBOX_PRIVATE_ROOT: &str = "/tmp/genarrative-command"; +#[cfg(target_os = "linux")] +const COMMAND_SANDBOX_PRIVATE_HOME: &str = "/tmp/genarrative-command/home"; +#[cfg(target_os = "linux")] +const COMMAND_SANDBOX_PRIVATE_CACHE: &str = "/tmp/genarrative-command/cache"; +#[cfg(target_os = "linux")] +const COMMAND_SANDBOX_PRIVATE_CONFIG: &str = "/tmp/genarrative-command/config"; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct CommandSandboxMetadata { + pub(crate) backend: &'static str, + pub(crate) mode: &'static str, + pub(crate) network: &'static str, + pub(crate) profile_version: &'static str, +} + +impl CommandSandboxMetadata { + #[cfg(target_os = "linux")] + fn enforced_linux() -> Self { + Self { + backend: "bubblewrap", + mode: "workspace-write", + network: "disabled", + profile_version: COMMAND_SANDBOX_PROFILE_VERSION, + } + } + + #[cfg(not(target_os = "linux"))] + fn unsupported_legacy() -> Self { + Self { + backend: "legacy-host-restricted", + mode: "fixed-command", + network: "proxy-only", + profile_version: "legacy-v1", + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct CommandSandboxLaunch { + pub(crate) executable: PathBuf, + pub(crate) arguments: Vec, + pub(crate) cwd: PathBuf, + /// The caller must still use `env_clear`; target variables are encoded as + /// bubblewrap `--setenv` arguments so the launcher inherits nothing. + pub(crate) environment: Vec<(OsString, OsString)>, + pub(crate) metadata: CommandSandboxMetadata, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct CommandSandboxError { + message: String, + metadata: CommandSandboxMetadata, +} + +impl CommandSandboxError { + fn new(message: impl Into, metadata: CommandSandboxMetadata) -> Self { + Self { + message: message.into(), + metadata, + } + } + + #[cfg(not(target_os = "linux"))] + pub(crate) fn metadata(&self) -> &CommandSandboxMetadata { + &self.metadata + } +} + +impl fmt::Display for CommandSandboxError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.message) + } +} + +impl std::error::Error for CommandSandboxError {} + +pub(crate) fn command_sandbox_platform_metadata() -> CommandSandboxMetadata { + #[cfg(target_os = "linux")] + { + CommandSandboxMetadata::enforced_linux() + } + #[cfg(not(target_os = "linux"))] + { + CommandSandboxMetadata::unsupported_legacy() + } +} + +/// Builds a fail-closed launcher for a direct executable plus structured argv. +/// It never falls back to launching the original command on the host. +pub(crate) fn prepare_command_sandbox_launch( + root: &Path, + executable: &Path, + arguments: &[String], + cwd: &Path, + environment: &[(OsString, OsString)], +) -> Result { + #[cfg(target_os = "linux")] + { + prepare_linux_command_sandbox_launch(root, executable, arguments, cwd, environment) + } + #[cfg(not(target_os = "linux"))] + { + let _ = (root, executable, arguments, cwd, environment); + let metadata = CommandSandboxMetadata::unsupported_legacy(); + Err(CommandSandboxError::new( + "当前平台没有可用的 OS-enforced workspace sandbox;拒绝宿主直通执行", + metadata, + )) + } +} + +#[cfg(target_os = "linux")] +mod linux { + use super::*; + use std::collections::{BTreeMap, BTreeSet}; + use std::fs; + use std::os::unix::ffi::OsStrExt; + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + use std::process::{Command, Stdio}; + use std::sync::OnceLock; + use std::time::{Duration, Instant}; + + const TRUSTED_BWRAP_PATHS: [&str; 2] = ["/usr/bin/bwrap", "/bin/bwrap"]; + const PROTECTED_READ_ONLY_NAMES: [&str; 4] = [".git", ".agents", ".codex", ".hermes"]; + const TOOLCHAIN_ENVIRONMENT_ROOTS: [&str; 4] = + ["RUSTUP_HOME", "JAVA_HOME", "GOROOT", "DOTNET_ROOT"]; + const FIXED_SYSTEM_READ_ONLY_PATHS: [&str; 8] = [ + "/etc/alternatives", + "/etc/group", + "/etc/hosts", + "/etc/ld.so.cache", + "/etc/localtime", + "/etc/nsswitch.conf", + "/etc/passwd", + "/etc/resolv.conf", + ]; + + static BWRAP_PREFLIGHT: OnceLock> = OnceLock::new(); + + #[derive(Clone, Debug, Eq, PartialEq)] + struct ReadOnlyMount { + source: PathBuf, + destination: PathBuf, + } + + #[derive(Clone, Debug, Eq, PartialEq)] + struct LinuxSandboxPlan { + bwrap: PathBuf, + root: PathBuf, + cwd: PathBuf, + executable: PathBuf, + arguments: Vec, + target_environment: Vec<(OsString, OsString)>, + merged_usr_links: Vec<(OsString, PathBuf)>, + protected_read_only: Vec, + external_read_only: Vec, + fixed_system_read_only: Vec, + } + + pub(super) fn prepare_linux_command_sandbox_launch( + root: &Path, + executable: &Path, + arguments: &[String], + cwd: &Path, + environment: &[(OsString, OsString)], + ) -> Result { + let metadata = CommandSandboxMetadata::enforced_linux(); + let bwrap = find_trusted_bwrap().map_err(|error| { + CommandSandboxError::new( + format!("command sandbox unavailable: {error}"), + metadata.clone(), + ) + })?; + let merged_usr_links = inspect_merged_usr_layout().map_err(|error| { + CommandSandboxError::new( + format!("command sandbox merged-usr preflight 失败:{error}"), + metadata.clone(), + ) + })?; + ensure_bwrap_preflight(&bwrap, &merged_usr_links).map_err(|error| { + CommandSandboxError::new( + format!("command sandbox namespace preflight 失败:{error}"), + metadata.clone(), + ) + })?; + + let root = canonical_workspace_root(root).map_err(|error| { + CommandSandboxError::new( + format!("command sandbox workspace 无效:{error}"), + metadata.clone(), + ) + })?; + let cwd = canonical_workspace_cwd(&root, cwd).map_err(|error| { + CommandSandboxError::new( + format!("command sandbox cwd 无效:{error}"), + metadata.clone(), + ) + })?; + let executable = validate_executable(executable).map_err(|error| { + CommandSandboxError::new( + format!("command sandbox executable 无效:{error}"), + metadata.clone(), + ) + })?; + let protected_read_only = inspect_workspace_control_paths(&root).map_err(|error| { + CommandSandboxError::new( + format!("command sandbox 控制目录无效:{error}"), + metadata.clone(), + ) + })?; + let target_environment = normalize_target_environment(environment).map_err(|error| { + CommandSandboxError::new( + format!("command sandbox environment 无效:{error}"), + metadata.clone(), + ) + })?; + let external_read_only = + collect_external_toolchain_mounts(&root, &executable, &target_environment).map_err( + |error| { + CommandSandboxError::new( + format!("command sandbox toolchain mount 无效:{error}"), + metadata.clone(), + ) + }, + )?; + let fixed_system_read_only = collect_fixed_system_mounts(); + + let launch = build_linux_bwrap_launch(LinuxSandboxPlan { + bwrap, + root, + cwd, + executable, + arguments: arguments.to_vec(), + target_environment, + merged_usr_links, + protected_read_only, + external_read_only, + fixed_system_read_only, + }); + run_project_mount_preflight(&launch).map_err(|error| { + CommandSandboxError::new( + format!("command sandbox project mount preflight 失败:{error}"), + metadata, + ) + })?; + Ok(launch) + } + + fn canonical_workspace_root(root: &Path) -> Result { + let root = + fs::canonicalize(root).map_err(|error| format!("无法 canonicalize 项目根:{error}"))?; + if !root.is_absolute() || !root.is_dir() || root.parent().is_none() { + return Err("项目根必须是非根目录的绝对普通目录".to_string()); + } + for system_root in ["/usr", "/etc", "/proc", "/dev", "/run"] { + if root == Path::new(system_root) || root.starts_with(Path::new(system_root)) { + return Err(format!("项目根不能位于系统目录 {system_root}")); + } + } + Ok(root) + } + + fn canonical_workspace_cwd(root: &Path, cwd: &Path) -> Result { + let cwd = + fs::canonicalize(cwd).map_err(|error| format!("无法 canonicalize cwd:{error}"))?; + if !cwd.is_dir() || !cwd.starts_with(root) { + return Err("cwd 必须是项目根内的普通目录".to_string()); + } + if cwd.starts_with(root.join(".agent")) { + return Err("cwd 不能位于隐藏的 .agent 控制目录".to_string()); + } + Ok(cwd) + } + + fn validate_executable(executable: &Path) -> Result { + if !executable.is_absolute() { + return Err("executable 必须是绝对路径".to_string()); + } + let canonical = fs::canonicalize(executable) + .map_err(|error| format!("无法 canonicalize executable:{error}"))?; + let metadata = + fs::metadata(&canonical).map_err(|error| format!("无法读取 executable:{error}"))?; + if !metadata.is_file() || metadata.permissions().mode() & 0o111 == 0 { + return Err("executable 必须是可执行普通文件".to_string()); + } + // Keep the original absolute entry so argv[0]-sensitive proxies such as + // cargo -> rustup retain their selected tool identity inside the sandbox. + Ok(executable.to_path_buf()) + } + + fn inspect_workspace_control_paths(root: &Path) -> Result, String> { + let agent = root.join(".agent"); + let metadata = fs::symlink_metadata(&agent) + .map_err(|error| format!(".agent 必须在 sandbox 准备前存在:{error}"))?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err(".agent 必须是项目根内普通目录".to_string()); + } + let mut protected = Vec::new(); + for name in PROTECTED_READ_ONLY_NAMES { + let path = root.join(name); + let metadata = match fs::symlink_metadata(&path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, + Err(error) => return Err(format!("读取 {name} 失败:{error}")), + }; + if metadata.file_type().is_symlink() { + return Err(format!("{name} 不能是符号链接")); + } + let canonical = fs::canonicalize(&path) + .map_err(|error| format!("canonicalize {name} 失败:{error}"))?; + if !canonical.starts_with(root) { + return Err(format!("{name} 逃逸项目根")); + } + protected.push(canonical); + } + Ok(protected) + } + + fn normalize_target_environment( + environment: &[(OsString, OsString)], + ) -> Result, String> { + let mut values = BTreeMap::::new(); + for (name, value) in environment { + validate_environment_name(name)?; + let replacement = match name.to_str().unwrap_or_default() { + "HOME" | "USERPROFILE" => Some(COMMAND_SANDBOX_PRIVATE_HOME), + "TMPDIR" | "TEMP" | "TMP" => Some("/tmp"), + "CARGO_HOME" => Some("/tmp/genarrative-command/cache/cargo"), + "XDG_CACHE_HOME" | "npm_config_cache" => Some("/tmp/genarrative-command/cache/npm"), + "XDG_CONFIG_HOME" => Some(COMMAND_SANDBOX_PRIVATE_CONFIG), + "npm_config_userconfig" => Some("/dev/null"), + "GIT_CONFIG_GLOBAL" => Some("/dev/null"), + _ => None, + }; + values.insert( + name.clone(), + replacement + .map(OsString::from) + .unwrap_or_else(|| value.clone()), + ); + } + for (name, value) in [ + ("HOME", COMMAND_SANDBOX_PRIVATE_HOME), + ("USERPROFILE", COMMAND_SANDBOX_PRIVATE_HOME), + ("TMPDIR", "/tmp"), + ("TEMP", "/tmp"), + ("TMP", "/tmp"), + ("XDG_CACHE_HOME", COMMAND_SANDBOX_PRIVATE_CACHE), + ("XDG_CONFIG_HOME", COMMAND_SANDBOX_PRIVATE_CONFIG), + ("GENARRATIVE_COMMAND_SANDBOX", "1"), + ("GENARRATIVE_COMMAND_SANDBOX_BACKEND", "bubblewrap"), + ] { + values.insert(OsString::from(name), OsString::from(value)); + } + Ok(values.into_iter().collect()) + } + + fn validate_environment_name(name: &OsStr) -> Result<(), String> { + let bytes = name.as_bytes(); + if bytes.is_empty() || bytes.contains(&0) || bytes.contains(&b'=') { + return Err("环境变量名为空、包含 NUL 或 '='".to_string()); + } + Ok(()) + } + + fn collect_external_toolchain_mounts( + root: &Path, + executable: &Path, + environment: &[(OsString, OsString)], + ) -> Result, String> { + let mut mounts = BTreeMap::::new(); + add_external_mount(root, executable, executable, &mut mounts)?; + for name in TOOLCHAIN_ENVIRONMENT_ROOTS { + let Some(value) = environment + .iter() + .find(|(candidate, _)| candidate == OsStr::new(name)) + .map(|(_, value)| value) + else { + continue; + }; + let path = PathBuf::from(value); + if !path.is_absolute() { + return Err(format!("{name} 必须是绝对路径")); + } + let canonical = fs::canonicalize(&path) + .map_err(|error| format!("canonicalize {name} 失败:{error}"))?; + if !canonical.is_dir() { + return Err(format!("{name} 必须指向普通目录")); + } + validate_external_toolchain_root(name, &canonical)?; + add_external_mount(root, &canonical, &canonical, &mut mounts)?; + } + Ok(mounts + .into_iter() + .map(|(destination, source)| ReadOnlyMount { + source, + destination, + }) + .collect()) + } + + fn validate_external_toolchain_root(name: &str, root: &Path) -> Result<(), String> { + if root.parent() == Some(Path::new("/home")) { + return Err(format!( + "拒绝把用户主目录作为 {name} 挂载:{}", + root.display() + )); + } + let leaf = root + .file_name() + .and_then(OsStr::to_str) + .map(str::to_ascii_lowercase) + .ok_or_else(|| format!("{name} 缺少可识别的工具链目录名"))?; + let expected_leaf = match name { + "RUSTUP_HOME" => leaf == ".rustup" || leaf.contains("rustup"), + "JAVA_HOME" => leaf.contains("java") || leaf.contains("jdk") || leaf.contains("jre"), + "GOROOT" => leaf == "go" || leaf.contains("golang"), + "DOTNET_ROOT" => leaf.contains("dotnet"), + _ => false, + }; + if !expected_leaf { + return Err(format!( + "拒绝把非 {name} 工具链目录挂载进沙箱:{}", + root.display() + )); + } + Ok(()) + } + + fn add_external_mount( + root: &Path, + source: &Path, + destination: &Path, + mounts: &mut BTreeMap, + ) -> Result<(), String> { + if source.starts_with("/usr") + || source.starts_with(root) + || matches!(source.to_str(), Some("/bin" | "/sbin" | "/lib" | "/lib64")) + { + return Ok(()); + } + if !source.is_absolute() || !destination.is_absolute() { + return Err("外部工具链 mount 必须是绝对路径".to_string()); + } + if matches!(source.to_str(), Some("/" | "/home" | "/root" | "/tmp")) { + return Err(format!("拒绝挂载宽泛宿主用户路径:{}", source.display())); + } + mounts.insert(destination.to_path_buf(), source.to_path_buf()); + Ok(()) + } + + fn collect_fixed_system_mounts() -> Vec { + FIXED_SYSTEM_READ_ONLY_PATHS + .iter() + .filter_map(|path| { + let path = PathBuf::from(path); + path.exists().then(|| ReadOnlyMount { + source: path.clone(), + destination: path, + }) + }) + .collect() + } + + fn build_linux_bwrap_launch(plan: LinuxSandboxPlan) -> CommandSandboxLaunch { + let mut args = Vec::::new(); + push_namespace_arguments(&mut args); + push_ro_bind(&mut args, Path::new("/usr"), Path::new("/usr")); + for (target, destination) in &plan.merged_usr_links { + push_option( + &mut args, + "--symlink", + [target.as_os_str(), destination.as_os_str()], + ); + } + push_option(&mut args, "--proc", [OsStr::new("/proc")]); + push_option(&mut args, "--dev", [OsStr::new("/dev")]); + push_option(&mut args, "--tmpfs", [OsStr::new("/tmp")]); + push_chmod(&mut args, "1777", Path::new("/tmp")); + push_option(&mut args, "--tmpfs", [OsStr::new("/run")]); + push_chmod(&mut args, "0755", Path::new("/run")); + for directory in [ + COMMAND_SANDBOX_PRIVATE_ROOT, + COMMAND_SANDBOX_PRIVATE_HOME, + COMMAND_SANDBOX_PRIVATE_CACHE, + "/tmp/genarrative-command/cache/cargo", + "/tmp/genarrative-command/cache/npm", + COMMAND_SANDBOX_PRIVATE_CONFIG, + ] { + push_option(&mut args, "--dir", [OsStr::new(directory)]); + } + push_chmod(&mut args, "0700", Path::new(COMMAND_SANDBOX_PRIVATE_ROOT)); + + let mut created_directories = BTreeSet::new(); + for mount in plan + .fixed_system_read_only + .iter() + .chain(plan.external_read_only.iter()) + { + push_destination_parent_dirs( + &mut args, + &mount.destination, + &plan.root, + &mut created_directories, + ); + push_ro_bind(&mut args, &mount.source, &mount.destination); + } + + push_option( + &mut args, + "--bind", + [plan.root.as_os_str(), plan.root.as_os_str()], + ); + for protected in &plan.protected_read_only { + push_ro_bind(&mut args, protected, protected); + } + let agent_path = plan.root.join(".agent"); + push_option(&mut args, "--tmpfs", [agent_path.as_os_str()]); + push_chmod(&mut args, "0000", &agent_path); + + for (name, value) in &plan.target_environment { + push_option(&mut args, "--setenv", [name.as_os_str(), value.as_os_str()]); + } + push_option(&mut args, "--chdir", [plan.cwd.as_os_str()]); + args.push(OsString::from("--")); + args.push(plan.executable.as_os_str().to_owned()); + args.extend(plan.arguments.iter().map(OsString::from)); + + CommandSandboxLaunch { + executable: plan.bwrap, + arguments: args, + cwd: plan.root, + environment: Vec::new(), + metadata: CommandSandboxMetadata::enforced_linux(), + } + } + + fn push_namespace_arguments(args: &mut Vec) { + for argument in [ + "--die-with-parent", + "--unshare-all", + "--unshare-user", + "--disable-userns", + "--assert-userns-disabled", + "--cap-drop", + "ALL", + "--clearenv", + ] { + args.push(OsString::from(argument)); + } + } + + fn push_option<'a, I>(args: &mut Vec, option: &str, values: I) + where + I: IntoIterator, + { + args.push(OsString::from(option)); + args.extend(values.into_iter().map(OsStr::to_owned)); + } + + fn push_ro_bind(args: &mut Vec, source: &Path, destination: &Path) { + push_option( + args, + "--ro-bind", + [source.as_os_str(), destination.as_os_str()], + ); + } + + fn push_chmod(args: &mut Vec, mode: &str, path: &Path) { + push_option(args, "--chmod", [OsStr::new(mode), path.as_os_str()]); + } + + fn push_destination_parent_dirs( + args: &mut Vec, + destination: &Path, + workspace_root: &Path, + created: &mut BTreeSet, + ) { + let Some(parent) = destination.parent() else { + return; + }; + let mut parents = parent + .ancestors() + .take_while(|path| *path != Path::new("/")) + .map(Path::to_path_buf) + .collect::>(); + parents.reverse(); + for path in parents { + if path.starts_with("/usr") + || path.starts_with(workspace_root) + || matches!(path.to_str(), Some("/tmp" | "/run")) + || !created.insert(path.clone()) + { + continue; + } + push_option(args, "--dir", [path.as_os_str()]); + } + } + + fn inspect_merged_usr_layout() -> Result, String> { + let candidates = [ + ("/bin", "usr/bin"), + ("/sbin", "usr/sbin"), + ("/lib", "usr/lib"), + ("/lib64", "usr/lib64"), + ]; + let mut links = Vec::new(); + for (destination, expected_target) in candidates { + if !Path::new(&format!("/{expected_target}")).exists() { + continue; + } + let target = fs::read_link(destination) + .map_err(|error| format!("读取 merged-usr link {destination} 失败:{error}"))?; + if target != Path::new(expected_target) { + return Err(format!( + "{destination} 必须指向 {expected_target},当前为 {}", + target.display() + )); + } + links.push((OsString::from(expected_target), PathBuf::from(destination))); + } + Ok(links) + } + + #[derive(Debug)] + struct BwrapInspection { + requested: PathBuf, + result: Result, + } + + fn find_trusted_bwrap() -> Result { + choose_trusted_bwrap(TRUSTED_BWRAP_PATHS.into_iter().map(|path| BwrapInspection { + requested: PathBuf::from(path), + result: inspect_trusted_bwrap(Path::new(path)), + })) + } + + fn choose_trusted_bwrap(inspections: I) -> Result + where + I: IntoIterator, + { + let mut failures = Vec::new(); + for inspection in inspections { + match inspection.result { + Ok(path) => return Ok(path), + Err(error) => failures.push(format!("{}: {error}", inspection.requested.display())), + } + } + Err(format!( + "未找到固定受信任系统 bwrap({})", + failures.join(";") + )) + } + + fn inspect_trusted_bwrap(path: &Path) -> Result { + let metadata = fs::metadata(path).map_err(|error| format!("不可用:{error}"))?; + if !metadata.is_file() + || metadata.uid() != 0 + || metadata.permissions().mode() & 0o111 == 0 + || metadata.permissions().mode() & 0o022 != 0 + { + return Err("必须是 root-owned、可执行且不可被 group/other 写入的普通文件".to_string()); + } + let canonical = + fs::canonicalize(path).map_err(|error| format!("canonicalize 失败:{error}"))?; + if canonical != Path::new("/usr/bin/bwrap") && canonical != Path::new("/bin/bwrap") { + return Err(format!("canonical path 不受信任:{}", canonical.display())); + } + let parent = canonical + .parent() + .ok_or_else(|| "缺少 parent".to_string())?; + let parent_metadata = + fs::metadata(parent).map_err(|error| format!("读取 parent 失败:{error}"))?; + if parent_metadata.uid() != 0 || parent_metadata.permissions().mode() & 0o022 != 0 { + return Err("bwrap parent 必须 root-owned 且不可被 group/other 写入".to_string()); + } + Ok(canonical) + } + + fn ensure_bwrap_preflight( + bwrap: &Path, + merged_usr_links: &[(OsString, PathBuf)], + ) -> Result<(), String> { + BWRAP_PREFLIGHT + .get_or_init(|| run_bwrap_preflight(bwrap, merged_usr_links)) + .clone() + } + + fn run_bwrap_preflight( + bwrap: &Path, + merged_usr_links: &[(OsString, PathBuf)], + ) -> Result<(), String> { + let mut args = Vec::::new(); + push_namespace_arguments(&mut args); + push_ro_bind(&mut args, Path::new("/usr"), Path::new("/usr")); + for (target, destination) in merged_usr_links { + push_option( + &mut args, + "--symlink", + [target.as_os_str(), destination.as_os_str()], + ); + } + push_option(&mut args, "--proc", [OsStr::new("/proc")]); + push_option(&mut args, "--dev", [OsStr::new("/dev")]); + push_option(&mut args, "--tmpfs", [OsStr::new("/tmp")]); + args.push(OsString::from("--")); + args.push(OsString::from("/usr/bin/true")); + + let mut child = Command::new(bwrap) + .args(&args) + .current_dir("/") + .env_clear() + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|error| format!("启动 bwrap preflight 失败:{error}"))?; + let deadline = Instant::now() + Duration::from_secs(3); + loop { + match child.try_wait() { + Ok(Some(status)) if status.success() => return Ok(()), + Ok(Some(status)) => { + let output = child + .wait_with_output() + .map_err(|error| format!("读取 bwrap preflight 输出失败:{error}"))?; + return Err(format!( + "bwrap preflight 退出 {}:{}", + status, + String::from_utf8_lossy(&output.stderr).trim() + )); + } + Ok(None) if Instant::now() < deadline => { + std::thread::sleep(Duration::from_millis(20)); + } + Ok(None) => { + let _ = child.kill(); + let _ = child.wait(); + return Err("bwrap preflight 超时".to_string()); + } + Err(error) => return Err(format!("等待 bwrap preflight 失败:{error}")), + } + } + } + + fn run_project_mount_preflight(launch: &CommandSandboxLaunch) -> Result<(), String> { + let separator = launch + .arguments + .iter() + .rposition(|argument| argument == OsStr::new("--")) + .ok_or_else(|| "sandbox launcher 缺少 argv 分隔符".to_string())?; + let mut arguments = launch.arguments[..=separator].to_vec(); + arguments.push(OsString::from("/usr/bin/true")); + let mut child = Command::new(&launch.executable) + .args(arguments) + .current_dir(&launch.cwd) + .env_clear() + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|error| format!("启动 project mount preflight 失败:{error}"))?; + let deadline = Instant::now() + Duration::from_secs(3); + loop { + match child.try_wait() { + Ok(Some(status)) if status.success() => return Ok(()), + Ok(Some(status)) => { + let output = child.wait_with_output().map_err(|error| { + format!("读取 project mount preflight 输出失败:{error}") + })?; + return Err(format!( + "project mount preflight 退出 {status}:{}", + String::from_utf8_lossy(&output.stderr).trim() + )); + } + Ok(None) if Instant::now() < deadline => { + std::thread::sleep(Duration::from_millis(20)); + } + Ok(None) => { + let _ = child.kill(); + let _ = child.wait(); + return Err("project mount preflight 超时".to_string()); + } + Err(error) => return Err(format!("等待 project mount preflight 失败:{error}")), + } + } + } + + #[cfg(test)] + mod tests { + use super::*; + use std::io::Write; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn has_sequence(arguments: &[OsString], values: &[&str]) -> bool { + arguments.windows(values.len()).any(|window| { + window + .iter() + .zip(values) + .all(|(actual, expected)| actual == OsStr::new(expected)) + }) + } + + #[test] + fn pure_builder_constructs_empty_workspace_namespace_and_private_environment() { + let launch = build_linux_bwrap_launch(LinuxSandboxPlan { + bwrap: PathBuf::from("/usr/bin/bwrap"), + root: PathBuf::from("/workspace/project"), + cwd: PathBuf::from("/workspace/project/game"), + executable: PathBuf::from("/opt/toolchain/bin/tool"), + arguments: vec!["check".to_string(), "--flag".to_string()], + target_environment: vec![ + ( + OsString::from("HOME"), + OsString::from(COMMAND_SANDBOX_PRIVATE_HOME), + ), + (OsString::from("PATH"), OsString::from("/usr/bin")), + ], + merged_usr_links: vec![ + (OsString::from("usr/bin"), PathBuf::from("/bin")), + (OsString::from("usr/lib"), PathBuf::from("/lib")), + ], + protected_read_only: vec![ + PathBuf::from("/workspace/project/.git"), + PathBuf::from("/workspace/project/.agents"), + PathBuf::from("/workspace/project/.codex"), + PathBuf::from("/workspace/project/.hermes"), + ], + external_read_only: vec![ReadOnlyMount { + source: PathBuf::from("/opt/toolchain/bin/tool"), + destination: PathBuf::from("/opt/toolchain/bin/tool"), + }], + fixed_system_read_only: Vec::new(), + }); + + assert_eq!(launch.executable, Path::new("/usr/bin/bwrap")); + assert!(launch.environment.is_empty()); + assert_eq!(launch.metadata.backend, "bubblewrap"); + assert_eq!(launch.metadata.mode, "workspace-write"); + assert_eq!(launch.metadata.network, "disabled"); + assert!(has_sequence( + &launch.arguments, + &["--unshare-all", "--unshare-user"] + )); + assert!(launch + .arguments + .contains(&OsString::from("--disable-userns"))); + assert!(launch + .arguments + .contains(&OsString::from("--assert-userns-disabled"))); + assert!(!launch.arguments.contains(&OsString::from("--share-net"))); + assert!(has_sequence( + &launch.arguments, + &["--ro-bind", "/usr", "/usr"] + )); + assert!(has_sequence( + &launch.arguments, + &["--bind", "/workspace/project", "/workspace/project"] + )); + for path in [".git", ".agents", ".codex", ".hermes"] { + let path = format!("/workspace/project/{path}"); + assert!(has_sequence( + &launch.arguments, + &["--ro-bind", &path, &path] + )); + } + assert!(has_sequence( + &launch.arguments, + &[ + "--tmpfs", + "/workspace/project/.agent", + "--chmod", + "0000", + "/workspace/project/.agent" + ] + )); + assert!(has_sequence( + &launch.arguments, + &[ + "--ro-bind", + "/opt/toolchain/bin/tool", + "/opt/toolchain/bin/tool" + ] + )); + assert!(has_sequence( + &launch.arguments, + &["--setenv", "HOME", COMMAND_SANDBOX_PRIVATE_HOME] + )); + assert!(launch.arguments.ends_with(&[ + OsString::from("--"), + OsString::from("/opt/toolchain/bin/tool"), + OsString::from("check"), + OsString::from("--flag"), + ])); + } + + #[test] + fn unavailable_parser_reports_fixed_candidates_without_host_fallback() { + let error = choose_trusted_bwrap([ + BwrapInspection { + requested: PathBuf::from("/usr/bin/bwrap"), + result: Err("missing".to_string()), + }, + BwrapInspection { + requested: PathBuf::from("/bin/bwrap"), + result: Err("group-writable".to_string()), + }, + ]) + .expect_err("unavailable bwrap must fail closed"); + + assert!(error.contains("/usr/bin/bwrap: missing")); + assert!(error.contains("/bin/bwrap: group-writable")); + assert!(!error.contains("直接执行")); + } + + #[test] + fn invalid_control_mount_fails_before_target_execution() { + use std::os::unix::fs::symlink; + + let tree = unique_temp_tree(); + let root = tree.0.join("workspace-preflight"); + let outside = tree.0.join("outside-control"); + std::fs::create_dir_all(root.join(".agent")).expect("create runtime control"); + std::fs::create_dir_all(&outside).expect("create outside control"); + symlink(&outside, root.join(".codex")).expect("create invalid control symlink"); + let marker = root.join("target-ran"); + let script = format!( + "from pathlib import Path; Path({:?}).write_text('ran')", + marker.to_string_lossy() + ); + let error = prepare_command_sandbox_launch( + &root, + Path::new("/usr/bin/python3"), + &["-c".to_string(), script], + &root, + &[(OsString::from("PATH"), OsString::from("/usr/bin"))], + ) + .expect_err("invalid protected path must fail closed"); + assert!(error.to_string().contains(".codex")); + assert!( + !marker.exists(), + "target program must not execute on preflight failure" + ); + } + + #[test] + fn external_toolchain_roots_reject_home_and_symlinked_home() { + use std::os::unix::fs::symlink; + + let tree = unique_temp_tree(); + let root = tree.0.join("workspace-toolchain-root"); + let home = tree.0.join("developer-home"); + std::fs::create_dir_all(&root).expect("create workspace"); + std::fs::create_dir_all(&home).expect("create fake home"); + + let direct = collect_external_toolchain_mounts( + &root, + Path::new("/usr/bin/true"), + &[(OsString::from("RUSTUP_HOME"), home.as_os_str().to_owned())], + ) + .expect_err("whole home must never be mounted as rustup root"); + assert!(direct.contains("非 RUSTUP_HOME 工具链目录"), "{direct}"); + + let rustup_link = tree.0.join(".rustup"); + symlink(&home, &rustup_link).expect("create rustup-to-home symlink"); + let linked = collect_external_toolchain_mounts( + &root, + Path::new("/usr/bin/true"), + &[(OsString::from("RUSTUP_HOME"), rustup_link.into_os_string())], + ) + .expect_err("canonicalized home target must never be mounted"); + assert!(linked.contains("非 RUSTUP_HOME 工具链目录"), "{linked}"); + } + + #[test] + fn external_toolchain_root_accepts_narrow_rustup_directory() { + let tree = unique_temp_tree(); + let root = tree.0.join("workspace-narrow-toolchain"); + let rustup_home = tree.0.join(".rustup"); + std::fs::create_dir_all(&root).expect("create workspace"); + std::fs::create_dir_all(&rustup_home).expect("create rustup home"); + + let mounts = collect_external_toolchain_mounts( + &root, + Path::new("/usr/bin/true"), + &[( + OsString::from("RUSTUP_HOME"), + rustup_home.as_os_str().to_owned(), + )], + ) + .expect("narrow rustup root should be mountable"); + assert_eq!(mounts.len(), 1); + assert_eq!(mounts[0].source, rustup_home); + } + + struct TempTree(PathBuf); + + impl Drop for TempTree { + fn drop(&mut self) { + std::fs::remove_dir_all(&self.0).ok(); + } + } + + fn unique_temp_tree() -> TempTree { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time") + .as_nanos(); + let path = std::env::temp_dir().join(format!( + "genarrative-command-sandbox-{}-{nanos}", + std::process::id() + )); + std::fs::create_dir_all(&path).expect("create temp tree"); + TempTree(path) + } + + #[test] + fn command_sandbox_real_linux_opt_in_enforces_workspace() { + if std::env::var_os("GENARRATIVE_COMMAND_SANDBOX_REAL_TEST").is_none() { + return; + } + let tree = unique_temp_tree(); + let root = tree.0.join("workspace"); + let outside = tree.0.join("outside-secret.txt"); + std::fs::create_dir_all(&root).expect("create workspace"); + std::fs::write(&outside, "OUTSIDE_SECRET").expect("write outside secret"); + for name in [".agent", ".git", ".agents", ".codex", ".hermes"] { + std::fs::create_dir_all(root.join(name)).expect("create control directory"); + std::fs::write(root.join(name).join("marker"), name).expect("write control marker"); + } + let script = format!( + r#" +from pathlib import Path +import socket +import subprocess + +Path("workspace-write.txt").write_text("WORKSPACE_OK") +assert Path("workspace-write.txt").read_text() == "WORKSPACE_OK" + +for control in [".git", ".agents", ".codex", ".hermes"]: + assert Path(control, "marker").read_text() == control + try: + Path(control, "blocked-write").write_text("NO") + raise AssertionError(control + " was writable") + except OSError: + pass + +try: + Path(".agent/marker").read_text() + raise AssertionError(".agent was visible") +except OSError: + pass + +outside = Path({outside:?}) +try: + outside.read_text() + raise AssertionError("outside host file was visible") +except OSError: + pass + +child = subprocess.run([ + "/usr/bin/python3", "-c", + "from pathlib import Path; p=Path(" + repr(str(outside)) + "); " + "assert not p.exists(); Path('child-write.txt').write_text('CHILD_OK')" +], check=False) +assert child.returncode == 0 +assert Path("child-write.txt").read_text() == "CHILD_OK" + +s = socket.socket() +s.settimeout(0.2) +try: + s.connect(("1.1.1.1", 53)) + raise AssertionError("network namespace was shared") +except OSError: + pass +finally: + s.close() + +print("SANDBOX_OK") +"#, + outside = outside.to_string_lossy() + ); + let environment = vec![ + (OsString::from("PATH"), OsString::from("/usr/bin")), + (OsString::from("HOME"), OsString::from("/host/home")), + ]; + let launch = prepare_command_sandbox_launch( + &root, + Path::new("/usr/bin/python3"), + &["-c".to_string(), script], + &root, + &environment, + ) + .expect("prepare real Linux sandbox"); + + let output = Command::new(&launch.executable) + .args(&launch.arguments) + .current_dir(&launch.cwd) + .env_clear() + .envs(launch.environment.iter().cloned()) + .output() + .expect("run real Linux sandbox"); + if !output.status.success() { + let mut diagnostics = std::io::stderr().lock(); + let _ = diagnostics.write_all(&output.stdout); + let _ = diagnostics.write_all(&output.stderr); + } + assert!(output.status.success()); + assert!(String::from_utf8_lossy(&output.stdout).contains("SANDBOX_OK")); + assert_eq!( + std::fs::read_to_string(root.join("workspace-write.txt")) + .expect("workspace write persisted"), + "WORKSPACE_OK" + ); + assert_eq!( + std::fs::read_to_string(root.join("child-write.txt")) + .expect("child write persisted"), + "CHILD_OK" + ); + assert_eq!( + std::fs::read_to_string(&outside).expect("outside secret unchanged"), + "OUTSIDE_SECRET" + ); + for name in [".git", ".agents", ".codex", ".hermes"] { + assert!(!root.join(name).join("blocked-write").exists()); + } + } + } +} + +#[cfg(target_os = "linux")] +use linux::prepare_linux_command_sandbox_launch; + +#[cfg(all(test, not(target_os = "linux")))] +mod unsupported_tests { + use super::*; + + #[test] + fn unsupported_platform_returns_legacy_metadata_without_launcher() { + let error = prepare_command_sandbox_launch( + Path::new("."), + Path::new("tool"), + &[], + Path::new("."), + &[], + ) + .expect_err("unsupported platform must fail closed"); + assert_eq!(error.metadata().backend, "legacy-host-restricted"); + assert_eq!(error.metadata().mode, "fixed-command"); + assert_eq!(error.metadata().network, "proxy-only"); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index d5415f55b..a239c9440 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -46,6 +46,7 @@ mod browser; mod cli; mod command_exec; mod command_output; +mod command_sandbox; mod commands; mod config; #[cfg(all(debug_assertions, not(test)))] @@ -67,6 +68,7 @@ use browser::*; use cli::*; use command_exec::*; use command_output::*; +use command_sandbox::*; use commands::*; use config::*; use git_inspect::*; diff --git a/apps/ai-game-creator-shell/src-tauri/src/process_session.rs b/apps/ai-game-creator-shell/src-tauri/src/process_session.rs index f3995d996..6c51b8266 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/process_session.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/process_session.rs @@ -4,7 +4,7 @@ use sha2::{Digest, Sha256}; use std::collections::HashMap; use std::sync::Condvar; -const PROCESS_SESSION_SCHEMA_VERSION: &str = "1"; +const PROCESS_SESSION_SCHEMA_VERSION: &str = "2"; const PROCESS_SESSION_CURSOR_VERSION: &str = "v1"; const PROCESS_SESSION_MAX_PER_PROJECT: usize = 4; const PROCESS_SESSION_MAX_PER_AGENT: usize = 2; @@ -51,6 +51,14 @@ pub(crate) struct ProcessSessionRecord { pub(crate) command_id: String, pub(crate) program: String, pub(crate) cwd: String, + #[serde(default)] + pub(crate) sandbox_backend: String, + #[serde(default)] + pub(crate) sandbox_mode: String, + #[serde(default)] + pub(crate) network_access: String, + #[serde(default)] + pub(crate) sandbox_profile_version: String, pub(crate) status: String, pub(crate) exit_code: Option, pub(crate) signal: Option, @@ -101,6 +109,10 @@ pub(crate) struct ProcessSessionPollResult { pub(crate) output_sha256: String, pub(crate) source_changed: Option, pub(crate) needs_reconciliation: bool, + pub(crate) sandbox_backend: String, + pub(crate) sandbox_mode: String, + pub(crate) network_access: String, + pub(crate) sandbox_profile_version: String, } #[derive(Clone, Debug, Eq, PartialEq, Serialize)] @@ -111,6 +123,10 @@ pub(crate) struct ProcessSessionStdinResult { pub(crate) content_sha256: String, pub(crate) stdin_open: bool, pub(crate) eof: bool, + pub(crate) sandbox_backend: String, + pub(crate) sandbox_mode: String, + pub(crate) network_access: String, + pub(crate) sandbox_profile_version: String, } #[derive(Debug)] @@ -158,6 +174,10 @@ struct LiveProcessSession { command_id: String, program: String, cwd: String, + sandbox_backend: String, + sandbox_mode: String, + network_access: String, + sandbox_profile_version: String, source_fingerprint_before: String, started_at: u64, output: Mutex, @@ -461,18 +481,29 @@ fn read_process_session_record( process_id: &str, ) -> Result, String> { validate_process_id(process_id)?; - let record = read_agent_runtime_json_sidecar_with_max_bytes( + let mut record = read_agent_runtime_json_sidecar_with_max_bytes::( root, &process_session_record_relative_path(process_id), "Agent Runtime process session", PROCESS_SESSION_RECORD_MAX_BYTES, )?; - if let Some(record) = &record { + if let Some(record) = record.as_mut() { + normalize_process_session_record(record); validate_process_session_record(root, record, process_id)?; } Ok(record) } +fn normalize_process_session_record(record: &mut ProcessSessionRecord) { + if record.schema_version == "1" { + record.schema_version = PROCESS_SESSION_SCHEMA_VERSION.to_string(); + record.sandbox_backend = "legacy-unknown".to_string(); + record.sandbox_mode = "unknown".to_string(); + record.network_access = "unknown".to_string(); + record.sandbox_profile_version = "legacy-v1".to_string(); + } +} + fn validate_process_session_record( root: &Path, record: &ProcessSessionRecord, @@ -481,6 +512,10 @@ fn validate_process_session_record( if record.schema_version != PROCESS_SESSION_SCHEMA_VERSION || record.process_id != process_id || record.project_id != game_creator_agent_runtime_context_project_id(root)? + || record.sandbox_backend.is_empty() + || record.sandbox_mode.is_empty() + || record.network_access.is_empty() + || record.sandbox_profile_version.is_empty() { return Err("Agent Runtime process session 身份不匹配".to_string()); } @@ -537,6 +572,10 @@ fn process_session_record_from_live( command_id: live.command_id.clone(), program: live.program.clone(), cwd: live.cwd.clone(), + sandbox_backend: live.sandbox_backend.clone(), + sandbox_mode: live.sandbox_mode.clone(), + network_access: live.network_access.clone(), + sandbox_profile_version: live.sandbox_profile_version.clone(), status: output.status.clone(), exit_code: output.exit_code, signal: output.signal.clone(), @@ -559,10 +598,23 @@ fn initial_process_session_record( process_id: &str, command_id: &str, spec: &ProjectCommandSpec, + launch: Option<&ProjectCommandLaunchSpec>, source_fingerprint_before: &str, status: &str, ) -> ProcessSessionRecord { let now = unix_timestamp(); + let sandbox_backend = launch + .map(|value| value.sandbox_backend.clone()) + .unwrap_or_else(|| "test-unknown".to_string()); + let sandbox_mode = launch + .map(|value| value.sandbox_mode.clone()) + .unwrap_or_else(|| "unknown".to_string()); + let network_access = launch + .map(|value| value.network_access.clone()) + .unwrap_or_else(|| "unknown".to_string()); + let sandbox_profile_version = launch + .map(|value| value.sandbox_profile_version.clone()) + .unwrap_or_else(|| "test-v1".to_string()); ProcessSessionRecord { schema_version: PROCESS_SESSION_SCHEMA_VERSION.to_string(), project_id: identity.project_id.clone(), @@ -577,6 +629,10 @@ fn initial_process_session_record( command_id: command_id.to_string(), program: spec.program.clone(), cwd: spec.cwd_relative.clone(), + sandbox_backend, + sandbox_mode, + network_access, + sandbox_profile_version, status: status.to_string(), exit_code: None, signal: None, @@ -815,7 +871,12 @@ fn process_session_command_builder( .to_str() .ok_or_else(|| "command.start executable 必须是 UTF-8 路径".to_string())?; let argv = std::iter::once(executable.to_string()) - .chain(launch.arguments.iter().cloned()) + .chain( + launch + .arguments + .iter() + .map(|argument| argument.to_string_lossy().into_owned()), + ) .collect::>(); command.env( PROCESS_SESSION_TEST_CHILD_ARGV_ENV, @@ -876,6 +937,18 @@ pub(crate) fn start_process_session_at( identity: ProcessSessionIdentity, spec: &ProjectCommandSpec, source_fingerprint_before: String, +) -> Result { + let launch = + prepare_project_command_launch_spec(root, spec).map_err(|error| error.to_string())?; + start_prepared_process_session_at(root, identity, spec, &launch, source_fingerprint_before) +} + +pub(crate) fn start_prepared_process_session_at( + root: &Path, + identity: ProcessSessionIdentity, + spec: &ProjectCommandSpec, + launch: &ProjectCommandLaunchSpec, + source_fingerprint_before: String, ) -> Result { validate_process_session_start_preflight_at(root, &identity, spec)?; if let Some(existing) = find_existing_start_action_record(root, &identity)? { @@ -910,8 +983,6 @@ pub(crate) fn start_process_session_at( } let process_id = process_session_id(&identity); - let launch = - prepare_project_command_launch_spec(root, spec).map_err(|error| error.to_string())?; let command_id = format!( "cmd-{}", &format!( @@ -932,6 +1003,7 @@ pub(crate) fn start_process_session_at( &process_id, &command_id, spec, + Some(launch), &source_fingerprint_before, "prepared", ); @@ -968,7 +1040,7 @@ pub(crate) fn start_process_session_at( format!("取得 command.start PTY writer 失败:{error}"), ) })?; - let command = process_session_command_builder(&launch) + let command = process_session_command_builder(launch) .map_err(|error| process_session_launch_failed(root, &mut durable_record, error))?; let mut child = pair.slave.spawn_command(command).map_err(|error| { process_session_launch_failed( @@ -1011,6 +1083,10 @@ pub(crate) fn start_process_session_at( command_id, program: spec.program.clone(), cwd: spec.cwd_relative.clone(), + sandbox_backend: launch.sandbox_backend.clone(), + sandbox_mode: launch.sandbox_mode.clone(), + network_access: launch.network_access.clone(), + sandbox_profile_version: launch.sandbox_profile_version.clone(), source_fingerprint_before, started_at: unix_timestamp(), output: Mutex::new(ProcessOutputState::running()), @@ -1516,6 +1592,10 @@ fn poll_result_from_output( process_id: &str, output: &str, state: &ProcessOutputState, + sandbox_backend: &str, + sandbox_mode: &str, + network_access: &str, + sandbox_profile_version: &str, cursor: Option<&str>, max_chars: usize, ) -> Result { @@ -1540,6 +1620,10 @@ fn poll_result_from_output( output_sha256: format!("{:x}", Sha256::digest(output.as_bytes())), source_changed: state.source_changed, needs_reconciliation: state.needs_reconciliation, + sandbox_backend: sandbox_backend.to_string(), + sandbox_mode: sandbox_mode.to_string(), + network_access: network_access.to_string(), + sandbox_profile_version: sandbox_profile_version.to_string(), }) } @@ -1572,7 +1656,17 @@ pub(crate) fn poll_process_session_at( .map_err(|_| "process session output 锁已损坏".to_string())?; output = waited.0; } - return poll_result_from_output(process_id, &output.text, &output, cursor, max_chars); + return poll_result_from_output( + process_id, + &output.text, + &output, + &live.sandbox_backend, + &live.sandbox_mode, + &live.network_access, + &live.sandbox_profile_version, + cursor, + max_chars, + ); } let mut record = read_process_session_record(root, process_id)? @@ -1627,7 +1721,17 @@ pub(crate) fn poll_process_session_at( source_changed: record.source_changed, needs_reconciliation: record.needs_reconciliation, }; - poll_result_from_output(process_id, output, &state, cursor, max_chars) + poll_result_from_output( + process_id, + output, + &state, + &record.sandbox_backend, + &record.sandbox_mode, + &record.network_access, + &record.sandbox_profile_version, + cursor, + max_chars, + ) } pub(crate) fn write_process_session_stdin_at( @@ -1714,6 +1818,10 @@ pub(crate) fn write_process_session_stdin_at( content_sha256, stdin_open: output.stdin_open, eof, + sandbox_backend: live.sandbox_backend.clone(), + sandbox_mode: live.sandbox_mode.clone(), + network_access: live.network_access.clone(), + sandbox_profile_version: live.sandbox_profile_version.clone(), }) } @@ -1967,13 +2075,32 @@ mod tests { source_changed: None, needs_reconciliation: false, }; - let first = poll_result_from_output(process_id, &state.text, &state, None, 2) - .expect("first unicode page"); + let first = poll_result_from_output( + process_id, + &state.text, + &state, + "test", + "test", + "test", + "test-v1", + None, + 2, + ) + .expect("first unicode page"); assert_eq!(first.output, "甲乙"); assert!(first.has_more); - let second = - poll_result_from_output(process_id, &state.text, &state, Some(&first.next_cursor), 3) - .expect("second unicode page"); + let second = poll_result_from_output( + process_id, + &state.text, + &state, + "test", + "test", + "test", + "test-v1", + Some(&first.next_cursor), + 3, + ) + .expect("second unicode page"); assert_eq!(second.output, "abc"); assert!(!second.has_more); } @@ -2032,6 +2159,13 @@ setInterval(() => {}, 1000); project_command_source_fingerprint(root).expect("source fingerprint"); let mut poll = start_process_session_at(root, identity.clone(), &spec, source_fingerprint) .expect("start process session"); + #[cfg(target_os = "linux")] + { + assert_eq!(poll.sandbox_backend, "bubblewrap"); + assert_eq!(poll.sandbox_mode, "workspace-write"); + assert_eq!(poll.network_access, "disabled"); + assert_eq!(poll.sandbox_profile_version, "workspace-v1"); + } assert!(has_active_process_sessions_at(root).expect("active process probe")); let mut combined = poll.output.clone(); for _ in 0..20 { @@ -2108,6 +2242,13 @@ setInterval(() => {}, 1000); .expect("record exists"); assert_eq!(record.status, "terminated"); assert!(record.output_ref.is_some()); + #[cfg(target_os = "linux")] + { + assert_eq!(record.sandbox_backend, "bubblewrap"); + assert_eq!(record.sandbox_mode, "workspace-write"); + assert_eq!(record.network_access, "disabled"); + assert_eq!(record.sandbox_profile_version, "workspace-v1"); + } let transcript = read_agent_runtime_json_sidecar_with_max_bytes::( root, @@ -2137,6 +2278,94 @@ setInterval(() => {}, 1000); clear_process_session_registry_for_tests(); } + #[cfg(target_os = "linux")] + #[test] + fn process_session_descendants_inherit_workspace_sandbox() { + let _guard = process_session_test_guard(); + clear_process_session_registry_for_tests(); + let directory = tempfile::tempdir().expect("temp project"); + let root = directory.path().join("workspace"); + let outside = directory.path().join("outside-secret.txt"); + init_local_game_project_at(&root, "process-sandbox-project", "Process Sandbox Project") + .expect("initialize project"); + fs::write(&outside, "OUTSIDE_SECRET").expect("write outside secret"); + fs::create_dir_all(root.join(".git")).expect("create git control directory"); + fs::write(root.join(".git/marker"), "git").expect("write git marker"); + let outside_literal = outside.to_string_lossy().replace('"', "\\\""); + let script = format!( + r#"set -u +if cat "{outside_literal}" >/dev/null 2>&1; then echo OUTSIDE_VISIBLE; else echo OUTSIDE_BLOCKED; fi +if (printf no > .git/blocked-write) 2>/dev/null; then echo GIT_WRITABLE; else echo GIT_BLOCKED; fi +if cat .agent/manifest.json >/dev/null 2>&1; then echo AGENT_VISIBLE; else echo AGENT_HIDDEN; fi +/usr/bin/setsid /bin/bash -lc 'cd /tmp; if test -e "{outside_literal}"; then echo DESCENDANT_VISIBLE; else echo DESCENDANT_BLOCKED; fi' +/usr/bin/python3 - <<'PY' +import socket +s = socket.socket() +s.settimeout(0.2) +try: + s.connect(("1.1.1.1", 53)) + print("NETWORK_VISIBLE") +except OSError: + print("NETWORK_BLOCKED") +finally: + s.close() +PY +"# + ); + fs::write(root.join("sandbox-probe.sh"), script).expect("write sandbox probe"); + let spec = resolve_project_command_spec_at( + &root, + "bash", + &["sandbox-probe.sh".to_string()], + ".", + 30, + ) + .expect("resolve sandbox probe"); + let identity = process_identity("process-sandbox-project"); + let fingerprint = project_command_source_fingerprint(&root).expect("source fingerprint"); + let mut poll = start_process_session_at(&root, identity.clone(), &spec, fingerprint) + .expect("start sandbox probe"); + let mut output = poll.output.clone(); + for _ in 0..30 { + if poll.status != "running" && !poll.has_more { + break; + } + poll = poll_process_session_at( + &root, + &identity, + &poll.process_id, + Some(&poll.next_cursor), + Some(8_000), + Some(250), + ) + .expect("poll sandbox probe"); + output.push_str(&poll.output); + } + assert_eq!(poll.status, "exited", "{output}"); + for marker in [ + "OUTSIDE_BLOCKED", + "GIT_BLOCKED", + "AGENT_HIDDEN", + "DESCENDANT_BLOCKED", + "NETWORK_BLOCKED", + ] { + assert!(output.contains(marker), "missing {marker}: {output}"); + } + for marker in [ + "OUTSIDE_VISIBLE", + "GIT_WRITABLE", + "AGENT_VISIBLE", + "DESCENDANT_VISIBLE", + "NETWORK_VISIBLE", + ] { + assert!(!output.contains(marker), "unexpected {marker}: {output}"); + } + assert_eq!(poll.sandbox_backend, "bubblewrap"); + assert_eq!(poll.sandbox_mode, "workspace-write"); + assert_eq!(poll.network_access, "disabled"); + clear_process_session_registry_for_tests(); + } + #[test] fn process_session_runner_shutdown_reaps_active_session() { let _guard = process_session_test_guard(); @@ -2225,6 +2454,10 @@ setInterval(() => {}, 1000); command_id: "cmd-process-test".to_string(), program: "npm".to_string(), cwd: ".".to_string(), + sandbox_backend: "test-unknown".to_string(), + sandbox_mode: "unknown".to_string(), + network_access: "unknown".to_string(), + sandbox_profile_version: "test-v1".to_string(), status: "failed".to_string(), exit_code: None, signal: Some("output-read-failed".to_string()), @@ -2315,6 +2548,7 @@ setInterval(() => {}, 1000); process_id, &format!("cmd-process-capacity-{index}"), &spec, + None, &"f".repeat(64), "running", ); @@ -2483,6 +2717,10 @@ process.stdin.resume(); command_id: "cmd-stale".to_string(), program: "npm".to_string(), cwd: ".".to_string(), + sandbox_backend: "bubblewrap".to_string(), + sandbox_mode: "workspace-write".to_string(), + network_access: "disabled".to_string(), + sandbox_profile_version: "workspace-v1".to_string(), status: "running".to_string(), exit_code: None, signal: None, @@ -2556,6 +2794,20 @@ process.stdin.resume(); #[cfg(target_os = "linux")] #[test] fn process_session_owner_sigkill_leaves_no_child_process() { + fn project_processes(root: &Path) -> Vec { + let canonical_root = fs::canonicalize(root).expect("canonical test project"); + fs::read_dir("/proc") + .into_iter() + .flatten() + .flatten() + .filter_map(|entry| { + let process_id = entry.file_name().to_string_lossy().parse::().ok()?; + let cwd = fs::read_link(entry.path().join("cwd")).ok()?; + (cwd == canonical_root).then_some(process_id) + }) + .collect() + } + let directory = tempfile::tempdir().expect("temp project"); let root = directory.path(); init_local_game_project_at(root, "owner-process-project", "Owner Process Project") @@ -2587,30 +2839,29 @@ setInterval(() => {}, 1000); .spawn() .expect("spawn owner fixture test process"); let deadline = std::time::Instant::now() + Duration::from_secs(10); - while (!root.join("owner-ready").is_file() || !root.join("child.pid").is_file()) + while (!root.join("owner-ready").is_file() || project_processes(root).is_empty()) && std::time::Instant::now() < deadline { thread::sleep(Duration::from_millis(25)); } - let child_pid = fs::read_to_string(root.join("child.pid")) - .expect("read child pid") - .trim() - .parse::() - .expect("parse child pid"); - assert_eq!(unsafe { libc::kill(child_pid, 0) }, 0); + let sandbox_processes = project_processes(root); + assert!( + !sandbox_processes.is_empty(), + "sandbox child should be visible from host /proc" + ); let owner_pid = i32::try_from(owner.id()).expect("owner pid"); assert_eq!(unsafe { libc::kill(owner_pid, libc::SIGKILL) }, 0); owner.wait().expect("reap owner fixture"); let deadline = std::time::Instant::now() + Duration::from_secs(5); loop { - let result = unsafe { libc::kill(child_pid, 0) }; - if result == -1 && std::io::Error::last_os_error().raw_os_error() == Some(libc::ESRCH) { + let remaining = project_processes(root); + if remaining.is_empty() { break; } assert!( std::time::Instant::now() < deadline, - "Runner owner SIGKILL 后子进程仍存在:pid={child_pid}" + "Runner owner SIGKILL 后 sandbox 子进程仍存在:pids={remaining:?}" ); thread::sleep(Duration::from_millis(25)); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/project.rs b/apps/ai-game-creator-shell/src-tauri/src/project.rs index 337084d80..c7c0b917e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project.rs @@ -3260,6 +3260,10 @@ pub(crate) struct ProjectVerificationResult { pub(crate) timed_out: bool, pub(crate) duration_ms: u64, pub(crate) output: String, + pub(crate) sandbox_backend: String, + pub(crate) sandbox_mode: String, + pub(crate) network_access: String, + pub(crate) sandbox_profile_version: String, pub(crate) log_path: String, pub(crate) updated_at: u64, } @@ -3269,6 +3273,10 @@ struct ProjectVerificationProcessResult { exit_code: Option, timed_out: bool, output: String, + sandbox_backend: String, + sandbox_mode: String, + network_access: String, + sandbox_profile_version: String, } #[derive(Debug)] @@ -3327,33 +3335,6 @@ pub(crate) fn project_verification_npm_program() -> &'static str { } } -fn project_verification_script_shell() -> Result { - #[cfg(unix)] - let path = PathBuf::from("/bin/sh"); - #[cfg(windows)] - let path = std::env::var_os("ComSpec") - .map(PathBuf::from) - .filter(|path| path.is_absolute()) - .ok_or_else(|| "project.verify 无法解析系统 ComSpec".to_string())?; - #[cfg(not(any(unix, windows)))] - let path: PathBuf = - return Err("project.verify 当前平台没有受支持的固定 script shell".to_string()); - - let metadata = fs::metadata(&path).map_err(|error| { - format!( - "project.verify 无法读取固定 script shell {}: {error}", - path.display() - ) - })?; - if !metadata.is_file() { - return Err(format!( - "project.verify 固定 script shell 不是普通文件:{}", - path.display() - )); - } - Ok(path) -} - fn project_verification_script_allowed(script: &str) -> bool { matches!(script, "check" | "typecheck" | "test" | "lint" | "build") || PROJECT_VERIFICATION_NAMED_SCRIPT_PREFIXES @@ -3635,55 +3616,30 @@ async fn run_project_verification_process( spec: &ProjectVerificationSpec, ) -> Result { ensure_project_verification_has_no_project_npmrc(root)?; - let isolated_home = resolve_local_project_path(root, ".agent/runtime/verify-home")?; - let isolated_tmp = resolve_local_project_path(root, ".agent/runtime/verify-tmp")?; - let isolated_cache = resolve_local_project_path(root, ".agent/runtime/npm-cache")?; - fs::create_dir_all(&isolated_home) - .and_then(|()| fs::create_dir_all(&isolated_tmp)) - .and_then(|()| fs::create_dir_all(&isolated_cache)) - .map_err(|error| format!("创建 project.verify 隔离目录失败:{error}"))?; - let script_shell = project_verification_script_shell()?; + let command_spec = + resolve_project_command_spec_at(root, "npm", &spec.arguments, ".", spec.timeout_seconds) + .map_err(|error| format!("project.verify 命令解析失败:{error}"))?; + let launch = prepare_project_command_launch_spec(root, &command_spec) + .map_err(|error| format!("project.verify sandbox preflight 失败:{error}"))?; - let mut command = tokio::process::Command::new(&spec.program); + let mut command = tokio::process::Command::new(&launch.executable); command - .args(&spec.arguments) - .current_dir(root) + .args(&launch.arguments) + .current_dir(&launch.cwd) .env_clear() - .env("CI", "1") - .env("NO_COLOR", "1") - .env("FORCE_COLOR", "0") - .env("HOME", &isolated_home) - .env("USERPROFILE", &isolated_home) - .env("TMPDIR", &isolated_tmp) - .env("TEMP", &isolated_tmp) - .env("TMP", &isolated_tmp) - .env("npm_config_audit", "false") - .env("npm_config_fund", "false") - .env("npm_config_ignore_scripts", "true") - .env("npm_config_script_shell", &script_shell) - .env("npm_config_update_notifier", "false") - .env("npm_config_cache", &isolated_cache) - .env( - "npm_config_userconfig", - isolated_home.join("empty-user.npmrc"), - ) .stdin(std::process::Stdio::null()) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) .kill_on_drop(true); - if let Some(path) = std::env::var_os("PATH") { - command.env("PATH", path); - } - for name in ["SystemRoot", "ComSpec", "PATHEXT"] { - if let Some(value) = std::env::var_os(name) { - command.env(name, value); - } + for (name, value) in &launch.environment { + command.env(name, value); } configure_project_verification_process_group(&mut command); let mut child = command .spawn() .map_err(|error| format!("启动 {} 失败:{error}", spec.program))?; + #[cfg(unix)] let process_id = child.id(); let stdout = child .stdout @@ -3745,6 +3701,10 @@ async fn run_project_verification_process( exit_code, timed_out, output: sanitize_project_verification_output(§ions.join("\n\n")), + sandbox_backend: launch.sandbox_backend, + sandbox_mode: launch.sandbox_mode, + network_access: launch.network_access, + sandbox_profile_version: launch.sandbox_profile_version, }) } @@ -3763,6 +3723,10 @@ pub(crate) async fn run_project_verification_at( exit_code: None, timed_out: false, output: sanitize_project_verification_output(&error), + sandbox_backend: "unavailable".to_string(), + sandbox_mode: "not-established".to_string(), + network_access: "not-established".to_string(), + sandbox_profile_version: "none".to_string(), }, }; let duration_ms = u64::try_from(started_at.elapsed().as_millis()).unwrap_or(u64::MAX); @@ -3776,7 +3740,7 @@ pub(crate) async fn run_project_verification_at( .map_err(|error| format!("创建命令日志目录失败:{}: {error}", parent.display()))?; } let log_entry = format!( - "{updated_at} project.verify {} {status} manager={} exitCode={} timedOut={} durationMs={}\n{}\n", + "{updated_at} project.verify {} {status} manager={} exitCode={} timedOut={} durationMs={} sandboxBackend={} sandboxMode={} networkAccess={} sandboxProfileVersion={}\n{}\n", spec.script, spec.package_manager, process @@ -3785,6 +3749,10 @@ pub(crate) async fn run_project_verification_at( .unwrap_or_else(|| "none".to_string()), process.timed_out, duration_ms, + process.sandbox_backend, + process.sandbox_mode, + process.network_access, + process.sandbox_profile_version, process.output ); fs::OpenOptions::new() @@ -3818,6 +3786,10 @@ pub(crate) async fn run_project_verification_at( timed_out: process.timed_out, duration_ms, output: process.output, + sandbox_backend: process.sandbox_backend, + sandbox_mode: process.sandbox_mode, + network_access: process.network_access, + sandbox_profile_version: process.sandbox_profile_version, log_path: log_path.to_string_lossy().into_owned(), updated_at, }) diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/tests.rs index e23ddb868..9cf7ef63b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests.rs @@ -14763,6 +14763,27 @@ fn pending_tool_action_identity_binds_task_context_and_occurrence() { let first_action_id = agent_runtime_tool_action_id("run-1", 1, 0, 100, &first_fingerprint); let repeated_action_id = agent_runtime_tool_action_id("run-1", 1, 1, 101, &first_fingerprint); assert_ne!(first_action_id, repeated_action_id); + + let command = AgentRuntimeToolAction { + tool: "command.exec".to_string(), + reason: Some("运行测试".to_string()), + input: serde_json::json!({"program":"npm","args":["test"]}), + }; + let command_fingerprint = agent_runtime_tool_action_fingerprint(&command, "验证项目"); + let legacy_payload = serde_json::json!({ + "tool": command.tool.trim(), + "input": &command.input, + "taskContext": "验证项目", + }); + let legacy_fingerprint = format!( + "{:x}", + Sha256::digest(serde_json::to_vec(&legacy_payload).expect("legacy fingerprint payload")) + ); + assert_ne!(command_fingerprint, legacy_fingerprint); + assert_eq!( + AGENT_RUNTIME_ACTION_FINGERPRINT_VERSION, + "sha256-serde-json-v2" + ); } #[test] @@ -28268,6 +28289,13 @@ async fn project_verification_runs_without_prepost_and_records_failure_and_timeo assert_eq!(completed.exit_code, Some(0)); assert!(!completed.timed_out); assert!(completed.output.contains("VERIFY_PROCESS_OK")); + #[cfg(target_os = "linux")] + { + assert_eq!(completed.sandbox_backend, "bubblewrap"); + assert_eq!(completed.sandbox_mode, "workspace-write"); + assert_eq!(completed.network_access, "disabled"); + assert_eq!(completed.sandbox_profile_version, "workspace-v1"); + } assert!(!root.join("precheck-ran.txt").exists()); let named = run_project_verification_at(&root, "test:unit", named_test_command, 15) @@ -28285,6 +28313,8 @@ async fn project_verification_runs_without_prepost_and_records_failure_and_timeo assert_eq!(failed.exit_code, Some(7)); assert!(!failed.timed_out); assert!(failed.output.contains("VERIFY_EXIT_7")); + #[cfg(target_os = "linux")] + assert_eq!(failed.sandbox_backend, "bubblewrap"); let timed_out = run_project_verification_at(&root, "lint", lint_command, 1) .await @@ -28292,6 +28322,8 @@ async fn project_verification_runs_without_prepost_and_records_failure_and_timeo assert_eq!(timed_out.status, "failed"); assert!(timed_out.timed_out); assert!(timed_out.output.contains("1 秒后超时")); + #[cfg(target_os = "linux")] + assert_eq!(timed_out.sandbox_backend, "bubblewrap"); let log = fs::read_to_string(root.join(".agent/logs/command.log")).expect("command log"); assert!(log.contains("project.verify check completed")); @@ -29803,7 +29835,11 @@ async fn process_session_planning_and_system_prompts_define_the_full_lifecycle() "command.poll", "command.stdin", "command.terminate", - "固定 program/argv", + if cfg!(target_os = "linux") { + "workspace-write、network-disabled" + } else { + "固定 program/argv" + }, "processId/cursor", "nextCursor", "waitMs", @@ -29819,7 +29855,6 @@ async fn process_session_planning_and_system_prompts_define_the_full_lifecycle() "system prompt missing {token}" ); } - let root = unique_project_path(); init_local_game_project_at(&root, "project-1", "持久进程规划提示项目").expect("project init"); let (sender, receiver) = mpsc::channel(); @@ -29877,6 +29912,18 @@ async fn process_session_planning_and_system_prompts_define_the_full_lifecycle() "planning prompt missing {token}" ); } + if cfg!(target_os = "linux") { + for token in [ + "受信任 PATH 中的裸可执行名", + "workspace-write", + "network-disabled", + ] { + assert!( + planning_input.contains(token), + "Linux planning prompt missing {token}" + ); + } + } let submit_plan_tool = request_json["tools"] .as_array() .expect("planning function tools") diff --git a/apps/ai-game-creator-shell/src-tauri/tauri.conf.json b/apps/ai-game-creator-shell/src-tauri/tauri.conf.json index c96c74da5..0594ee622 100644 --- a/apps/ai-game-creator-shell/src-tauri/tauri.conf.json +++ b/apps/ai-game-creator-shell/src-tauri/tauri.conf.json @@ -30,6 +30,14 @@ "bundle": { "active": true, "targets": "all", + "linux": { + "deb": { + "depends": ["bubblewrap"] + }, + "rpm": { + "depends": ["bubblewrap"] + } + }, "icon": [ "../../desktop-shell/src-tauri/icons/32x32.png", "../../desktop-shell/src-tauri/icons/128x128.png", diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index f2a2ad32e..f8baa2ed7 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -4280,3 +4280,14 @@ - 决策:容量预检同时扫描 registry 与 durable active / reconciliation record;未解决旧 boot 会话禁止新 start,同项目 4 / 同 Agent 2 的拒绝发生在 revision 推进和 OS spawn 前。终态 record 的 `needsReconciliation=true` 即使 status 为 failed / terminated 也继续阻止 final 和 idle,可信终态落盘后从 registry 清理。 - 安全边界:Linux child wrapper 监测 owning Runner parent PID,Runner 强杀后 fail-closed 杀死同一前台进程组;Windows 使用 kill-on-close Job Object。它们只提供默认同组 / 同 Job 生命周期,不是 OS sandbox,也不能阻止主动 `setsid`、外部 service、读取当前用户可读宿主文件或绕过代理联网。当前仍没有容器、namespace、seccomp、macOS sandbox profile 或 Windows restricted token / AppContainer;实现、UI 和报告不得宣称达到 Codex CLI 级沙箱或主动逃逸下的完整进程树隔离。 - 验收:真实 `gpt-5.5` `process-session` 在无工具配方任务中完成 1 次 start、3 次连续 cursor poll、1 次 stdin 和 1 次 terminate;41 条 task、75 条 event、63 条 Agent DB、8 条 receipt、4 套确认生命周期、唯一 completed / assistant,fixture launch=1,终态 PID / 端口、重放、重复、公共正文 / 密钥 / 诱饵泄漏均为 0。独立 `process-session-runner-kill` 在 readiness 后强杀 owning Runner;21 条 task、34 条 event、36 条 Agent DB,新 boot 保持原 run / session,只产生 1 条 reconciliation,launch=1、PID reconnect / completed / assistant / 重放 / 泄漏均为 0。两个 disposable 项目均按 sentinel 清理。 + +## 2026-07-14 AI 游戏创作 Agent Runtime V1.11 OS 强制工作区沙箱 + +- 决策:Linux `command.exec / command.start / project.verify` 的安全事实源从固定 program / argv 白名单或平行 npm spawn 升级为同一个 bubblewrap OS sandbox launcher。approval policy 继续决定是否确认,sandbox 独立限制文件系统和网络;普通 confirm 永远不能扩大 sandbox。 +- 决策:Linux 只允许受信任系统 bubblewrap,缺失、权限异常或 namespace setup 失败必须在项目命令执行前失败关闭,不用裸 userns、代理变量或宿主全权限回退。当前机器 bubblewrap 0.11.1 已通过真实 namespace smoke,裸 userns 因 AppArmor uid_map 限制不可作为可靠 fallback。 +- 决策:项目根可写,`.git / .agents / .codex / .hermes` 只读,`.agent` 隐藏且不可写,项目外普通用户文件不挂载,network namespace 默认隔离;HOME / TMP / cache 使用 sandbox 私有目录,所有 shell、PTY 和后代继承同一边界。 +- 决策:Linux sandbox 生效后,program 扩展为受信任 PATH 中的裸可执行名,argv 仅保留结构长度与控制字符门禁,允许 shell 管道和项目脚本;Windows 在等价原生 sandbox 落地前继续使用 V1.10 固定白名单与 Job Object,不能宣称通用命令或 Codex CLI 级隔离。 +- 验收门禁:项目内构建 / 测试 / Git 读取成功;项目外读写、控制目录写入和网络访问失败;子进程与 PTY 会话继承相同边界;bubblewrap 不可用时零项目命令执行。真实 Provider 还需在无固定命令配方下自行发现并运行项目命令。 +- 审计与发布:process record v2 保存 launch 当时的 backend / mode / network / profile,后续 process 工具从 durable/live 身份读取,preflight 失败使用 unavailable / not-established,不能按平台静态宣称已建立。共享 `os-workspace-sandbox` capability 只标记 Linux;deb / rpm 声明 bubblewrap 依赖,AppImage 依赖宿主预装并保持 fail-closed。 +- 长进程策略:`command.start` 只用于仓库清单确认的持续交互服务,短命令、探测、构建和测试走 `command.exec`;同一服务成功启动后只沿原 processId 操作。真实验收出现第二条 process record 时立即失败,防止模型主动重复 start 被误判成 Runtime 重放或一直等待总超时。 +- 已知残余:项目 mount preflight 与真实 bwrap launch 是两次独立进程启动。第二次 setup 失败不会让目标程序脱离沙箱执行,但当前缺少 exec-ready 握手,revision 可能已推进且审计无法证明目标是否进入 exec;后续必须在 launcher 层补可信握手,当前文档和验收不得宣称该阶段具备原子保证。 diff --git a/docs/project-memory/shared-memory/development-workflow.md b/docs/project-memory/shared-memory/development-workflow.md index dccb93459..e9c266805 100644 --- a/docs/project-memory/shared-memory/development-workflow.md +++ b/docs/project-memory/shared-memory/development-workflow.md @@ -398,3 +398,9 @@ npm run check:server-rs-ddd 3. 是否有长期知识需要写入 docs/project-memory/shared-memory; 4. 建议的测试命令和提交信息。 ``` + +## AI 游戏创作 App 命令沙箱验证 + +- Linux `command.exec / command.start / project.verify` 必须经过受信任系统 bubblewrap;缺失或 namespace / mount preflight 失败时工具失败关闭,不能回退宿主执行。 +- 修改命令执行、PTY、项目验证或发布配置后,至少运行 `GENARRATIVE_COMMAND_SANDBOX_REAL_TEST=1 cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml command_sandbox -- --nocapture --test-threads=1`、`cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml command_exec` 和 `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml process_session -- --test-threads=1`。 +- 真实门禁必须同时证明项目内 Cargo / npm / Git 成功,项目外普通文件读写失败,`.git / .agent / .agents / .codex / .hermes` 写入失败,网络默认不可达,shell / PTY 后代在 `setsid + chdir` 后仍继承相同边界;process record、poll / stdin / terminate 和 project.verify 审计必须断言 `bubblewrap / workspace-write / disabled / workspace-v1`。另测 `RUSTUP_HOME=$HOME` 与 `.rustup -> $HOME` 必须在目标执行前失败。Linux deb / rpm 包必须声明 `bubblewrap` 依赖;AppImage 发布说明必须要求宿主预装受支持的 bwrap,缺失时只能返回 sandbox unavailable。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index f2b6f6f5f..e86ebb9fa 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -2877,3 +2877,19 @@ - 处理:launching / running / terminating 与任意 status 上的 `needsReconciliation=true` 全部阻止 final reply、finalization journal、completed 和 idle shutdown。terminate 携带最后一次 poll cursor 并返回同一 cursor 的零消费元数据;Unix 完成完整 graceful wait 后只 force kill 同组残留,再 wait / reap / drain PTY,Windows 首版使用 Job force terminate + wait / reap;任何 signal / Job / wait 阶段无法确认都保持 reconciliation。Linux wrapper 监测 owner PID 并在 Runner 强杀后 kill 当前前台进程组,Windows 使用 kill-on-close Job Object;取消 run 也走同一收束路径。主动 `setsid` / 外部 service 和 OS sandbox 仍不在承诺内。 - 验证:活会话下 finalization 和 `runner.shutdown_if_idle` 必须失败关闭;分别验证 graceful handler 尾部输出、宽限超时后的 force、忽略 SIGHUP 的 npm 孙进程和 Windows Job 路径,只有 child 已终态、同组残留已处理且 PTY 尾部排空才出现唯一 terminal record。另用允许程序证明代理和固定 cwd 不是文件系统 / 网络沙箱,不得把该现象误写成测试失败或安全能力。 - 关联:`docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md`、`apps/ai-game-creator-shell/src-tauri/src/runner.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent.rs`。 + +## 命令环境变量、代理和进程组不能冒充 OS 沙箱 + +- 现象:命令看似使用隔离 HOME / TMP、离线包管理器和不可达代理,仍能直接读取宿主用户文件、用原始 socket 联网,或由 `project.verify` 的平行 npm spawn 绕开 `command.exec` 限制。 +- 原因:环境变量和 argv 白名单只约束主动配合的程序,进程组 / Job Object 主要解决生命周期;它们不建立 mount / network namespace,也不能保护 `.agent` Runtime 控制面。只包 `command.exec` 而漏掉 `command.start` 或 `project.verify` 同样属于 fail-open。 +- 处理:Linux 三个入口统一使用受信任系统 bubblewrap;项目根 rw,`.git / .agents / .codex / .hermes` ro,`.agent` 以 000 空 mount 隐藏,项目外普通用户路径不挂载,network namespace 默认隔离,嵌套 userns 禁用。全局 namespace canary 与项目 mount preflight 都必须在 revision / processId / 目标 program 前成功;任何失败都不回退宿主执行。Windows 在等价 restricted process / AppContainer 落地前继续标记为固定命令 legacy 边界。 +- 验证:不能只断言 bwrap argv。必须运行真实目标和子进程,分别检查工作区写入、宿主 sentinel、五个控制目录、原始 socket、PTY stdin / graceful terminate、Runner SIGKILL 后宿主 `/proc` 无项目 cwd 进程,以及 unavailable 时 marker 为零。 +- 关联:`apps/ai-game-creator-shell/src-tauri/src/command_sandbox.rs`、`command_exec.rs`、`process_session.rs`、`project.rs`、`docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md`。 + +## 工具链环境根不能把整个用户目录挂进命令沙箱 + +- 现象:Runner 以 `RUSTUP_HOME=$HOME` 启动,或 `.rustup` 符号链接最终 canonicalize 到 HOME;Agent 随后能在 bubblewrap 内读取 SSH、Cookie 或其它用户文件,并把正文带回命令输出。 +- 原因:只拒绝字面 `/home / root / tmp`,没有拒绝 `/home/`,也没有检查 canonicalize 后目录名是否仍与 `RUSTUP_HOME / JAVA_HOME / GOROOT / DOTNET_ROOT` 类型匹配。 +- 处理:外部工具链环境根 canonicalize 后必须通过窄叶目录校验;用户 HOME、HOME 符号链接目标和类型不匹配目录全部在 mount preflight 阶段失败关闭。不要为了兼容任意自定义环境根放宽成“只读就安全”。 +- 验证:直接 HOME、`.rustup -> HOME` 均返回错误且目标 program 零执行;真实 `.rustup` 叶目录仍可只读挂载,Cargo fixture build 继续通过。 +- 关联:`apps/ai-game-creator-shell/src-tauri/src/command_sandbox.rs`、`command_exec.rs`、`docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md`。 diff --git a/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md b/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md index 7aca0430e..8073e67ee 100644 --- a/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md +++ b/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md @@ -544,6 +544,39 @@ V1.10 为需要持续交互的前台开发进程增加 Runner-owned PTY 会话 2026-07-14 真实 `gpt-5.5` V1.10 验收已通过。`process-session` 在无工具配方任务中自行完成 1 次 start、3 次连续 cursor poll、1 次 stdin 和 1 次 terminate,形成 41 条 task、75 条 event、63 条 Agent DB、8 条 action receipt、4 套确认生命周期、唯一 completed / assistant audit / assistant;fixture launch 为 1,终态后 PID 与端口均消失,副作用重放、重复 action / message / receipt、PTY / stdin 公共正文、密钥和诱饵泄漏均为 0。独立 `process-session-runner-kill` 在 readiness 后真实 `SIGKILL` owning Runner,形成 21 条 task、34 条 event、36 条 Agent DB;新 boot 保持原 run / session 身份,只把旧会话转为 1 条 reconciliation,launch 仍为 1、PID reconnect 为 0、PID / 端口消失,completed / assistant / 副作用重放和公共正文泄漏均为 0。两套 disposable 项目均按 sentinel 清理。 +## V1.11 OS 强制工作区沙箱与通用项目命令 + +V1.11 把命令安全边界从“固定 program + argv 规则 + 隔离环境变量”升级为 OS 强制 workspace sandbox。approval policy 仍决定动作是否需要确认,sandbox 独立决定确认后的真实进程能访问哪些文件和网络;确认不能扩大沙箱权限。首版只把 Linux 标记为通用命令已支持平台,Windows 继续保留 V1.10 受限命令与 Job Object,不伪装成等价隔离。 + +### Linux launch 契约 + +- `command.exec` 与 `command.start` 必须继续共用唯一 `ProjectCommandLaunchSpec`,`project.verify` 必须调用同一 sandbox launcher。真实 executable / argv 在该层包装为受信任系统 bubblewrap;一次性 Tokio child、PTY child wrapper、npm 验证脚本和全部后代不能有绕过该包装的生产 spawn 路径。 +- bubblewrap 只允许从固定系统候选路径解析,文件必须是普通可执行文件且不能被当前普通用户写入。缺失、权限异常、namespace setup 失败或挂载失败都返回 `sandbox-unavailable` / preflight 失败;不得尝试裸 `unshare`、代理断网或无沙箱宿主执行作为 fallback。 +- sandbox 使用独立 user / mount / pid / ipc / uts / cgroup / network namespace,禁用嵌套 user namespace,并启用 parent-death 收束。系统 executable / dynamic runtime 和明确工具链缓存只读挂载;规范项目根以原绝对路径读写挂载,cwd 仍必须是无 symlink / reparse point 的项目内目录。 +- 项目根挂载后覆盖控制目录:`.git / .agents / .codex / .hermes` 存在时按原路径只读挂载;`.agent` 使用不可读写的空 mount 覆盖,命令不能看到 Runtime sidecar、会话、审计或配置。项目内指向外部的 symlink 因目标未挂载而不可访问。 +- HOME / USERPROFILE / TMP / Cargo / npm cache 使用 sandbox 内私有临时目录。允许只读复用不含凭据的工具链 source cache,但外部工具链环境根必须 canonicalize 后再次校验为与变量类型匹配的窄叶目录;`RUSTUP_HOME=$HOME`、`.rustup -> $HOME` 和其它宽用户目录必须失败关闭。不得挂载整个用户 HOME、AppData、SSH、云凭据、Cookie 或 Runtime 配置目录。 +- 网络 namespace 默认无外部网络,HTTP(S) / ALL proxy 与离线包管理器变量只作为纵深防御。`networkAccess` 首版固定 `disabled`,模型输入不能开启;需要联网必须作为未来独立 approval escalation 设计,不能复用普通 confirm 偷渡。 + +### 通用命令契约 + +- Linux sandbox 生效后,`program` 从固定 cargo / npm / node / git / rg 扩展为裸可执行名:1-64 个 ASCII 字母、数字、点、下划线、加号或连字符,不接受绝对路径、相对路径、路径分隔符、NUL 或控制字符。executable 必须从 Runtime 构造的受信任 PATH 解析,项目内同名文件不能劫持。 +- `args` 继续结构化逐项传输,最多 64 项、单项 512 字符、总计 8 KiB、禁止 NUL 和控制字符;Linux 不再按工具子命令维护 cargo / npm / node / git / rg 白名单,也允许 `bash -lc` 在 OS sandbox 内承载管道、重定向和项目脚本。模型不能注入环境变量、sandbox mount、network policy 或宿主 executable path。 +- `command.start` 只用于从仓库清单确认过的持续交互长进程;有限诊断、文件探测、构建和测试使用 `command.exec`。同一服务 start 成功后,后续必须沿返回的 processId 执行 poll / stdin / terminate,不能为了探测、试错、重试或停止另起 session;验收器发现第二条 process record 时立即失败,不能空等到总超时。 +- 非 Linux 平台继续使用 V1.10 固定 program / argv 校验,直到对应平台具备等价的原生强制隔离。共享 capability 的 `os-workspace-sandbox` 必须携带 `platforms=[linux]`,prompt 和 UI 也必须按平台陈述能力,不能让 Windows 用户误以为任意命令已安全开放。 +- Git 控制目录只读,因此 status / diff / log / show 等检查可运行,commit / checkout / reset 等写操作会由 OS 拒绝。源码与普通项目产物可写;`command.exec` 若改变源码,继续推进 revision 并使本次 verification 失效,不能用命令退出码冒充 passed gate。`command.start` 永远不签发验证凭证。 + +### 失败、审计与验收 + +- namespace canary 与项目 mount preflight 失败时,必须在 revision / processId 推进前返回稳定的 sandbox unavailable / setup 错误并保持项目命令未执行。当前 preflight 与随后真实 bwrap launch 是两次独立启动:真实 launch 若在目标 exec 前发生第二次 setup 失败,目标程序不会绕过沙箱执行,但尚无可信 exec-ready 握手证明失败阶段,revision 可能已经推进并按普通命令失败收束。这是 V1.11 已知残余,后续必须用 launcher 握手把“沙箱已建立且目标已 exec”与“仅准备采用沙箱”分开,不能把当前行为描述为原子保证。 +- command log、terminal receipt、Agent DB 和 process record 至少记录固定 `sandboxMode=workspace-write / networkAccess=disabled / sandboxBackend=bubblewrap / sandboxProfileVersion=workspace-v1` 安全元数据,不记录 host mount source、用户 HOME、bwrap 完整 argv 或本地工具链路径。process record 使用 schema v2 持久化 launch 当时的四项元数据,poll / stdin / terminate 和旧 boot reconciliation 必须从 record / live session 读取,不能按当前平台静态猜测;旧 v1 record 只能迁移为 `legacy-unknown`。纯 preflight 失败记录 `unavailable / not-established`,不得谎报 bubblewrap 已建立。 +- 确定性真实进程测试必须证明:项目内构建 / 测试 / Git 读取成功;项目外普通文件读取与写入失败;`.git / .agent / .agents / .codex / .hermes` 写入失败;网络默认不可达;shell 子进程继承同一边界;bwrap 不可用时项目命令零执行且失败关闭。`command.start` 还必须让 PTY 后代实际执行 `setsid + chdir` 后的项目外读取、控制目录写入和原始 socket 负例,并断言 process record 与每段专用审计的四项 sandbox metadata。 +- deb / rpm 发布包声明 `bubblewrap` 宿主依赖;AppImage 不携带 bubblewrap sidecar,发布页和安装检查必须明确要求受支持版本的系统 `/usr/bin/bwrap` 或 `/bin/bwrap`。缺失时命令工具安全失败关闭,但该 AppImage 不算具备可用的通用开发能力。 +- 真实 Provider disposable E2E 不给固定 program、文件名或工具顺序,要求模型自行发现项目技术栈,运行构建、测试和 Git 检查,并用结构化审计证明所有命令都在 workspace-write / network-disabled 下执行。上述门禁通过前不得宣称 V1.11 完成。 + +2026-07-14 最新真实 `gpt-5.5` `llm-runtime` 已按新增 metadata 门禁通过:123 条 task、208 条 event、220 条 Agent DB、15 次成功工具执行、2 次 `command.exec`(先失败后成功)、1 次 `project.verify`、3 个隔离实例、双视口浏览器验证、唯一 completed / assistant;Runner 强杀后 run / session 身份稳定恢复,重复、副作用重放、密钥和诱饵泄漏均为 0。保留现场独立核对 2 条 command.exec 和 1 条 project.verify 审计均为 `bubblewrap / workspace-write / disabled / workspace-v1` 后按 sentinel 清理。 + +同日追加的 `process-session` Provider 复验未计为通过:前三轮模型以不同 actionId / fingerprint 主动重复 start,现场同时存在大量把有限探测误用为 command.start 的失败动作;收紧策略后不再重复 start,但仍因没有形成 challenge 精确回显而以 `process-transcript-interaction-evidence-missing` 失败。13 项确定性 process session 测试和真实 PTY `setsid + chdir` 沙箱负例仍通过,process record / start / poll / stdin / terminate metadata 均正确;在新的 Provider 严格单 launch 交互套件 PASS 前,不更新 V1.10 的历史 process Provider PASS 结论,也不把本次失败描述成已验收。 + ## 验收命令 - `npm run ai-game-creator-shell:typecheck` diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 0ddc29e27..35008425a 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -16,6 +16,8 @@ ## Runtime 边界 +V1.11 的受保护仓库控制目录同时包含 `.git / .agent / .agents / .codex / .hermes`;其中 `.agent` 对项目命令隐藏,其余控制目录只读。 + 2026-07-12 起,通用开发能力的 Runtime V1.1 增量以 [`【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md`](./【技术方案】AI游戏创作Agent%20Runtime%20V1.1-2026-07-12.md) 为编码级事实源。它补充仓库启动上下文、同一发布二进制独立 Runner、受限本地预览浏览器验证、动态隔离子 Agent 和真实 Provider 全链路验收;本文件中“进程内 tokio task”“首轮不预加载项目内容”和“不创建动态执行实例”的旧口径由 V1.1 明确替代,未涉及能力继续沿用本文件。 同一文档的“V1.2 对标 Codex CLI 增量”继续作为受控命令与推理档位的事实源。对一次性 `command.exec` 而言,只接受 Runtime 白名单内的固定 `program` 和逐项 `args` argv,默认 `confirm`,可执行文件解析为项目外绝对路径且子进程只使用安全 PATH;不解析 shell 字符串,不提供管道、重定向、PTY 或后台进程。这里对 PTY 和后台进程的排除仅适用于 `command.exec`,不能用来否定 V1.10 的独立持久进程工具,也不能把 `command.exec` 自身改成长驻入口。`command.exec` 的 action、stdout / stderr、退出码、超时与源码指纹结果统一进入现有 `action / observation`、project revision、verification gate 和 `needs-reconciliation` 链路;只有明确验证型命令且退出码、源码指纹、命令日志、manifest 与 Agent DB 审计全通过才签发 passed gate,Git / rg / cargo metadata / 普通 npm run 只作诊断。首版只请求终止受控进程组,安全等级与 `project.verify` 相同,不宣称已具备完整 OS sandbox 或 detached-process 隔离。 @@ -28,6 +30,8 @@ 2026-07-14 V1.10 真实 `gpt-5.5` 验收:`process-session` 在无工具配方任务中完成 start / 3 次连续 cursor poll / stdin / terminate,41 条 task、75 条 event、63 条 Agent DB、8 条 receipt、4 套确认生命周期和唯一 completed / assistant,fixture launch 为 1,终态 PID / 端口、重放、重复与公共正文 / 密钥 / 诱饵泄漏均为 0。独立 Runner 强杀套件形成 21 条 task、34 条 event、36 条 Agent DB,新 boot 保持原 run / session,只产生 1 条 reconciliation,launch 仍为 1、PID reconnect / final / assistant / 重放 / 泄漏均为 0;两个 disposable 项目均已清理。 +2026-07-14 起,同一文档的“V1.11 OS 强制工作区沙箱与通用项目命令”替代 V1.2 / V1.10 在 Linux 上的固定 program / 严格 argv 白名单边界。`command.exec / command.start` 继续接受结构化 `program + args + cwd`、沿用 confirm policy、durable action、revision、verification、输出和进程会话协议;`project.verify` 也必须复用同一 launcher,不能保留平行的宿主 npm spawn。Linux 只在受信任系统 bubblewrap 创建的 workspace-write sandbox 内启动真实命令:项目根可写,`.git / .agents / .codex / .hermes` 只读,`.agent` 不可见且不可写,项目外普通用户文件不挂载,网络 namespace 默认隔离,所有后代继承相同边界。program 只接受无路径分隔符的裸可执行名并从受信任 PATH 解析,argv 只保留数量、长度和控制字符硬限制;允许 `bash -lc`、Git、构建器、测试器和项目脚本在沙箱内自行工作。外部工具链环境根必须 canonicalize 后校验为窄工具链目录,禁止把整个 HOME 或其符号链接目标挂入沙箱。bubblewrap 缺失、不可执行或 setup 失败必须在项目命令执行前失败关闭,不允许退回宿主全权限。process record v2 与命令审计持久化真实 launch metadata,失败不能按平台静态冒充已建立沙箱。共享 `os-workspace-sandbox` capability 只标记 Linux;Windows 首版继续使用原固定白名单、隔离环境和 Job Object,不能宣称已达到同等 OS sandbox。deb / rpm 声明 bubblewrap 依赖,AppImage 依赖宿主预装且缺失时功能失败关闭;approval 与 sandbox 仍是两层独立门禁。 + 2026-07-12 真实验收:发布 AppData 中的真实 `gpt-5.5` 已通过最终安全收紧后的 `llm-runtime` 套件,覆盖 Runner 强杀恢复且 run/session 身份稳定、仓库上下文、checkpoint/精确修改、失败命令诊断与修复复验、6 套确认生命周期、项目验证、桌面与移动非空画布证据、3 个隔离实例并行和唯一 all-join;95 条 task、161 条 event、137 条 Agent DB、13 条合法工具协议、副作用判重、终态投影、assistant audit、消息、回执和密钥泄露均以结构化落盘事实验收。`full` 套件仍要求 External Editor API 配置,缺失时必须返回 `BLOCKED(editorApi)`,不得记为通过。 2026-07-13 V1.3 真实验收:同一真实 Provider 套件已改为先读取 SHA-256,再用唯一一次 `project.patchset` 同时更新和创建文件,并使用自动 checkpointId 读取 2 项内容 hunks;prepared / completed 审计各 1 条、patchset revision 增量为 1,Runner 强杀恢复、命令和项目验证、双视口浏览器验证、隔离 Agent join、重复副作用与密钥扫描继续全部通过。 @@ -501,7 +505,7 @@ game-project/ - 普通模式渲染聊天区、上传入口、Agent 状态列表和单 Agent 对话;Tauri 主窗口不承载工具台布局,也不承载游戏预览画面。 - 聊天生成草案后会尝试启动只读 `127.0.0.1:` 静态 HTTP server,调用系统外部浏览器打开预览,并把预览地址回到聊天消息;外部浏览器打开失败时保留本地 URL 供用户手动复制。 - 开发模式仅在 Vite dev 环境响应 `?dev` 或 `#dev`,Tauri dev 可额外打开 `developer` 窗口显示专业组、本地项目、预览 iframe 和内置命令日志;正式构建忽略 dev 参数,release 配置只登记一个用户窗口,登录后同窗口进入首页并在项目组 / 项目开发占位之间切换。 -- `check:native-shells` 会运行 `ai-game-creator-shell:check` 和 `ai-game-creator-shell:build -- --no-bundle`,并静态检查 release 只登记一个用户窗口、开发窗口只在 debug 下打开,开发面板必须挂在 `devMode` 分支内,正式用户 App 不能嵌入游戏预览 iframe,release CSP 也不能允许 `frame-src http://127.0.0.1:*`,用户侧预览命令必须调用 `open_local_game_preview` 交给系统外部浏览器,用户主流程不得调用旧工作区窗口切换 command。 +- `check:native-shells` 会运行 `ai-game-creator-shell:check` 和 `ai-game-creator-shell:build -- --no-bundle`,并静态检查 release 只登记 `client / index.html` 这一个用户窗口、开发窗口只在 debug 下打开,开发面板必须挂在 `devMode` 分支内,正式用户 App 不能嵌入游戏预览 iframe,release CSP 也不能允许 `frame-src http://127.0.0.1:*`,用户侧预览命令必须调用 `open_local_game_preview` 交给系统外部浏览器,用户主流程不得调用旧工作区窗口切换 command。 - 共享契约提供 `GAME_CREATION_AGENT_CAPABILITIES` 和内置命令权限枚举;开发模式会展示能力列表。 - 共享契约提供 manifest task schema 和 ready-task 选择器,用于记录任务拆分、专业组、角色模板、依赖、产物、验收条件和当前可执行任务。 - 开发模式可读取、保存、删除短期记忆和长期记忆文件;普通用户通过聊天命令完成同类能力。 diff --git a/packages/shared/src/contracts/gameCreationApp.test.ts b/packages/shared/src/contracts/gameCreationApp.test.ts index 20d6d8fb9..10a9eca09 100644 --- a/packages/shared/src/contracts/gameCreationApp.test.ts +++ b/packages/shared/src/contracts/gameCreationApp.test.ts @@ -236,7 +236,7 @@ describe('AI 游戏创作 App 共享契约', () => { (capability) => capability.id, ); - expect(GAME_CREATION_AGENT_CAPABILITIES).toHaveLength(35); + expect(GAME_CREATION_AGENT_CAPABILITIES).toHaveLength(36); expect(capabilityIds).toEqual( expect.arrayContaining([ 'chat', @@ -264,6 +264,7 @@ describe('AI 游戏创作 App 共享契约', () => { 'local-preview', 'developer-window', 'command-exec', + 'os-workspace-sandbox', 'command-output-read', ]), ); @@ -274,8 +275,17 @@ describe('AI 游戏创作 App 共享契约', () => { ).toEqual({ id: 'command-exec', area: 'dev-runtime', - title: - '受控命令执行(固定 program + argv、非 shell、项目内 cwd、有界输出)', + title: '结构化项目命令执行(Linux 工作区沙箱)', + }); + expect( + GAME_CREATION_AGENT_CAPABILITIES.find( + (capability) => capability.id === 'os-workspace-sandbox', + ), + ).toEqual({ + id: 'os-workspace-sandbox', + area: 'dev-runtime', + title: 'Linux OS 强制工作区沙箱', + platforms: ['linux'], }); expect( GAME_CREATION_AGENT_CAPABILITIES.find( diff --git a/packages/shared/src/contracts/gameCreationApp.ts b/packages/shared/src/contracts/gameCreationApp.ts index a61379d1c..54e4c5d30 100644 --- a/packages/shared/src/contracts/gameCreationApp.ts +++ b/packages/shared/src/contracts/gameCreationApp.ts @@ -79,6 +79,7 @@ export interface GameCreationAgentCapabilityDescriptor { id: string; area: 'user' | 'agent-runtime' | 'local-runtime' | 'dev-runtime'; title: string; + platforms?: readonly ('linux' | 'macos' | 'windows')[]; } export const GAME_CREATION_AGENT_CAPABILITIES = [ @@ -171,8 +172,13 @@ export const GAME_CREATION_AGENT_CAPABILITIES = [ { id: 'command-exec', area: 'dev-runtime', - title: - '受控命令执行(固定 program + argv、非 shell、项目内 cwd、有界输出)', + title: '结构化项目命令执行(Linux 工作区沙箱)', + }, + { + id: 'os-workspace-sandbox', + area: 'dev-runtime', + title: 'Linux OS 强制工作区沙箱', + platforms: ['linux'], }, { id: 'command-output-read', diff --git a/scripts/check-native-shells.mjs b/scripts/check-native-shells.mjs index 498b342f0..9de7d220e 100644 --- a/scripts/check-native-shells.mjs +++ b/scripts/check-native-shells.mjs @@ -1955,10 +1955,10 @@ function assertAiGameCreatorShellUserDevBoundary() { const windows = aiGameCreatorShellTauriConfig.app?.windows ?? []; if ( windows.length !== 1 || - windows[0]?.label !== 'launcher' || - windows[0]?.url !== 'index.html?launcher' + windows[0]?.label !== 'client' || + windows[0]?.url !== 'index.html' ) { - throw new Error('AI game creator release shell must register only the launcher window'); + throw new Error('AI game creator release shell must register only the client window'); } const releaseCsp = aiGameCreatorShellTauriConfig.app?.security?.csp ?? ''; const devCsp = aiGameCreatorShellTauriConfig.app?.security?.devCsp ?? ''; @@ -2031,12 +2031,13 @@ function assertAiGameCreatorShellUserDevBoundary() { } } for (const snippet of [ - 'fn open_developer_window(', - 'WebviewWindowBuilder::new(app, "developer"', - 'open_developer_window(app)?', + '#[cfg(all(debug_assertions, not(test)))]\npub(crate) fn open_developer_window(', + '#[cfg(all(debug_assertions, not(test)))]\n open_developer_window(app.handle())?;', ]) { - if (aiGameCreatorShellTauriSource.includes(snippet)) { - throw new Error(`AI game creator release shell must not auto-open developer windows: ${snippet}`); + if (!aiGameCreatorShellTauriSource.includes(snippet)) { + throw new Error( + `AI game creator developer window must stay compile-time debug-only: ${snippet}`, + ); } } diff --git a/server-rs/crates/shared-contracts/src/game_creation_app.rs b/server-rs/crates/shared-contracts/src/game_creation_app.rs index 1252db9fa..97d05d057 100644 --- a/server-rs/crates/shared-contracts/src/game_creation_app.rs +++ b/server-rs/crates/shared-contracts/src/game_creation_app.rs @@ -97,9 +97,11 @@ pub struct GameCreationAgentCapabilityDescriptor { pub id: &'static str, pub area: &'static str, pub title: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + pub platforms: Option<&'static [&'static str]>, } -pub const GAME_CREATION_AGENT_CAPABILITIES: [GameCreationAgentCapabilityDescriptor; 35] = [ +pub const GAME_CREATION_AGENT_CAPABILITIES: [GameCreationAgentCapabilityDescriptor; 36] = [ capability("chat", "user", "聊天入口"), capability("file-upload", "user", "上传文件"), capability("built-in-commands", "agent-runtime", "内置命令调用"), @@ -153,13 +155,15 @@ pub const GAME_CREATION_AGENT_CAPABILITIES: [GameCreationAgentCapabilityDescript capability( "command-exec", "dev-runtime", - "受控命令执行(固定 program + argv、非 shell、项目内 cwd、有界输出)", + "结构化项目命令执行(Linux 工作区沙箱)", ), - capability( - "command-output-read", + platform_capability( + "os-workspace-sandbox", "dev-runtime", - "命令输出分页回查", + "Linux OS 强制工作区沙箱", + &["linux"], ), + capability("command-output-read", "dev-runtime", "命令输出分页回查"), capability("guardrails", "dev-runtime", "权限 Gate"), capability("project-policy", "dev-runtime", "项目级权限策略"), capability("trace-log", "dev-runtime", "执行日志"), @@ -170,7 +174,26 @@ const fn capability( area: &'static str, title: &'static str, ) -> GameCreationAgentCapabilityDescriptor { - GameCreationAgentCapabilityDescriptor { id, area, title } + GameCreationAgentCapabilityDescriptor { + id, + area, + title, + platforms: None, + } +} + +const fn platform_capability( + id: &'static str, + area: &'static str, + title: &'static str, + platforms: &'static [&'static str], +) -> GameCreationAgentCapabilityDescriptor { + GameCreationAgentCapabilityDescriptor { + id, + area, + title, + platforms: Some(platforms), + } } #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] @@ -981,7 +1004,7 @@ mod tests { #[test] fn capabilities_cover_standard_agent_runtime_needs() { - assert_eq!(GAME_CREATION_AGENT_CAPABILITIES.len(), 35); + assert_eq!(GAME_CREATION_AGENT_CAPABILITIES.len(), 36); let ids = GAME_CREATION_AGENT_CAPABILITIES .iter() @@ -1010,6 +1033,7 @@ mod tests { "visual-inspection", "developer-window", "command-exec", + "os-workspace-sandbox", "command-output-read", ] { assert!(ids.contains(&expected), "missing {expected}"); @@ -1019,10 +1043,14 @@ mod tests { .find(|capability| capability.id == "command-exec") .expect("command-exec capability should exist"); assert_eq!(command_exec.area, "dev-runtime"); - assert_eq!( - command_exec.title, - "受控命令执行(固定 program + argv、非 shell、项目内 cwd、有界输出)" - ); + assert_eq!(command_exec.title, "结构化项目命令执行(Linux 工作区沙箱)"); + let workspace_sandbox = GAME_CREATION_AGENT_CAPABILITIES + .iter() + .find(|capability| capability.id == "os-workspace-sandbox") + .expect("os-workspace-sandbox capability should exist"); + assert_eq!(workspace_sandbox.area, "dev-runtime"); + assert_eq!(workspace_sandbox.title, "Linux OS 强制工作区沙箱"); + assert_eq!(workspace_sandbox.platforms, Some(&["linux"][..])); let command_output_read = GAME_CREATION_AGENT_CAPABILITIES .iter() .find(|capability| capability.id == "command-output-read")